Move website directory

This commit is contained in:
Aaron Po
2026-04-27 16:00:11 -04:00
parent 189bce040b
commit 5a21589029
58 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import type { Route } from './+types/beer-styles';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Beer Styles | The Biergarten App' }];
}
export default function BeerStyles() {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
<p className="text-base-content/70">Learn about different beer styles.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { Route } from './+types/beers';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Beers | The Biergarten App' }];
}
export default function Beers() {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Beers</h1>
<p className="text-base-content/70">Explore our collection of beers.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { Route } from './+types/breweries';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Breweries | The Biergarten App' }];
}
export default function Breweries() {
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
<p className="text-base-content/70">Discover our partner breweries.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useEffect } from 'react';
import { Link } from 'react-router';
import { showErrorToast, showSuccessToast } from '../components/toast/toast';
import { confirmEmail, requireAuth } from '../lib/auth.server';
import type { Route } from './+types/confirm';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Confirm Email | The Biergarten App' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await requireAuth(request);
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
return { success: false as const, error: 'Missing confirmation token.' };
}
try {
const payload = await confirmEmail(token, auth.accessToken);
return {
success: true as const,
confirmedDate: payload.confirmedDate,
};
} catch (err) {
return {
success: false as const,
error: err instanceof Error ? err.message : 'Confirmation failed.',
};
}
}
export default function Confirm({ loaderData }: Route.ComponentProps) {
useEffect(() => {
if (loaderData.success) {
showSuccessToast('Email confirmed successfully.');
return;
}
showErrorToast(loaderData.error);
}, [loaderData]);
return (
<div className="hero min-h-screen bg-base-200">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body items-center text-center gap-4">
{loaderData.success ? (
<>
<div className="text-success text-6xl"></div>
<h1 className="card-title text-2xl">Email Confirmed!</h1>
<p className="text-base-content/70">
Your email address has been successfully verified.
</p>
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
Confirmed at
</span>
<p className="font-mono mt-1">
{new Date(loaderData.confirmedDate).toLocaleString()}
</p>
</div>
<div className="card-actions w-full pt-2">
<Link to="/dashboard" className="btn btn-primary w-full">
Go to Dashboard
</Link>
</div>
</>
) : (
<>
<div className="text-error text-6xl"></div>
<h1 className="card-title text-2xl">Confirmation Failed</h1>
<div role="alert" className="alert alert-error alert-soft w-full">
<span>{loaderData.error}</span>
</div>
<p className="text-base-content/70 text-sm">
The confirmation link may have expired (valid for 30 minutes) or already
been used.
</p>
<div className="card-actions w-full pt-2 flex-col gap-2">
<Link to="/dashboard" className="btn btn-primary w-full">
Back to Dashboard
</Link>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { requireAuth } from '../lib/auth.server';
import type { Route } from './+types/dashboard';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Dashboard | The Biergarten App' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await requireAuth(request);
return {
username: auth.username,
userAccountId: auth.userAccountId,
};
}
export default function Dashboard({ loaderData }: Route.ComponentProps) {
const { username, userAccountId } = loaderData;
return (
<div className="min-h-screen bg-base-200">
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
<p className="text-base-content/70">
You are successfully authenticated. This is a protected page that requires a
valid session.
</p>
<div className="bg-base-200 rounded-box p-4 mt-2">
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
Session Info
</p>
<div className="stats stats-vertical w-full">
<div className="stat py-2">
<div className="stat-title">Username</div>
<div className="stat-value text-lg font-mono">{username}</div>
</div>
<div className="stat py-2">
<div className="stat-title">User ID</div>
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
</div>
</div>
</div>
</div>
</div>
<div className="card bg-base-100 shadow">
<div className="card-body">
<h2 className="card-title">Auth Flow Demo</h2>
<p className="text-sm text-base-content/70">
This demo showcases the following authentication features:
</p>
<ul className="list">
<li className="list-row">
<div>
<p className="font-semibold">Login</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
username &amp; password
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Register</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/register</code> with
full user details
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Session</p>
<p className="text-sm text-base-content/60">
JWT access &amp; refresh tokens stored in an HTTP-only cookie
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Protected Routes</p>
<p className="text-sm text-base-content/60">
This dashboard requires authentication via{' '}
<code className="kbd kbd-sm">requireAuth()</code>
</p>
</div>
</li>
<li className="list-row">
<div>
<p className="font-semibold">Token Refresh</p>
<p className="text-sm text-base-content/60">
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with
refresh token
</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Link } from 'react-router';
import { getOptionalAuth } from '../lib/auth.server';
import type { Route } from './+types/home';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'The Biergarten App' },
{ name: 'description', content: 'Welcome to The Biergarten App' },
];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request);
return { username: auth?.username ?? null };
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { username } = loaderData;
return (
<div className="hero min-h-screen bg-base-200">
<div className="hero-content text-center">
<div className="max-w-md space-y-6">
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
<p className="text-lg text-base-content/70">Authentication Demo</p>
{username ? (
<>
<p className="text-base-content/80">
Welcome back, <span className="font-semibold text-primary">{username}</span>
!
</p>
<div className="flex gap-3 justify-center">
<Link to="/dashboard" className="btn btn-primary">
Dashboard
</Link>
<Link to="/logout" className="btn btn-ghost">
Logout
</Link>
</div>
</>
) : (
<div className="flex gap-3 justify-center">
<Link to="/login" className="btn btn-primary">
Login
</Link>
<Link to="/register" className="btn btn-outline">
Register
</Link>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { HomeSimpleDoor, LogIn, UserPlus } from 'iconoir-react';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
import FormField from '../components/forms/FormField';
import SubmitButton from '../components/forms/SubmitButton';
import { showErrorToast } from '../components/toast/toast';
import { createAuthSession, getOptionalAuth, login } from '../lib/auth.server';
import { loginSchema, type LoginSchema } from '../lib/schemas';
import type { Route } from './+types/login';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Login | The Biergarten App' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request);
if (auth) throw redirect('/dashboard');
return null;
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const result = loginSchema.safeParse({
username: formData.get('username'),
password: formData.get('password'),
});
if (!result.success) {
return { error: result.error.issues[0].message };
}
try {
const payload = await login(result.data.username, result.data.password);
return createAuthSession(payload, '/dashboard');
} catch (err) {
return { error: err instanceof Error ? err.message : 'Login failed.' };
}
}
export default function Login({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.state === 'submitting';
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
const onSubmit = handleSubmit((data) => {
submit(data, { method: 'post' });
});
useEffect(() => {
if (actionData?.error) {
showErrorToast(actionData.error);
}
}, [actionData?.error]);
return (
<div className="hero min-h-screen bg-base-200">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body gap-4">
<div className="text-center">
<h1 className="card-title text-3xl justify-center gap-2">
<LogIn className="size-7" aria-hidden="true" />
Login
</h1>
<p className="text-base-content/70">Sign in to your Biergarten account</p>
</div>
{actionData?.error && (
<div role="alert" className="alert alert-error alert-soft">
<span>{actionData.error}</span>
</div>
)}
<form onSubmit={onSubmit} className="space-y-3">
<FormField
id="username"
type="text"
autoComplete="username"
placeholder="your_username"
label="Username"
error={errors.username?.message}
{...register('username')}
/>
<FormField
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
label="Password"
error={errors.password?.message}
{...register('password')}
/>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Sign In"
submittingText="Signing in..."
/>
</form>
<div className="divider text-xs">New here?</div>
<div className="text-center space-y-2">
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
<UserPlus className="size-4" aria-hidden="true" />
Create an account
</Link>
<Link
to="/"
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
>
<HomeSimpleDoor className="size-4" aria-hidden="true" />
Back to home
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from 'react-router';
import { destroySession, getSession } from '../lib/auth.server';
import type { Route } from './+types/logout';
export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request);
return redirect('/', {
headers: { 'Set-Cookie': await destroySession(session) },
});
}

View File

@@ -0,0 +1,189 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
import FormField from '../components/forms/FormField';
import SubmitButton from '../components/forms/SubmitButton';
import { showErrorToast } from '../components/toast/toast';
import { createAuthSession, getOptionalAuth, register } from '../lib/auth.server';
import { registerSchema, type RegisterSchema } from '../lib/schemas';
import type { Route } from './+types/register';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Register | The Biergarten App' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request);
if (auth) throw redirect('/dashboard');
return null;
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const result = registerSchema.safeParse({
username: formData.get('username'),
firstName: formData.get('firstName'),
lastName: formData.get('lastName'),
email: formData.get('email'),
dateOfBirth: formData.get('dateOfBirth'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
});
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
return { error: null, fieldErrors };
}
try {
const body = {
username: result.data.username,
firstName: result.data.firstName,
lastName: result.data.lastName,
email: result.data.email,
dateOfBirth: result.data.dateOfBirth,
password: result.data.password,
};
const payload = await register(body);
return createAuthSession(payload, '/dashboard');
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Registration failed.',
fieldErrors: null,
};
}
}
export default function Register({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const submit = useSubmit();
const isSubmitting = navigation.state === 'submitting';
const {
register: field,
handleSubmit,
formState: { errors },
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
const onSubmit = handleSubmit((data) => {
submit(data, { method: 'post' });
});
useEffect(() => {
if (actionData?.error) {
showErrorToast(actionData.error);
}
}, [actionData?.error]);
return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
<div className="card-body gap-4">
<div className="text-center">
<h1 className="card-title text-3xl justify-center">Register</h1>
<p className="text-base-content/70">Create your Biergarten account</p>
</div>
{actionData?.error && (
<div role="alert" className="alert alert-error alert-soft">
<span>{actionData.error}</span>
</div>
)}
<form onSubmit={onSubmit} className="space-y-3">
<FormField
id="username"
type="text"
autoComplete="username"
placeholder="your_username"
label="Username"
hint="3-64 characters, alphanumeric and . _ -"
error={errors.username?.message}
{...field('username')}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
id="firstName"
type="text"
autoComplete="given-name"
placeholder="Jane"
label="First Name"
error={errors.firstName?.message}
{...field('firstName')}
/>
<FormField
id="lastName"
type="text"
autoComplete="family-name"
placeholder="Doe"
label="Last Name"
error={errors.lastName?.message}
{...field('lastName')}
/>
</div>
<FormField
id="email"
type="email"
autoComplete="email"
placeholder="jane@example.com"
label="Email"
error={errors.email?.message}
{...field('email')}
/>
<FormField
id="dateOfBirth"
type="date"
label="Date of Birth"
hint="Must be 19 years or older"
error={errors.dateOfBirth?.message}
{...field('dateOfBirth')}
/>
<FormField
id="password"
type="password"
autoComplete="new-password"
placeholder="••••••••"
label="Password"
hint="8+ chars: uppercase, lowercase, digit, special character"
error={errors.password?.message}
{...field('password')}
/>
<FormField
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder="••••••••"
label="Confirm Password"
error={errors.confirmPassword?.message}
{...field('confirmPassword')}
/>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Create Account"
submittingText="Creating account..."
/>
</form>
<div className="divider text-xs">Already have an account?</div>
<div className="text-center space-y-2">
<Link to="/login" className="btn btn-outline btn-sm w-full">
Sign in
</Link>
<Link to="/" className="link link-hover text-sm text-base-content/60">
Back to home
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import {
biergartenThemes,
defaultThemeName,
isBiergartenTheme,
themeStorageKey,
type ThemeName,
} from '../lib/themes';
import type { Route } from './+types/theme';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'Theme | The Biergarten App' },
{
name: 'description',
content: 'Theme guide and switcher for The Biergarten App',
},
];
}
function applyTheme(theme: ThemeName) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(themeStorageKey, theme);
}
export default function ThemePage() {
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
if (typeof window === 'undefined') {
return defaultThemeName;
}
const savedTheme = localStorage.getItem(themeStorageKey);
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
});
useEffect(() => {
applyTheme(selectedTheme);
}, [selectedTheme]);
const activeTheme =
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
return (
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<section className="card border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-4">
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
<p className="text-base-content/70">
Four themes, four moods from the sun-bleached clarity of a Weizen afternoon
to the deep berry dark of a Cassis barrel. Every theme shares the same semantic
token structure so components stay consistent while the atmosphere shifts
completely.
</p>
<div className="alert alert-info alert-soft">
<span>
Active theme: <strong>{activeTheme.label}</strong> {activeTheme.vibe}
</span>
</div>
</div>
</section>
<section className="card border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-4">
<h2 className="card-title text-2xl">Theme switcher</h2>
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
<div
className="join join-vertical sm:join-horizontal"
role="radiogroup"
aria-label="Theme selector"
>
{biergartenThemes.map((theme) => {
const checked = selectedTheme === theme.value;
return (
<label
key={theme.value}
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
>
<input
type="radio"
name="theme"
value={theme.value}
className="sr-only"
checked={checked}
onChange={() => {
setSelectedTheme(theme.value);
applyTheme(theme.value);
}}
/>
{theme.label}
</label>
);
})}
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="card border border-base-300 bg-base-100 shadow-lg">
<div className="card-body">
<h3 className="card-title">Brand colors</h3>
<div className="grid grid-cols-2 gap-2 text-sm font-medium">
<div className="rounded-box bg-primary p-3 text-primary-content">
Primary
</div>
<div className="rounded-box bg-secondary p-3 text-secondary-content">
Secondary
</div>
<div className="rounded-box bg-accent p-3 text-accent-content">Accent</div>
<div className="rounded-box bg-neutral p-3 text-neutral-content">
Neutral
</div>
</div>
</div>
</div>
<div className="card border border-base-300 bg-base-100 shadow-lg">
<div className="card-body">
<h3 className="card-title">Status colors</h3>
<div className="space-y-2 text-sm font-medium">
<div className="rounded-box bg-info p-3 text-info-content">Info</div>
<div className="rounded-box bg-success p-3 text-success-content">
Success
</div>
<div className="rounded-box bg-warning p-3 text-warning-content">
Warning
</div>
<div className="rounded-box bg-error p-3 text-error-content">Error</div>
</div>
</div>
</div>
<div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
<div className="card-body">
<h3 className="card-title">Core style outline</h3>
<ul className="list list-disc space-y-2 pl-5 text-base-content/80">
<li>Warm serif headings paired with clear sans-serif body text</li>
<li>Rounded, tactile surfaces with subtle depth and grain</li>
<li>Semantic token usage to keep contrast consistent in both themes</li>
</ul>
</div>
</div>
</section>
<section className="card border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-4">
<h2 className="card-title text-2xl">Component preview</h2>
<div className="flex flex-wrap gap-3">
<button className="btn btn-primary">Primary action</button>
<button className="btn btn-secondary">Secondary action</button>
<button className="btn btn-accent">Accent action</button>
<button className="btn btn-ghost">Ghost action</button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div role="alert" className="alert alert-success alert-soft">
<span>Theme tokens are applied consistently.</span>
</div>
<div role="alert" className="alert alert-warning alert-soft">
<span>Use semantic colors over hard-coded color values.</span>
</div>
</div>
</div>
</section>
</div>
</main>
);
}