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
/build
# project-specific build artifacts
/src/Website/build/
/src/Website/.react-router/
/test-results/
# misc
.DS_Store
*.pem
@@ -42,6 +47,9 @@ next-env.d.ts
# vscode
.vscode
.idea/
*.swp
*.swo
/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: {};
};
"/theme": {
params: {};
};
"/login": {
params: {};
};
@@ -43,12 +46,16 @@ type Pages = {
type RouteFiles = {
"root.tsx": {
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": {
id: "routes/home";
page: "/";
};
"routes/theme.tsx": {
id: "routes/theme";
page: "/theme";
};
"routes/login.tsx": {
id: "routes/login";
page: "/login";
@@ -86,6 +93,7 @@ type RouteFiles = {
type RouteModules = {
"root": typeof import("./app/root.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/register": typeof import("./app/routes/register.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";
@plugin "daisyui" {
themes: biergarten-lager, biergarten-stout;
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";
"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;
}
@@ -25,35 +25,35 @@ h6,
default: true;
prefersdark: false;
color-scheme: "light";
/* Base — warm parchment / aged paper */
--color-base-100: oklch(97% 0.025 80);
--color-base-200: oklch(92% 0.04 78);
--color-base-300: oklch(86% 0.06 75);
--color-base-content: oklch(28% 0.05 55);
/* Primary — golden amber lager */
--color-primary: oklch(68% 0.165 60);
--color-primary-content: oklch(18% 0.04 55);
/* Secondary — deep mahogany ale */
--color-secondary: oklch(38% 0.09 40);
--color-secondary-content: oklch(95% 0.02 75);
/* Accent — frothy cream head */
--color-accent: oklch(94% 0.03 90);
--color-accent-content: oklch(30% 0.05 55);
/* Neutral — roasted stout */
--color-neutral: oklch(24% 0.04 45);
--color-neutral-content: oklch(92% 0.025 80);
/* Info — cool hop green */
--color-info: oklch(58% 0.14 145);
--color-info-content: oklch(97% 0.015 145);
/* Success — fresh barley */
--color-success: oklch(72% 0.13 120);
--color-success-content: oklch(20% 0.04 120);
/* Warning — amber harvest */
--color-warning: oklch(74% 0.19 55);
--color-warning-content: oklch(18% 0.04 55);
/* Error — deep cherry kriek */
--color-error: oklch(52% 0.2 20);
--color-error-content: oklch(97% 0.012 15);
/* Base — muted parchment / brushed paper */
--color-base-100: oklch(96% 0.012 82);
--color-base-200: oklch(92% 0.018 80);
--color-base-300: oklch(87% 0.025 78);
--color-base-content: oklch(30% 0.025 58);
/* Primary — mellow amber */
--color-primary: oklch(65% 0.085 62);
--color-primary-content: oklch(20% 0.02 58);
/* Secondary — softened mahogany */
--color-secondary: oklch(42% 0.05 42);
--color-secondary-content: oklch(96% 0.01 76);
/* Accent — frothy cream */
--color-accent: oklch(93% 0.015 90);
--color-accent-content: oklch(32% 0.02 58);
/* Neutral — warm roast */
--color-neutral: oklch(28% 0.02 46);
--color-neutral-content: oklch(92% 0.012 80);
/* Info — muted hop green */
--color-info: oklch(46% 0.065 145);
--color-info-content: oklch(97% 0.008 145);
/* Success — soft barley */
--color-success: oklch(70% 0.06 122);
--color-success-content: oklch(22% 0.02 122);
/* Warning — toned amber */
--color-warning: oklch(72% 0.09 56);
--color-warning-content: oklch(20% 0.02 56);
/* Error — restrained cherry */
--color-error: oklch(54% 0.09 22);
--color-error-content: oklch(97% 0.006 15);
--radius-selector: 0.375rem;
--radius-field: 0.5rem;
--radius-box: 0.875rem;
@@ -80,7 +80,7 @@ h6,
--color-primary-content: oklch(14% 0.012 50);
/* 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);
/* Accent — frothy cream head */
@@ -116,3 +116,111 @@ h6,
--depth: 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";
interface NavbarProps {
@@ -10,43 +19,95 @@ interface 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 (
<div className="navbar bg-base-100 shadow-md sticky top-0 z-50">
<div className="navbar-start">
<Link to="/" className="text-xl font-bold px-4">
<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 gap-4">
<Link to="/beers" className="btn btn-ghost btn-sm">
Beers
</Link>
<Link to="/breweries" className="btn btn-ghost btn-sm">
Breweries
</Link>
<Link to="/beer-styles" className="btn btn-ghost btn-sm">
Beer Styles
<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 pr-4">
<Link to="/register" className="btn btn-ghost btn-sm">
<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>
<div className="divider divider-horizontal m-0 h-6"></div>
<span className="text-sm text-base-content/70">
{auth.username}
</span>
<Link to="/logout" className="btn btn-ghost btn-sm">
<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">
@@ -55,5 +116,23 @@ export default function Navbar({ auth }: NavbarProps) {
)}
</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 [
index("routes/home.tsx"),
route("theme", "routes/theme.tsx"),
route("login", "routes/login.tsx"),
route("register", "routes/register.tsx"),
route("logout", "routes/logout.tsx"),

View File

@@ -1,6 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { HomeSimpleDoor, LogIn, UserPlus } from "iconoir-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 { createAuthSession, getOptionalAuth, login } from "../lib/auth.server";
import { loginSchema, type LoginSchema } from "../lib/schemas";
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-body gap-4">
<div className="text-center">
<h1 className="card-title text-3xl justify-center">Login</h1>
<p className="text-base-content/70">
Sign in to your Biergarten account
</p>
<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 && (
@@ -67,63 +71,46 @@ export default function Login({ actionData }: Route.ComponentProps) {
)}
<form onSubmit={onSubmit} className="space-y-3">
<fieldset className="fieldset">
<legend className="fieldset-legend">Username</legend>
<input
<FormField
id="username"
type="text"
autoComplete="username"
placeholder="your_username"
className={`input w-full ${errors.username ? "input-error" : ""}`}
label="Username"
error={errors.username?.message}
{...register("username")}
/>
{errors.username && (
<p className="label text-error">{errors.username.message}</p>
)}
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Password</legend>
<input
<FormField
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
className={`input w-full ${errors.password ? "input-error" : ""}`}
label="Password"
error={errors.password?.message}
{...register("password")}
/>
{errors.password && (
<p className="label text-error">{errors.password.message}</p>
)}
</fieldset>
<button
type="submit"
disabled={isSubmitting}
className="btn btn-primary w-full mt-2"
>
{isSubmitting ? (
<>
<span className="loading loading-spinner loading-sm" />{" "}
Signing in...
</>
) : (
"Sign In"
)}
</button>
<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">
<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"
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>
</div>
</div>

View File

@@ -1,11 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Link, redirect, useNavigation, useSubmit } from "react-router";
import {
createAuthSession,
getOptionalAuth,
register,
} from "../lib/auth.server";
import FormField from "../components/forms/FormField";
import SubmitButton from "../components/forms/SubmitButton";
import { createAuthSession, getOptionalAuth, register } from "../lib/auth.server";
import { registerSchema, type RegisterSchema } from "../lib/schemas";
import type { Route } from "./+types/register";
@@ -37,7 +35,14 @@ export async function action({ request }: Route.ActionArgs) {
}
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);
return createAuthSession(payload, "/dashboard");
} catch (err) {
@@ -69,9 +74,7 @@ export default function Register({ actionData }: Route.ComponentProps) {
<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>
<p className="text-base-content/70">Create your Biergarten account</p>
</div>
{actionData?.error && (
@@ -80,135 +83,85 @@ export default function Register({ actionData }: Route.ComponentProps) {
</div>
)}
<form onSubmit={onSubmit} className="space-y-2">
<fieldset className="fieldset">
<legend className="fieldset-legend">Username</legend>
<input
<form onSubmit={onSubmit} className="space-y-3">
<FormField
id="username"
type="text"
autoComplete="username"
placeholder="your_username"
className={`input w-full ${errors.username ? "input-error" : ""}`}
label="Username"
hint="3-64 characters, alphanumeric and . _ -"
error={errors.username?.message}
{...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">
<fieldset className="fieldset">
<legend className="fieldset-legend">First Name</legend>
<input
<FormField
id="firstName"
type="text"
autoComplete="given-name"
placeholder="Jane"
className={`input w-full ${errors.firstName ? "input-error" : ""}`}
label="First Name"
error={errors.firstName?.message}
{...field("firstName")}
/>
{errors.firstName && (
<p className="label text-error">{errors.firstName.message}</p>
)}
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Last Name</legend>
<input
<FormField
id="lastName"
type="text"
autoComplete="family-name"
placeholder="Doe"
className={`input w-full ${errors.lastName ? "input-error" : ""}`}
label="Last Name"
error={errors.lastName?.message}
{...field("lastName")}
/>
{errors.lastName && (
<p className="label text-error">{errors.lastName.message}</p>
)}
</fieldset>
</div>
<fieldset className="fieldset">
<legend className="fieldset-legend">Email</legend>
<input
<FormField
id="email"
type="email"
autoComplete="email"
placeholder="jane@example.com"
className={`input w-full ${errors.email ? "input-error" : ""}`}
label="Email"
error={errors.email?.message}
{...field("email")}
/>
{errors.email && (
<p className="label text-error">{errors.email.message}</p>
)}
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Date of Birth</legend>
<input
<FormField
id="dateOfBirth"
type="date"
className={`input w-full ${errors.dateOfBirth ? "input-error" : ""}`}
label="Date of Birth"
hint="Must be 19 years or older"
error={errors.dateOfBirth?.message}
{...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">
<legend className="fieldset-legend">Password</legend>
<input
<FormField
id="password"
type="password"
autoComplete="new-password"
placeholder="••••••••"
className={`input w-full ${errors.password ? "input-error" : ""}`}
label="Password"
hint="8+ chars: uppercase, lowercase, digit, special character"
error={errors.password?.message}
{...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">
<legend className="fieldset-legend">Confirm Password</legend>
<input
<FormField
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder="••••••••"
className={`input w-full ${errors.confirmPassword ? "input-error" : ""}`}
label="Confirm Password"
error={errors.confirmPassword?.message}
{...field("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="label text-error">
{errors.confirmPassword.message}
</p>
)}
</fieldset>
<button
type="submit"
disabled={isSubmitting}
className="btn btn-primary w-full mt-2"
>
{isSubmitting ? (
<>
<span className="loading loading-spinner loading-sm" />{" "}
Creating account...
</>
) : (
"Create Account"
)}
</button>
<SubmitButton
isSubmitting={isSubmitting}
idleText="Create Account"
submittingText="Creating account..."
/>
</form>
<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">
Sign in
</Link>
<Link
to="/"
className="link link-hover text-sm text-base-content/60"
>
<Link to="/" className="link link-hover text-sm text-base-content/60">
Back to home
</Link>
</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",
"build": "react-router build",
"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": {
"@headlessui/react": "^2.2.9",
"@hookform/resolvers": "^5.2.2",
"@react-router/dev": "^7.13.1",
"@react-router/express": "^7.13.1",
"@react-router/node": "^7.13.1",
"iconoir-react": "^7.11.0",
"isbot": "^5.1.36",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-router": "7.13.1",
"zod": "^3.23.8"
"react-router": "^7.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@tailwindcss/postcss": "^4.2.1",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
@@ -28,9 +36,15 @@
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.27",
"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",
"prettier": "^3.8.1",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"typescript-eslint": "^8.57.0",
"vite": "^7.0.0"
}
}

View File

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

View File

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