add 'biergarten-cassis' and 'biergarten-weizen' themes update CSS variables, and refine .gitignore

This commit is contained in:
Aaron Po
2026-03-15 18:19:24 -04:00
parent 60b784e365
commit 9a0eadc514
20 changed files with 2493 additions and 979 deletions

8
.gitignore vendored
View File

@@ -15,6 +15,11 @@
# production # production
/build /build
# project-specific build artifacts
/src/Website/build/
/src/Website/.react-router/
/test-results/
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
@@ -42,6 +47,9 @@ next-env.d.ts
# vscode # vscode
.vscode .vscode
.idea/
*.swp
*.swo
/cloudinary-images /cloudinary-images

View File

@@ -0,0 +1,4 @@
build
node_modules
.react-router
package-lock.json

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"printWidth": 100,
"singleQuote": false,
"trailingComma": "all",
"semi": true,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always"
}

View File

@@ -14,6 +14,9 @@ type Pages = {
"/": { "/": {
params: {}; params: {};
}; };
"/theme": {
params: {};
};
"/login": { "/login": {
params: {}; params: {};
}; };
@@ -43,12 +46,16 @@ type Pages = {
type RouteFiles = { type RouteFiles = {
"root.tsx": { "root.tsx": {
id: "root"; id: "root";
page: "/" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles"; page: "/" | "/theme" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles";
}; };
"routes/home.tsx": { "routes/home.tsx": {
id: "routes/home"; id: "routes/home";
page: "/"; page: "/";
}; };
"routes/theme.tsx": {
id: "routes/theme";
page: "/theme";
};
"routes/login.tsx": { "routes/login.tsx": {
id: "routes/login"; id: "routes/login";
page: "/login"; page: "/login";
@@ -86,6 +93,7 @@ type RouteFiles = {
type RouteModules = { type RouteModules = {
"root": typeof import("./app/root.tsx"); "root": typeof import("./app/root.tsx");
"routes/home": typeof import("./app/routes/home.tsx"); "routes/home": typeof import("./app/routes/home.tsx");
"routes/theme": typeof import("./app/routes/theme.tsx");
"routes/login": typeof import("./app/routes/login.tsx"); "routes/login": typeof import("./app/routes/login.tsx");
"routes/register": typeof import("./app/routes/register.tsx"); "routes/register": typeof import("./app/routes/register.tsx");
"routes/logout": typeof import("./app/routes/logout.tsx"); "routes/logout": typeof import("./app/routes/logout.tsx");

View File

@@ -0,0 +1,62 @@
// Generated by React Router
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../theme.js")
type Info = GetInfo<{
file: "routes/theme.tsx",
module: Module
}>
type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/theme";
module: typeof import("../theme.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
export namespace Route {
// links
export type LinkDescriptors = Annotations["LinkDescriptors"];
export type LinksFunction = Annotations["LinksFunction"];
// meta
export type MetaArgs = Annotations["MetaArgs"];
export type MetaDescriptors = Annotations["MetaDescriptors"];
export type MetaFunction = Annotations["MetaFunction"];
// headers
export type HeadersArgs = Annotations["HeadersArgs"];
export type HeadersFunction = Annotations["HeadersFunction"];
// middleware
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
// clientMiddleware
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
// loader
export type LoaderArgs = Annotations["LoaderArgs"];
// clientLoader
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
// action
export type ActionArgs = Annotations["ActionArgs"];
// clientAction
export type ClientActionArgs = Annotations["ClientActionArgs"];
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
}

View File

@@ -1,8 +0,0 @@
{
"hash": "fdf55a9c",
"configHash": "c6a852b3",
"lockfileHash": "e3b0c442",
"browserHash": "58d74d30",
"optimized": {},
"chunks": {}
}

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -1,12 +1,12 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui" { @plugin "daisyui" {
themes: biergarten-lager, biergarten-stout; themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen;
} }
@theme { @theme {
--font-sans: --font-sans:
"DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; "Segoe UI Symbol", "Noto Color Emoji";
--font-serif: "Volkhov", ui-serif, Georgia, serif; --font-serif: "Volkhov", ui-serif, Georgia, serif;
} }
@@ -25,35 +25,35 @@ h6,
default: true; default: true;
prefersdark: false; prefersdark: false;
color-scheme: "light"; color-scheme: "light";
/* Base — warm parchment / aged paper */ /* Base — muted parchment / brushed paper */
--color-base-100: oklch(97% 0.025 80); --color-base-100: oklch(96% 0.012 82);
--color-base-200: oklch(92% 0.04 78); --color-base-200: oklch(92% 0.018 80);
--color-base-300: oklch(86% 0.06 75); --color-base-300: oklch(87% 0.025 78);
--color-base-content: oklch(28% 0.05 55); --color-base-content: oklch(30% 0.025 58);
/* Primary — golden amber lager */ /* Primary — mellow amber */
--color-primary: oklch(68% 0.165 60); --color-primary: oklch(65% 0.085 62);
--color-primary-content: oklch(18% 0.04 55); --color-primary-content: oklch(20% 0.02 58);
/* Secondary — deep mahogany ale */ /* Secondary — softened mahogany */
--color-secondary: oklch(38% 0.09 40); --color-secondary: oklch(42% 0.05 42);
--color-secondary-content: oklch(95% 0.02 75); --color-secondary-content: oklch(96% 0.01 76);
/* Accent — frothy cream head */ /* Accent — frothy cream */
--color-accent: oklch(94% 0.03 90); --color-accent: oklch(93% 0.015 90);
--color-accent-content: oklch(30% 0.05 55); --color-accent-content: oklch(32% 0.02 58);
/* Neutral — roasted stout */ /* Neutral — warm roast */
--color-neutral: oklch(24% 0.04 45); --color-neutral: oklch(28% 0.02 46);
--color-neutral-content: oklch(92% 0.025 80); --color-neutral-content: oklch(92% 0.012 80);
/* Info — cool hop green */ /* Info — muted hop green */
--color-info: oklch(58% 0.14 145); --color-info: oklch(46% 0.065 145);
--color-info-content: oklch(97% 0.015 145); --color-info-content: oklch(97% 0.008 145);
/* Success — fresh barley */ /* Success — soft barley */
--color-success: oklch(72% 0.13 120); --color-success: oklch(70% 0.06 122);
--color-success-content: oklch(20% 0.04 120); --color-success-content: oklch(22% 0.02 122);
/* Warning — amber harvest */ /* Warning — toned amber */
--color-warning: oklch(74% 0.19 55); --color-warning: oklch(72% 0.09 56);
--color-warning-content: oklch(18% 0.04 55); --color-warning-content: oklch(20% 0.02 56);
/* Error — deep cherry kriek */ /* Error — restrained cherry */
--color-error: oklch(52% 0.2 20); --color-error: oklch(54% 0.09 22);
--color-error-content: oklch(97% 0.012 15); --color-error-content: oklch(97% 0.006 15);
--radius-selector: 0.375rem; --radius-selector: 0.375rem;
--radius-field: 0.5rem; --radius-field: 0.5rem;
--radius-box: 0.875rem; --radius-box: 0.875rem;
@@ -80,7 +80,7 @@ h6,
--color-primary-content: oklch(14% 0.012 50); --color-primary-content: oklch(14% 0.012 50);
/* Secondary — deep mahogany ale */ /* Secondary — deep mahogany ale */
--color-secondary: oklch(55% 0.025 40); --color-secondary: oklch(51% 0.025 40);
--color-secondary-content: oklch(97% 0.005 75); --color-secondary-content: oklch(97% 0.005 75);
/* Accent — frothy cream head */ /* Accent — frothy cream head */
@@ -116,3 +116,111 @@ h6,
--depth: 1; --depth: 1;
--noise: 1; --noise: 1;
} }
@plugin "daisyui/theme" {
name: "biergarten-cassis";
default: false;
prefersdark: false;
color-scheme: "dark";
/* Base — blackberry-stained barrel, dark purple-black */
--color-base-100: oklch(13% 0.022 295);
--color-base-200: oklch(17% 0.028 292);
--color-base-300: oklch(22% 0.032 290);
--color-base-content: oklch(90% 0.014 300);
/* Primary — cassis berry purple */
--color-primary: oklch(52% 0.15 295);
--color-primary-content: oklch(97% 0.008 295);
/* Secondary — sour cherry */
--color-secondary: oklch(46% 0.11 10);
--color-secondary-content: oklch(97% 0.006 10);
/* Accent — tart lime zest */
--color-accent: oklch(75% 0.1 130);
--color-accent-content: oklch(18% 0.04 130);
/* Neutral — deep blackened grape */
--color-neutral: oklch(18% 0.016 290);
--color-neutral-content: oklch(88% 0.01 295);
/* Info — muted indigo */
--color-info: oklch(46% 0.065 250);
--color-info-content: oklch(97% 0.006 250);
/* Success — dark elderberry green */
--color-success: oklch(50% 0.065 145);
--color-success-content: oklch(97% 0.006 145);
/* Warning — sour apricot */
--color-warning: oklch(70% 0.1 65);
--color-warning-content: oklch(18% 0.03 65);
/* Error — kriek red */
--color-error: oklch(50% 0.13 22);
--color-error-content: oklch(97% 0.006 22);
--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;
}
@plugin "daisyui/theme" {
name: "biergarten-weizen";
default: false;
prefersdark: false;
color-scheme: "light";
/* Base — near-white with the faintest young-barley green breath */
--color-base-100: oklch(99% 0.007 112);
--color-base-200: oklch(96% 0.012 114);
--color-base-300: oklch(92% 0.019 116);
--color-base-content: oklch(20% 0.022 122);
/* Primary — fresh-cut barley, green-gold */
--color-primary: oklch(70% 0.09 118);
--color-primary-content: oklch(16% 0.022 118);
/* Secondary — muted sage stem */
--color-secondary: oklch(44% 0.055 128);
--color-secondary-content: oklch(97% 0.005 128);
/* Accent — pale morning dew */
--color-accent: oklch(93% 0.03 148);
--color-accent-content: oklch(22% 0.022 148);
/* Neutral — dried straw with green memory */
--color-neutral: oklch(88% 0.014 116);
--color-neutral-content: oklch(20% 0.02 118);
/* Info — clear summer sky */
--color-info: oklch(46% 0.07 232);
--color-info-content: oklch(98% 0.005 232);
/* Success — vivid young shoot */
--color-success: oklch(48% 0.09 145);
--color-success-content: oklch(98% 0.005 145);
/* Warning — ripening grain amber */
--color-warning: oklch(68% 0.1 76);
--color-warning-content: oklch(18% 0.02 72);
/* Error — washed dusty rose */
--color-error: oklch(52% 0.1 18);
--color-error-content: oklch(98% 0.005 15);
--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

@@ -1,3 +1,12 @@
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
} from "@headlessui/react";
import { Link } from "react-router"; import { Link } from "react-router";
interface NavbarProps { interface NavbarProps {
@@ -10,50 +19,120 @@ interface NavbarProps {
} }
export default function Navbar({ auth }: NavbarProps) { 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 ( return (
<div className="navbar bg-base-100 shadow-md sticky top-0 z-50"> <Disclosure
<div className="navbar-start"> as="nav"
<Link to="/" className="text-xl font-bold px-4"> className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
🍺 The Biergarten App >
</Link> {({ open }) => (
</div> <>
<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>
<div className="navbar-center gap-4"> <Link to="/" className="text-xl font-bold">
<Link to="/beers" className="btn btn-ghost btn-sm"> 🍺 The Biergarten App
Beers </Link>
</Link> </div>
<Link to="/breweries" className="btn btn-ghost btn-sm">
Breweries
</Link>
<Link to="/beer-styles" className="btn btn-ghost btn-sm">
Beer Styles
</Link>
</div>
<div className="navbar-end gap-2 pr-4"> <div className="navbar-center hidden lg:flex gap-2">
<Link to="/register" className="btn btn-ghost btn-sm"> {navLinks.map((link) => (
Register User <Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
</Link> {link.label}
</Link>
))}
</div>
{auth ? ( <div className="navbar-end gap-2">
<> {!auth && (
<Link to="/dashboard" className="btn btn-primary btn-sm"> <Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
Dashboard Register User
</Link> </Link>
<div className="divider divider-horizontal m-0 h-6"></div> )}
<span className="text-sm text-base-content/70">
{auth.username} {auth ? (
</span> <>
<Link to="/logout" className="btn btn-ghost btn-sm"> <Link to="/dashboard" className="btn btn-primary btn-sm">
Logout Dashboard
</Link> </Link>
</>
) : ( <Menu as="div" className="relative">
<Link to="/login" className="btn btn-primary btn-sm"> <MenuButton className="btn btn-ghost btn-sm">{auth.username}</MenuButton>
Login <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">
</Link> <MenuItem>
)} {({ focus }) => (
</div> <Link to="/dashboard" className={focus ? "active" : ""}>
</div> 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

@@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [ export default [
index("routes/home.tsx"), index("routes/home.tsx"),
route("theme", "routes/theme.tsx"),
route("login", "routes/login.tsx"), route("login", "routes/login.tsx"),
route("register", "routes/register.tsx"), route("register", "routes/register.tsx"),
route("logout", "routes/logout.tsx"), route("logout", "routes/logout.tsx"),

View File

@@ -1,6 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { HomeSimpleDoor, LogIn, UserPlus } from "iconoir-react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Link, redirect, useNavigation, useSubmit } from "react-router"; import { Link, redirect, useNavigation, useSubmit } from "react-router";
import FormField from "../components/forms/FormField";
import SubmitButton from "../components/forms/SubmitButton";
import { createAuthSession, getOptionalAuth, login } from "../lib/auth.server"; import { createAuthSession, getOptionalAuth, login } from "../lib/auth.server";
import { loginSchema, type LoginSchema } from "../lib/schemas"; import { loginSchema, type LoginSchema } from "../lib/schemas";
import type { Route } from "./+types/login"; import type { Route } from "./+types/login";
@@ -54,10 +57,11 @@ export default function Login({ actionData }: Route.ComponentProps) {
<div className="card w-full max-w-md bg-base-100 shadow-xl"> <div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body gap-4"> <div className="card-body gap-4">
<div className="text-center"> <div className="text-center">
<h1 className="card-title text-3xl justify-center">Login</h1> <h1 className="card-title text-3xl justify-center gap-2">
<p className="text-base-content/70"> <LogIn className="size-7" aria-hidden="true" />
Sign in to your Biergarten account Login
</p> </h1>
<p className="text-base-content/70">Sign in to your Biergarten account</p>
</div> </div>
{actionData?.error && ( {actionData?.error && (
@@ -67,63 +71,46 @@ export default function Login({ actionData }: Route.ComponentProps) {
)} )}
<form onSubmit={onSubmit} className="space-y-3"> <form onSubmit={onSubmit} className="space-y-3">
<fieldset className="fieldset"> <FormField
<legend className="fieldset-legend">Username</legend> id="username"
<input type="text"
id="username" autoComplete="username"
type="text" placeholder="your_username"
autoComplete="username" label="Username"
placeholder="your_username" error={errors.username?.message}
className={`input w-full ${errors.username ? "input-error" : ""}`} {...register("username")}
{...register("username")} />
/>
{errors.username && (
<p className="label text-error">{errors.username.message}</p>
)}
</fieldset>
<fieldset className="fieldset"> <FormField
<legend className="fieldset-legend">Password</legend> id="password"
<input type="password"
id="password" autoComplete="current-password"
type="password" placeholder="••••••••"
autoComplete="current-password" label="Password"
placeholder="••••••••" error={errors.password?.message}
className={`input w-full ${errors.password ? "input-error" : ""}`} {...register("password")}
{...register("password")} />
/>
{errors.password && (
<p className="label text-error">{errors.password.message}</p>
)}
</fieldset>
<button <SubmitButton
type="submit" isSubmitting={isSubmitting}
disabled={isSubmitting} idleText="Sign In"
className="btn btn-primary w-full mt-2" submittingText="Signing in..."
> />
{isSubmitting ? (
<>
<span className="loading loading-spinner loading-sm" />{" "}
Signing in...
</>
) : (
"Sign In"
)}
</button>
</form> </form>
<div className="divider text-xs">New here?</div> <div className="divider text-xs">New here?</div>
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<Link to="/register" className="btn btn-outline btn-sm w-full"> <Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
<UserPlus className="size-4" aria-hidden="true" />
Create an account Create an account
</Link> </Link>
<Link <Link
to="/" to="/"
className="link link-hover text-sm text-base-content/60" className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
> >
Back to home <HomeSimpleDoor className="size-4" aria-hidden="true" />
Back to home
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Link, redirect, useNavigation, useSubmit } from "react-router"; import { Link, redirect, useNavigation, useSubmit } from "react-router";
import { import FormField from "../components/forms/FormField";
createAuthSession, import SubmitButton from "../components/forms/SubmitButton";
getOptionalAuth, import { createAuthSession, getOptionalAuth, register } from "../lib/auth.server";
register,
} from "../lib/auth.server";
import { registerSchema, type RegisterSchema } from "../lib/schemas"; import { registerSchema, type RegisterSchema } from "../lib/schemas";
import type { Route } from "./+types/register"; import type { Route } from "./+types/register";
@@ -37,7 +35,14 @@ export async function action({ request }: Route.ActionArgs) {
} }
try { try {
const { confirmPassword: _, ...body } = result.data; 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); const payload = await register(body);
return createAuthSession(payload, "/dashboard"); return createAuthSession(payload, "/dashboard");
} catch (err) { } catch (err) {
@@ -69,9 +74,7 @@ export default function Register({ actionData }: Route.ComponentProps) {
<div className="card-body gap-4"> <div className="card-body gap-4">
<div className="text-center"> <div className="text-center">
<h1 className="card-title text-3xl justify-center">Register</h1> <h1 className="card-title text-3xl justify-center">Register</h1>
<p className="text-base-content/70"> <p className="text-base-content/70">Create your Biergarten account</p>
Create your Biergarten account
</p>
</div> </div>
{actionData?.error && ( {actionData?.error && (
@@ -80,135 +83,85 @@ export default function Register({ actionData }: Route.ComponentProps) {
</div> </div>
)} )}
<form onSubmit={onSubmit} className="space-y-2"> <form onSubmit={onSubmit} className="space-y-3">
<fieldset className="fieldset"> <FormField
<legend className="fieldset-legend">Username</legend> id="username"
<input type="text"
id="username" autoComplete="username"
type="text" placeholder="your_username"
autoComplete="username" label="Username"
placeholder="your_username" hint="3-64 characters, alphanumeric and . _ -"
className={`input w-full ${errors.username ? "input-error" : ""}`} error={errors.username?.message}
{...field("username")} {...field("username")}
/> />
{errors.username ? (
<p className="label text-error">{errors.username.message}</p>
) : (
<p className="label">3-64 characters, alphanumeric and . _ -</p>
)}
</fieldset>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<fieldset className="fieldset"> <FormField
<legend className="fieldset-legend">First Name</legend> id="firstName"
<input type="text"
id="firstName" autoComplete="given-name"
type="text" placeholder="Jane"
autoComplete="given-name" label="First Name"
placeholder="Jane" error={errors.firstName?.message}
className={`input w-full ${errors.firstName ? "input-error" : ""}`} {...field("firstName")}
{...field("firstName")} />
/>
{errors.firstName && ( <FormField
<p className="label text-error">{errors.firstName.message}</p> id="lastName"
)} type="text"
</fieldset> autoComplete="family-name"
<fieldset className="fieldset"> placeholder="Doe"
<legend className="fieldset-legend">Last Name</legend> label="Last Name"
<input error={errors.lastName?.message}
id="lastName" {...field("lastName")}
type="text" />
autoComplete="family-name"
placeholder="Doe"
className={`input w-full ${errors.lastName ? "input-error" : ""}`}
{...field("lastName")}
/>
{errors.lastName && (
<p className="label text-error">{errors.lastName.message}</p>
)}
</fieldset>
</div> </div>
<fieldset className="fieldset"> <FormField
<legend className="fieldset-legend">Email</legend> id="email"
<input type="email"
id="email" autoComplete="email"
type="email" placeholder="jane@example.com"
autoComplete="email" label="Email"
placeholder="jane@example.com" error={errors.email?.message}
className={`input w-full ${errors.email ? "input-error" : ""}`} {...field("email")}
{...field("email")} />
/>
{errors.email && (
<p className="label text-error">{errors.email.message}</p>
)}
</fieldset>
<fieldset className="fieldset"> <FormField
<legend className="fieldset-legend">Date of Birth</legend> id="dateOfBirth"
<input type="date"
id="dateOfBirth" label="Date of Birth"
type="date" hint="Must be 19 years or older"
className={`input w-full ${errors.dateOfBirth ? "input-error" : ""}`} error={errors.dateOfBirth?.message}
{...field("dateOfBirth")} {...field("dateOfBirth")}
/> />
{errors.dateOfBirth ? (
<p className="label text-error">{errors.dateOfBirth.message}</p>
) : (
<p className="label">Must be 19 years or older</p>
)}
</fieldset>
<fieldset className="fieldset"> <FormField
<legend className="fieldset-legend">Password</legend> id="password"
<input type="password"
id="password" autoComplete="new-password"
type="password" placeholder="••••••••"
autoComplete="new-password" label="Password"
placeholder="••••••••" hint="8+ chars: uppercase, lowercase, digit, special character"
className={`input w-full ${errors.password ? "input-error" : ""}`} error={errors.password?.message}
{...field("password")} {...field("password")}
/> />
{errors.password ? (
<p className="label text-error">{errors.password.message}</p>
) : (
<p className="label">
8+ chars: uppercase, lowercase, digit, special character
</p>
)}
</fieldset>
<fieldset className="fieldset"> <FormField
<legend className="fieldset-legend">Confirm Password</legend> id="confirmPassword"
<input type="password"
id="confirmPassword" autoComplete="new-password"
type="password" placeholder="••••••••"
autoComplete="new-password" label="Confirm Password"
placeholder="••••••••" error={errors.confirmPassword?.message}
className={`input w-full ${errors.confirmPassword ? "input-error" : ""}`} {...field("confirmPassword")}
{...field("confirmPassword")} />
/>
{errors.confirmPassword && (
<p className="label text-error">
{errors.confirmPassword.message}
</p>
)}
</fieldset>
<button <SubmitButton
type="submit" isSubmitting={isSubmitting}
disabled={isSubmitting} idleText="Create Account"
className="btn btn-primary w-full mt-2" submittingText="Creating account..."
> />
{isSubmitting ? (
<>
<span className="loading loading-spinner loading-sm" />{" "}
Creating account...
</>
) : (
"Create Account"
)}
</button>
</form> </form>
<div className="divider text-xs">Already have an account?</div> <div className="divider text-xs">Already have an account?</div>
@@ -217,10 +170,7 @@ export default function Register({ actionData }: Route.ComponentProps) {
<Link to="/login" className="btn btn-outline btn-sm w-full"> <Link to="/login" className="btn btn-outline btn-sm w-full">
Sign in Sign in
</Link> </Link>
<Link <Link to="/" className="link link-hover text-sm text-base-content/60">
to="/"
className="link link-hover text-sm text-base-content/60"
>
Back to home Back to home
</Link> </Link>
</div> </div>

View File

@@ -0,0 +1,186 @@
import { useEffect, useState } from "react";
import type { Route } from "./+types/theme";
interface ThemeOption {
value: "biergarten-lager" | "biergarten-stout" | "biergarten-cassis" | "biergarten-weizen";
label: string;
vibe: string;
}
const themeOptions: ThemeOption[] = [
{
value: "biergarten-lager",
label: "Biergarten Lager",
vibe: "Warm parchment, golden 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 kriek, dark berry night",
},
{
value: "biergarten-weizen",
label: "Biergarten Weizen",
vibe: "Hazy straw wheat, banana-clove, sunny afternoon",
},
];
const storageKey = "biergarten-theme";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Theme | The Biergarten App" },
{
name: "description",
content: "Theme guide and switcher for The Biergarten App",
},
];
}
function isValidTheme(value: string | null): value is ThemeOption["value"] {
return themeOptions.some((theme) => theme.value === value);
}
function applyTheme(theme: ThemeOption["value"]) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(storageKey, theme);
}
export default function ThemePage() {
const [selectedTheme, setSelectedTheme] = useState<ThemeOption["value"]>(() => {
if (typeof window === "undefined") {
return "biergarten-lager";
}
const savedTheme = localStorage.getItem(storageKey);
return isValidTheme(savedTheme) ? savedTheme : "biergarten-lager";
});
useEffect(() => {
applyTheme(selectedTheme);
}, [selectedTheme]);
const activeTheme =
themeOptions.find((theme) => theme.value === selectedTheme) ?? themeOptions[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"
>
{themeOptions.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>
);
}

View File

@@ -0,0 +1,48 @@
import js from "@eslint/js";
import prettierConfig from "eslint-config-prettier";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["build/**", "node_modules/**", ".react-router/**", "coverage/**"],
},
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
"react-hooks": reactHooks,
},
rules: {
...reactHooks.configs.recommended.rules,
"no-empty-pattern": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
prettierConfig,
);

File diff suppressed because it is too large Load Diff

View File

@@ -6,21 +6,29 @@
"dev": "react-router dev", "dev": "react-router dev",
"build": "react-router build", "build": "react-router build",
"start": "NODE_ENV=production node ./build/server/index.js", "start": "NODE_ENV=production node ./build/server/index.js",
"typecheck": "tsc" "lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"typegen": "react-router typegen",
"typecheck": "npm run typegen && tsc -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.9",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@react-router/dev": "^7.13.1", "@react-router/dev": "^7.13.1",
"@react-router/express": "^7.13.1", "@react-router/express": "^7.13.1",
"@react-router/node": "^7.13.1", "@react-router/node": "^7.13.1",
"iconoir-react": "^7.11.0",
"isbot": "^5.1.36", "isbot": "^5.1.36",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-router": "7.13.1", "react-router": "^7.13.1",
"zod": "^3.23.8" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.0.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
@@ -28,9 +36,15 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.4.0",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"prettier": "^3.8.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^8.0.0" "typescript-eslint": "^8.57.0",
"vite": "^7.0.0"
} }
} }

View File

@@ -1,6 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"charset": "utf8",
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"isolatedModules": true, "isolatedModules": true,
@@ -9,10 +8,12 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"module": "ESNext", "module": "ESNext",
"noEmit": true, "noEmit": true,
"rootDirs": [".", "./.react-router/types"],
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["node", "vite/client"],
"target": "ES2022", "target": "ES2022",
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["app"], "include": ["app", ".react-router/types/**/*", "react-router.config.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

@@ -3,4 +3,7 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [reactRouter()], plugins: [reactRouter()],
resolve: {
dedupe: ["react", "react-dom"],
},
}); });