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

251
web/frontend/app/app.css Normal file
View File

@@ -0,0 +1,251 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen;
}
@theme {
--font-sans:
'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: 'Volkhov', ui-serif, Georgia, serif;
}
h1,
h2,
h3,
h4,
h5,
h6,
.card-title {
font-family: var(--font-serif);
}
/* ─────────────────────────────────────────
BIERGARTEN LAGER
Light. Warm parchment base, mellow amber
primary, softened mahogany secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" {
name: 'biergarten-lager';
default: true;
prefersdark: false;
color-scheme: 'light';
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
--color-primary: oklch(65% 0.085 62); /* mellow amber */
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
--color-accent: oklch(93% 0.015 90); /* frothy cream */
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
--color-info: oklch(46% 0.065 145); /* muted hop green */
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
--color-success: oklch(70% 0.06 122); /* soft barley gold */
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
--color-warning: oklch(72% 0.09 56); /* toned amber */
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
--color-error: oklch(54% 0.09 22); /* restrained cherry */
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
--radius-selector: 0.375rem;
--radius-field: 0.5rem;
--radius-box: 0.875rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
/* ─────────────────────────────────────────
BIERGARTEN STOUT
Dark. Charred barrel base, golden amber
primary, deep mahogany secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" {
name: 'biergarten-stout';
default: false;
prefersdark: true;
color-scheme: 'dark';
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
--color-base-300: oklch(23% 0.01 42); /* deep brown */
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
--color-primary: oklch(68% 0.055 60); /* golden amber */
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
--color-info: oklch(60% 0.04 145); /* cool hop green */
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
--color-success: oklch(66% 0.038 120); /* fresh barley */
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
--color-warning: oklch(70% 0.055 55); /* amber harvest */
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
--radius-selector: 0.375rem;
--radius-field: 0.5rem;
--radius-box: 0.875rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
/* ─────────────────────────────────────────
BIERGARTEN CASSIS
Dark. Blackberry base, cassis berry
primary, sour cherry secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" {
name: 'biergarten-cassis';
default: false;
prefersdark: false;
color-scheme: 'dark';
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
--color-base-300: oklch(22% 0.016 290); /* dark grape */
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
--color-info: oklch(62% 0.04 250); /* muted indigo */
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
--color-success: oklch(65% 0.04 145); /* elderberry green */
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
--color-warning: oklch(70% 0.05 65); /* sour apricot */
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
--color-error: oklch(50% 0.055 22); /* kriek red */
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
--color-muted: oklch(
77.6% 0.022 300
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
/* ─────────────────────────────────────────
BIERGARTEN WEIZEN
Light. Near-white barley-green base,
fresh-cut barley primary, sage secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" {
name: 'biergarten-weizen';
default: false;
prefersdark: false;
color-scheme: 'light';
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
--color-base-300: oklch(92% 0.019 116); /* light straw */
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
--color-info: oklch(38% 0.065 232); /* clear summer sky */
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
--color-success: oklch(38% 0.085 145); /* young shoot green */
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
--color-error: oklch(52% 0.1 18); /* dusty rose red */
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
--radius-selector: 2rem;
--radius-field: 2rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}

View File

@@ -0,0 +1,144 @@
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
} from '@headlessui/react';
import { Link } from 'react-router';
interface NavbarProps {
auth: {
username: string;
accessToken: string;
refreshToken: string;
userAccountId: string;
} | null;
}
export default function Navbar({ auth }: NavbarProps) {
const navLinks = [
{ to: '/theme', label: 'Theme' },
{ to: '/beers', label: 'Beers' },
{ to: '/breweries', label: 'Breweries' },
{ to: '/beer-styles', label: 'Beer Styles' },
];
return (
<Disclosure
as="nav"
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
>
{({ open }) => (
<>
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
<div className="navbar-start gap-2">
<DisclosureButton
className="btn btn-ghost btn-square lg:hidden"
aria-label="Toggle navigation"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="h-5 w-5 stroke-current"
>
{open ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</DisclosureButton>
<Link to="/" className="text-xl font-bold">
🍺 The Biergarten App
</Link>
</div>
<div className="navbar-center hidden lg:flex gap-2">
{navLinks.map((link) => (
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
{link.label}
</Link>
))}
</div>
<div className="navbar-end gap-2">
{!auth && (
<Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
Register User
</Link>
)}
{auth ? (
<>
<Link to="/dashboard" className="btn btn-primary btn-sm">
Dashboard
</Link>
<Menu as="div" className="relative">
<MenuButton className="btn btn-ghost btn-sm">
{auth.username}
</MenuButton>
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
<MenuItem>
{({ focus }) => (
<Link to="/dashboard" className={focus ? 'active' : ''}>
Dashboard
</Link>
)}
</MenuItem>
<MenuItem>
{({ focus }) => (
<Link to="/logout" className={focus ? 'active' : ''}>
Logout
</Link>
)}
</MenuItem>
</MenuItems>
</Menu>
</>
) : (
<Link to="/login" className="btn btn-primary btn-sm">
Login
</Link>
)}
</div>
</div>
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
<div className="flex flex-col gap-2">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className="btn btn-ghost btn-sm justify-start"
>
{link.label}
</Link>
))}
{!auth && (
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
Register User
</Link>
)}
</div>
</DisclosurePanel>
</>
)}
</Disclosure>
);
}

View File

@@ -0,0 +1,40 @@
import { Description, Field, Label } from '@headlessui/react';
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
error?: string;
hint?: string;
labelClassName?: string;
inputClassName?: string;
hintClassName?: string;
};
export default function FormField({
label,
error,
hint,
className,
labelClassName,
inputClassName,
hintClassName,
...inputProps
}: FormFieldProps) {
return (
<Field className={className ?? 'space-y-1'}>
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
{label}
</Label>
<input
{...inputProps}
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
/>
{error ? (
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
) : hint ? (
<Description className={hintClassName ?? 'label'}>{hint}</Description>
) : null}
</Field>
);
}

View File

@@ -0,0 +1,31 @@
import { Button } from '@headlessui/react';
interface SubmitButtonProps {
isSubmitting: boolean;
idleText: string;
submittingText: string;
className?: string;
}
export default function SubmitButton({
isSubmitting,
idleText,
submittingText,
className,
}: SubmitButtonProps) {
return (
<Button
type="submit"
disabled={isSubmitting}
className={className ?? 'btn btn-primary w-full mt-2'}
>
{isSubmitting ? (
<>
<span className="loading loading-spinner loading-sm" /> {submittingText}
</>
) : (
idleText
)}
</Button>
);
}

View File

@@ -0,0 +1,25 @@
import { Toaster } from 'react-hot-toast';
export default function ToastProvider() {
return (
<Toaster
position="top-right"
toastOptions={{
duration: 3500,
className: 'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
success: {
iconTheme: {
primary: 'var(--color-success)',
secondary: 'var(--color-success-content)',
},
},
error: {
iconTheme: {
primary: 'var(--color-error)',
secondary: 'var(--color-error-content)',
},
},
}}
/>
);
}

View File

@@ -0,0 +1,6 @@
import toast from 'react-hot-toast';
export const showSuccessToast = (message: string) => toast.success(message);
export const showErrorToast = (message: string) => toast.error(message);
export const showInfoToast = (message: string) => toast(message);
export const dismissToasts = () => toast.dismiss();

View File

@@ -0,0 +1,162 @@
import { createCookieSessionStorage, redirect } from 'react-router';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
userAccountId: string;
username: string;
}
interface ApiResponse<T> {
message: string;
payload: T;
}
interface LoginPayload {
userAccountId: string;
username: string;
refreshToken: string;
accessToken: string;
}
interface RegistrationPayload extends LoginPayload {
confirmationEmailSent: boolean;
}
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
path: '/',
sameSite: 'lax',
secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
secure: process.env.NODE_ENV === 'production',
},
});
export async function getSession(request: Request) {
return sessionStorage.getSession(request.headers.get('Cookie'));
}
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
return sessionStorage.commitSession(session);
}
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
return sessionStorage.destroySession(session);
}
export async function requireAuth(request: Request): Promise<AuthTokens> {
const session = await getSession(request);
const accessToken = session.get('accessToken');
const refreshToken = session.get('refreshToken');
if (!accessToken || !refreshToken) {
throw redirect('/login');
}
return {
accessToken,
refreshToken,
userAccountId: session.get('userAccountId'),
username: session.get('username'),
};
}
export async function getOptionalAuth(request: Request): Promise<AuthTokens | null> {
const session = await getSession(request);
const accessToken = session.get('accessToken');
if (!accessToken) return null;
return {
accessToken,
refreshToken: session.get('refreshToken'),
userAccountId: session.get('userAccountId'),
username: session.get('username'),
};
}
export async function login(username: string, password: string) {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Login failed (${res.status})`);
}
const data: ApiResponse<LoginPayload> = await res.json();
return data.payload;
}
export async function register(body: {
username: string;
firstName: string;
lastName: string;
email: string;
dateOfBirth: string;
password: string;
}) {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Registration failed (${res.status})`);
}
const data: ApiResponse<RegistrationPayload> = await res.json();
return data.payload;
}
export async function refreshTokens(refreshToken: string) {
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
throw new Error('Token refresh failed');
}
const data: ApiResponse<LoginPayload> = await res.json();
return data.payload;
}
export async function confirmEmail(token: string, accessToken: string) {
const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Confirmation failed (${res.status})`);
}
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
return data.payload;
}
export async function createAuthSession(payload: LoginPayload, redirectTo: string) {
const session = await sessionStorage.getSession();
session.set('accessToken', payload.accessToken);
session.set('refreshToken', payload.refreshToken);
session.set('userAccountId', payload.userAccountId);
session.set('username', payload.username);
return redirect(redirectTo, {
headers: { 'Set-Cookie': await commitSession(session) },
});
}

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
export const loginSchema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
});
export type LoginSchema = z.infer<typeof loginSchema>;
export const registerSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters'),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email address'),
dateOfBirth: z.string().min(1, 'Date of birth is required'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
});
export type RegisterSchema = z.infer<typeof registerSchema>;

View File

@@ -0,0 +1,41 @@
export type ThemeName =
| 'biergarten-lager'
| 'biergarten-stout'
| 'biergarten-cassis'
| 'biergarten-weizen';
export interface ThemeOption {
value: ThemeName;
label: string;
vibe: string;
}
export const defaultThemeName: ThemeName = 'biergarten-lager';
export const themeStorageKey = 'biergarten-theme';
export const biergartenThemes: ThemeOption[] = [
{
value: 'biergarten-lager',
label: 'Biergarten Lager',
vibe: 'Muted parchment, mellow amber, daytime beer garden',
},
{
value: 'biergarten-stout',
label: 'Biergarten Stout',
vibe: 'Charred barrel, deep roast, cozy evening cellar',
},
{
value: 'biergarten-cassis',
label: 'Biergarten Cassis',
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
},
{
value: 'biergarten-weizen',
label: 'Biergarten Weizen',
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
},
];
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
return biergartenThemes.some((theme) => theme.value === value);
}

90
web/frontend/app/root.tsx Normal file
View File

@@ -0,0 +1,90 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router';
import type { Route } from './+types/root';
import './app.css';
import Navbar from './components/Navbar';
import ToastProvider from './components/toast/ToastProvider';
import { getOptionalAuth } from './lib/auth.server';
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap',
},
];
export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await getOptionalAuth(request);
return { auth };
};
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App({ loaderData }: Route.ComponentProps) {
const { auth } = loaderData;
return (
<>
<Navbar auth={auth} />
<ToastProvider />
<Outlet />
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details =
error.status === 404
? 'The requested page could not be found.'
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

View File

@@ -0,0 +1,14 @@
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('theme', 'routes/theme.tsx'),
route('login', 'routes/login.tsx'),
route('register', 'routes/register.tsx'),
route('logout', 'routes/logout.tsx'),
route('dashboard', 'routes/dashboard.tsx'),
route('confirm', 'routes/confirm.tsx'),
route('beers', 'routes/beers.tsx'),
route('breweries', 'routes/breweries.tsx'),
route('beer-styles', 'routes/beer-styles.tsx'),
] satisfies RouteConfig;

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>
);
}