mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-05-31 17:53:59 +00:00
Move website directory
This commit is contained in:
7
web/frontend/.prettierignore
Normal file
7
web/frontend/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
build
|
||||
node_modules
|
||||
.react-router
|
||||
package-lock.json
|
||||
storybook-static
|
||||
test-results
|
||||
debug-storybook.log
|
||||
11
web/frontend/.prettierrc.json
Normal file
11
web/frontend/.prettierrc.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"tabWidth": 3,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
9
web/frontend/.react-router/types/+future.ts
Normal file
9
web/frontend/.react-router/types/+future.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router";
|
||||
|
||||
declare module "react-router" {
|
||||
interface Future {
|
||||
v8_middleware: false
|
||||
}
|
||||
}
|
||||
105
web/frontend/.react-router/types/+routes.ts
Normal file
105
web/frontend/.react-router/types/+routes.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router"
|
||||
|
||||
declare module "react-router" {
|
||||
interface Register {
|
||||
pages: Pages
|
||||
routeFiles: RouteFiles
|
||||
routeModules: RouteModules
|
||||
}
|
||||
}
|
||||
|
||||
type Pages = {
|
||||
"/": {
|
||||
params: {};
|
||||
};
|
||||
"/theme": {
|
||||
params: {};
|
||||
};
|
||||
"/login": {
|
||||
params: {};
|
||||
};
|
||||
"/register": {
|
||||
params: {};
|
||||
};
|
||||
"/logout": {
|
||||
params: {};
|
||||
};
|
||||
"/dashboard": {
|
||||
params: {};
|
||||
};
|
||||
"/confirm": {
|
||||
params: {};
|
||||
};
|
||||
"/beers": {
|
||||
params: {};
|
||||
};
|
||||
"/breweries": {
|
||||
params: {};
|
||||
};
|
||||
"/beer-styles": {
|
||||
params: {};
|
||||
};
|
||||
};
|
||||
|
||||
type RouteFiles = {
|
||||
"root.tsx": {
|
||||
id: "root";
|
||||
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";
|
||||
};
|
||||
"routes/register.tsx": {
|
||||
id: "routes/register";
|
||||
page: "/register";
|
||||
};
|
||||
"routes/logout.tsx": {
|
||||
id: "routes/logout";
|
||||
page: "/logout";
|
||||
};
|
||||
"routes/dashboard.tsx": {
|
||||
id: "routes/dashboard";
|
||||
page: "/dashboard";
|
||||
};
|
||||
"routes/confirm.tsx": {
|
||||
id: "routes/confirm";
|
||||
page: "/confirm";
|
||||
};
|
||||
"routes/beers.tsx": {
|
||||
id: "routes/beers";
|
||||
page: "/beers";
|
||||
};
|
||||
"routes/breweries.tsx": {
|
||||
id: "routes/breweries";
|
||||
page: "/breweries";
|
||||
};
|
||||
"routes/beer-styles.tsx": {
|
||||
id: "routes/beer-styles";
|
||||
page: "/beer-styles";
|
||||
};
|
||||
};
|
||||
|
||||
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");
|
||||
"routes/dashboard": typeof import("./app/routes/dashboard.tsx");
|
||||
"routes/confirm": typeof import("./app/routes/confirm.tsx");
|
||||
"routes/beers": typeof import("./app/routes/beers.tsx");
|
||||
"routes/breweries": typeof import("./app/routes/breweries.tsx");
|
||||
"routes/beer-styles": typeof import("./app/routes/beer-styles.tsx");
|
||||
};
|
||||
18
web/frontend/.react-router/types/+server-build.d.ts
vendored
Normal file
18
web/frontend/.react-router/types/+server-build.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
// Generated by React Router
|
||||
|
||||
declare module "virtual:react-router/server-build" {
|
||||
import { ServerBuild } from "react-router";
|
||||
export const assets: ServerBuild["assets"];
|
||||
export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"];
|
||||
export const basename: ServerBuild["basename"];
|
||||
export const entry: ServerBuild["entry"];
|
||||
export const future: ServerBuild["future"];
|
||||
export const isSpaMode: ServerBuild["isSpaMode"];
|
||||
export const prerender: ServerBuild["prerender"];
|
||||
export const publicPath: ServerBuild["publicPath"];
|
||||
export const routeDiscovery: ServerBuild["routeDiscovery"];
|
||||
export const routes: ServerBuild["routes"];
|
||||
export const ssr: ServerBuild["ssr"];
|
||||
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
|
||||
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
|
||||
}
|
||||
59
web/frontend/.react-router/types/app/+types/root.ts
Normal file
59
web/frontend/.react-router/types/app/+types/root.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../root.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "root.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../root.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"];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../beer-styles.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/beer-styles.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/beer-styles";
|
||||
module: typeof import("../beer-styles.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"];
|
||||
}
|
||||
62
web/frontend/.react-router/types/app/routes/+types/beers.ts
Normal file
62
web/frontend/.react-router/types/app/routes/+types/beers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../beers.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/beers.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/beers";
|
||||
module: typeof import("../beers.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"];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../breweries.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/breweries.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/breweries";
|
||||
module: typeof import("../breweries.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"];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../confirm.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/confirm.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/confirm";
|
||||
module: typeof import("../confirm.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"];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../dashboard.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/dashboard.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/dashboard";
|
||||
module: typeof import("../dashboard.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"];
|
||||
}
|
||||
62
web/frontend/.react-router/types/app/routes/+types/home.ts
Normal file
62
web/frontend/.react-router/types/app/routes/+types/home.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../home.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/home.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/home";
|
||||
module: typeof import("../home.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"];
|
||||
}
|
||||
62
web/frontend/.react-router/types/app/routes/+types/login.ts
Normal file
62
web/frontend/.react-router/types/app/routes/+types/login.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../login.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/login.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/login";
|
||||
module: typeof import("../login.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"];
|
||||
}
|
||||
62
web/frontend/.react-router/types/app/routes/+types/logout.ts
Normal file
62
web/frontend/.react-router/types/app/routes/+types/logout.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../logout.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/logout.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/logout";
|
||||
module: typeof import("../logout.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"];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../register.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/register.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/register";
|
||||
module: typeof import("../register.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"];
|
||||
}
|
||||
62
web/frontend/.react-router/types/app/routes/+types/theme.ts
Normal file
62
web/frontend/.react-router/types/app/routes/+types/theme.ts
Normal 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"];
|
||||
}
|
||||
50
web/frontend/.storybook/main.ts
Normal file
50
web/frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
'../stories/Configure.mdx',
|
||||
'../stories/SubmitButton.stories.tsx',
|
||||
'../stories/FormField.stories.tsx',
|
||||
'../stories/Navbar.stories.tsx',
|
||||
'../stories/Toast.stories.tsx',
|
||||
'../stories/Themes.stories.tsx',
|
||||
],
|
||||
addons: [
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-vitest',
|
||||
'@storybook/addon-a11y',
|
||||
'@storybook/addon-docs',
|
||||
'@storybook/addon-onboarding',
|
||||
],
|
||||
framework: '@storybook/react-vite',
|
||||
async viteFinal(config) {
|
||||
config.plugins = (config.plugins ?? []).filter((plugin) => {
|
||||
if (!plugin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pluginName = typeof plugin === 'object' && 'name' in plugin ? plugin.name : '';
|
||||
return !pluginName.startsWith('react-router');
|
||||
});
|
||||
|
||||
config.build ??= {};
|
||||
config.build.rollupOptions ??= {};
|
||||
|
||||
const previousOnWarn = config.build.rollupOptions.onwarn;
|
||||
config.build.rollupOptions.onwarn = (warning, warn) => {
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof previousOnWarn === 'function') {
|
||||
previousOnWarn(warning, warn);
|
||||
return;
|
||||
}
|
||||
|
||||
warn(warning);
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
6
web/frontend/.storybook/preview-head.html
Normal file
6
web/frontend/.storybook/preview-head.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
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"
|
||||
/>
|
||||
63
web/frontend/.storybook/preview.ts
Normal file
63
web/frontend/.storybook/preview.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import { createElement } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import '../app/app.css';
|
||||
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes';
|
||||
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'Active Biergarten theme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
dynamicTitle: true,
|
||||
items: biergartenThemes.map((theme) => ({
|
||||
value: theme.value,
|
||||
title: theme.label,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
theme: defaultThemeName,
|
||||
},
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
const theme = isBiergartenTheme(String(context.globals.theme))
|
||||
? context.globals.theme
|
||||
: defaultThemeName;
|
||||
|
||||
return createElement(
|
||||
MemoryRouter,
|
||||
undefined,
|
||||
createElement(
|
||||
'div',
|
||||
{
|
||||
'data-theme': theme,
|
||||
className: 'bg-base-200 p-6 text-base-content',
|
||||
},
|
||||
createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
layout: 'padded',
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
7
web/frontend/.storybook/vitest.setup.ts
Normal file
7
web/frontend/.storybook/vitest.setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
|
||||
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||
import * as projectAnnotations from './preview';
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||
251
web/frontend/app/app.css
Normal file
251
web/frontend/app/app.css
Normal 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;
|
||||
}
|
||||
144
web/frontend/app/components/Navbar.tsx
Normal file
144
web/frontend/app/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
web/frontend/app/components/forms/FormField.tsx
Normal file
40
web/frontend/app/components/forms/FormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
web/frontend/app/components/forms/SubmitButton.tsx
Normal file
31
web/frontend/app/components/forms/SubmitButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
web/frontend/app/components/toast/ToastProvider.tsx
Normal file
25
web/frontend/app/components/toast/ToastProvider.tsx
Normal 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)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
6
web/frontend/app/components/toast/toast.ts
Normal file
6
web/frontend/app/components/toast/toast.ts
Normal 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();
|
||||
162
web/frontend/app/lib/auth.server.ts
Normal file
162
web/frontend/app/lib/auth.server.ts
Normal 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) },
|
||||
});
|
||||
}
|
||||
33
web/frontend/app/lib/schemas.ts
Normal file
33
web/frontend/app/lib/schemas.ts
Normal 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>;
|
||||
41
web/frontend/app/lib/themes.ts
Normal file
41
web/frontend/app/lib/themes.ts
Normal 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
90
web/frontend/app/root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
web/frontend/app/routes.ts
Normal file
14
web/frontend/app/routes.ts
Normal 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;
|
||||
16
web/frontend/app/routes/beer-styles.tsx
Normal file
16
web/frontend/app/routes/beer-styles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
web/frontend/app/routes/beers.tsx
Normal file
16
web/frontend/app/routes/beers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
web/frontend/app/routes/breweries.tsx
Normal file
16
web/frontend/app/routes/breweries.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
web/frontend/app/routes/confirm.tsx
Normal file
91
web/frontend/app/routes/confirm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
web/frontend/app/routes/dashboard.tsx
Normal file
105
web/frontend/app/routes/dashboard.tsx
Normal 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
56
web/frontend/app/routes/home.tsx
Normal file
56
web/frontend/app/routes/home.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
web/frontend/app/routes/login.tsx
Normal file
128
web/frontend/app/routes/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
web/frontend/app/routes/logout.tsx
Normal file
10
web/frontend/app/routes/logout.tsx
Normal 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) },
|
||||
});
|
||||
}
|
||||
189
web/frontend/app/routes/register.tsx
Normal file
189
web/frontend/app/routes/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
web/frontend/app/routes/theme.tsx
Normal file
169
web/frontend/app/routes/theme.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/frontend/eslint.config.mjs
Normal file
52
web/frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import storybook from 'eslint-plugin-storybook';
|
||||
|
||||
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,
|
||||
storybook.configs['flat/recommended'],
|
||||
);
|
||||
7713
web/frontend/package-lock.json
generated
Normal file
7713
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
web/frontend/package.json
Normal file
68
web/frontend/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "biergarten-website",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "react-router dev",
|
||||
"build": "react-router build",
|
||||
"start": "NODE_ENV=production node ./build/server/index.js",
|
||||
"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",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"test:storybook": "vitest run --project storybook",
|
||||
"test:storybook:playwright": "playwright test -c playwright.storybook.config.ts"
|
||||
},
|
||||
"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-hot-toast": "^2.6.0",
|
||||
"react-router": "^7.13.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@storybook/addon-a11y": "^10.2.19",
|
||||
"@storybook/addon-docs": "^10.2.19",
|
||||
"@storybook/addon-onboarding": "^10.2.19",
|
||||
"@storybook/addon-vitest": "^10.2.19",
|
||||
"@storybook/react-vite": "^10.2.19",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/browser-playwright": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"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",
|
||||
"eslint-plugin-storybook": "^10.2.19",
|
||||
"globals": "^17.4.0",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.8.1",
|
||||
"storybook": "^10.2.19",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
20
web/frontend/playwright.storybook.config.ts
Normal file
20
web/frontend/playwright.storybook.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const port = process.env.STORYBOOK_PORT ?? '6006';
|
||||
const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/playwright',
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run storybook -- --ci --port ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
5
web/frontend/postcss.config.js
Normal file
5
web/frontend/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
5
web/frontend/react-router.config.ts
Normal file
5
web/frontend/react-router.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Config } from '@react-router/dev/config';
|
||||
|
||||
export default {
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
41
web/frontend/stories/Configure.mdx
Normal file
41
web/frontend/stories/Configure.mdx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Docs/Storybook" />
|
||||
|
||||
# Biergarten Storybook
|
||||
|
||||
This Storybook is scoped to real app UI only:
|
||||
|
||||
- `SubmitButton`
|
||||
- `FormField`
|
||||
- `Navbar`
|
||||
- `Themes` gallery
|
||||
|
||||
## Theme workflow
|
||||
|
||||
Use the toolbar theme switcher to preview all Biergarten themes:
|
||||
|
||||
- `biergarten-lager`
|
||||
- `biergarten-stout`
|
||||
- `biergarten-cassis`
|
||||
- `biergarten-weizen`
|
||||
|
||||
Stories are rendered inside a decorator that sets `data-theme`, so tokens and components reflect production styling.
|
||||
|
||||
## Tests
|
||||
|
||||
Two layers are enabled:
|
||||
|
||||
1. Story `play` tests (Storybook test runner / Vitest addon)
|
||||
2. Browser checks with Playwright against Storybook iframe routes
|
||||
|
||||
Run:
|
||||
|
||||
- `npm run build-storybook -- --test`
|
||||
- `npm run test:storybook:playwright`
|
||||
|
||||
## Rules
|
||||
|
||||
- Add stories only for reusable app components.
|
||||
- Prefer semantic classes (`bg-primary`, `text-base-content`, etc.).
|
||||
- Keep stories state-focused and minimal.
|
||||
68
web/frontend/stories/FormField.stories.tsx
Normal file
68
web/frontend/stories/FormField.stories.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import FormField from '../app/components/forms/FormField';
|
||||
|
||||
const formFieldDescription = `Reusable labeled input for Biergarten forms. This page shows guided, error, and password states so you can review label spacing, helper text, validation messaging, and ARIA behavior in the same card layout used across the app.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Forms/FormField',
|
||||
component: FormField,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
id: 'email',
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: 'Email address',
|
||||
placeholder: 'you@example.com',
|
||||
hint: 'We only use this to manage your account.',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: formFieldDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="w-full max-w-md rounded-box bg-base-100 p-6 shadow-lg">
|
||||
<FormField {...args} />
|
||||
</div>
|
||||
),
|
||||
} satisfies Meta<typeof FormField>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const WithHint: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/manage your account/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
error: 'Please enter a valid email address.',
|
||||
hint: undefined,
|
||||
'aria-invalid': true,
|
||||
defaultValue: 'not-an-email',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute('aria-invalid', 'true');
|
||||
},
|
||||
};
|
||||
|
||||
export const PasswordField: Story = {
|
||||
args: {
|
||||
id: 'password',
|
||||
name: 'password',
|
||||
type: 'password',
|
||||
label: 'Password',
|
||||
placeholder: 'Enter a strong password',
|
||||
hint: 'Use 12 or more characters.',
|
||||
},
|
||||
};
|
||||
69
web/frontend/stories/Navbar.stories.tsx
Normal file
69
web/frontend/stories/Navbar.stories.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import Navbar from '../app/components/Navbar';
|
||||
|
||||
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Navigation/Navbar',
|
||||
component: Navbar,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: navbarDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Navbar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Guest: Story = {
|
||||
args: {
|
||||
auth: null,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('link', { name: /the biergarten app/i })).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('link', { name: /login/i })).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('link', { name: /register user/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Authenticated: Story = {
|
||||
args: {
|
||||
auth: {
|
||||
username: 'Hans',
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
userAccountId: 'user-1',
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const userButton = canvas.getByRole('button', { name: /hans/i });
|
||||
await expect(userButton).toBeInTheDocument();
|
||||
await userEvent.click(userButton);
|
||||
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const MobileMenu: Story = {
|
||||
args: {
|
||||
auth: null,
|
||||
},
|
||||
parameters: {
|
||||
viewport: {
|
||||
defaultViewport: 'mobile1',
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /toggle navigation/i }));
|
||||
await expect(canvas.getByRole('link', { name: /beer styles/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
52
web/frontend/stories/SubmitButton.stories.tsx
Normal file
52
web/frontend/stories/SubmitButton.stories.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import SubmitButton from '../app/components/forms/SubmitButton';
|
||||
|
||||
const submitButtonDescription = `Shared submit action for Biergarten forms. These stories cover the idle, loading, and custom-width states so you can verify button copy, disabled behavior during submission, and theme styling without wiring up a full form flow.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Forms/SubmitButton',
|
||||
component: SubmitButton,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
idleText: 'Save changes',
|
||||
submittingText: 'Saving changes',
|
||||
isSubmitting: false,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: submitButtonDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof SubmitButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Idle: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('button', { name: /save changes/i })).toBeEnabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const Submitting: Story = {
|
||||
args: {
|
||||
isSubmitting: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('button', { name: /saving changes/i })).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomWidth: Story = {
|
||||
args: {
|
||||
className: 'btn btn-secondary min-w-64',
|
||||
idleText: 'Register user',
|
||||
submittingText: 'Registering user',
|
||||
},
|
||||
};
|
||||
157
web/frontend/stories/Themes.stories.tsx
Normal file
157
web/frontend/stories/Themes.stories.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { biergartenThemes } from '../app/lib/themes';
|
||||
|
||||
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
|
||||
|
||||
function ThemeSwatch({ label, className }: { label: string; className: string }) {
|
||||
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
|
||||
}
|
||||
|
||||
/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */
|
||||
function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color: string }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-box p-3 text-sm font-medium"
|
||||
style={{ backgroundColor: `var(${bg})`, color: `var(${color})` }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextTokenSample({
|
||||
label,
|
||||
background,
|
||||
text,
|
||||
}: {
|
||||
label: string;
|
||||
background: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
|
||||
Secondary copy, placeholders, and helper text.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) {
|
||||
return (
|
||||
<section
|
||||
data-theme={value}
|
||||
className="rounded-box border border-base-300 bg-base-100 shadow-lg"
|
||||
>
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold">{label}</h2>
|
||||
<p className="text-sm text-base-content/70">{vibe}</p>
|
||||
</div>
|
||||
|
||||
{/* Core palette */}
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
Core
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" />
|
||||
<ThemeSwatch label="Secondary" className="bg-secondary text-secondary-content" />
|
||||
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" />
|
||||
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status tokens */}
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
Status
|
||||
</p>
|
||||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
|
||||
<ThemeSwatch label="Info" className="bg-info text-info-content" />
|
||||
<ThemeSwatch label="Success" className="bg-success text-success-content" />
|
||||
<ThemeSwatch label="Warning" className="bg-warning text-warning-content" />
|
||||
<ThemeSwatch label="Error" className="bg-error text-error-content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content tokens (custom) */}
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||
Content
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<CssVarSwatch
|
||||
label="Surface"
|
||||
bg="--color-surface"
|
||||
color="--color-surface-content"
|
||||
/>
|
||||
<TextTokenSample
|
||||
label="Muted on Base"
|
||||
background="--color-base-100"
|
||||
text="--color-muted"
|
||||
/>
|
||||
<CssVarSwatch
|
||||
label="Highlight"
|
||||
bg="--color-highlight"
|
||||
color="--color-highlight-content"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<TextTokenSample
|
||||
label="Muted on Surface"
|
||||
background="--color-surface"
|
||||
text="--color-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="btn btn-primary btn-sm">Primary</button>
|
||||
<button className="btn btn-secondary btn-sm">Secondary</button>
|
||||
<button className="btn btn-outline btn-sm">Outline</button>
|
||||
</div>
|
||||
|
||||
<div role="alert" className="alert alert-success alert-soft">
|
||||
<span>Semantic tokens stay stable while the atmosphere changes.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Themes/Biergarten Themes',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: themesDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
render: () => (
|
||||
<div className="grid gap-6 p-6 lg:grid-cols-2">
|
||||
{biergartenThemes.map((theme) => (
|
||||
<ThemePanel key={theme.value} {...theme} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Gallery: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
for (const theme of biergartenThemes) {
|
||||
await expect(canvas.getByRole('heading', { name: theme.label })).toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
};
|
||||
74
web/frontend/stories/Toast.stories.tsx
Normal file
74
web/frontend/stories/Toast.stories.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||
import ToastProvider from '../app/components/toast/ToastProvider';
|
||||
import {
|
||||
dismissToasts,
|
||||
showErrorToast,
|
||||
showInfoToast,
|
||||
showSuccessToast,
|
||||
} from '../app/components/toast/toast';
|
||||
|
||||
const toastDescription = `Theme-aware toast feedback built on react-hot-toast. Use this page to trigger success, error, and info messages, check icon contrast and surface styling, and confirm notifications feel consistent across Biergarten themes.`;
|
||||
|
||||
function ToastDemo() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToastProvider />
|
||||
<div className="card border border-base-300 bg-base-100 shadow-md">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">Toast demo</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
Use these actions to preview toast styles.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => showSuccessToast('Saved successfully')}
|
||||
>
|
||||
Success
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-error btn-sm"
|
||||
onClick={() => showErrorToast('Something went wrong')}
|
||||
>
|
||||
Error
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-info btn-sm"
|
||||
onClick={() => showInfoToast('Heads up: check your email')}
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={dismissToasts}>
|
||||
Dismiss all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Feedback/Toast',
|
||||
component: ToastDemo,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: toastDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ToastDemo>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole('button', { name: /success/i }));
|
||||
await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
8
web/frontend/tailwind.config.js
Normal file
8
web/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./app/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require('daisyui')],
|
||||
};
|
||||
49
web/frontend/tests/playwright/storybook.components.spec.ts
Normal file
49
web/frontend/tests/playwright/storybook.components.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const themes = [
|
||||
'biergarten-lager',
|
||||
'biergarten-stout',
|
||||
'biergarten-cassis',
|
||||
'biergarten-weizen',
|
||||
] as const;
|
||||
|
||||
test.describe('storybook component coverage', () => {
|
||||
for (const theme of themes) {
|
||||
test(`SubmitButton idle renders in ${theme}`, async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=forms-submitbutton--idle&globals=theme:${theme}`);
|
||||
await expect(page.getByRole('button', { name: /save changes/i })).toBeVisible();
|
||||
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test(`FormField error renders in ${theme}`, async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=forms-formfield--with-error&globals=theme:${theme}`);
|
||||
await expect(page.getByLabel('Email address')).toBeVisible();
|
||||
await expect(page.getByText(/valid email address/i)).toBeVisible();
|
||||
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test(`Navbar guest renders in ${theme}`, async ({ page }) => {
|
||||
await page.goto(`/iframe.html?id=navigation-navbar--guest&globals=theme:${theme}`);
|
||||
await expect(page.getByRole('link', { name: /the biergarten app/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /^login$/i })).toBeVisible();
|
||||
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('Navbar authenticated state renders', async ({ page }) => {
|
||||
await page.goto(
|
||||
`/iframe.html?id=navigation-navbar--authenticated&globals=theme:biergarten-stout`,
|
||||
);
|
||||
await expect(page.getByRole('button', { name: /hans/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Theme gallery shows all themes', async ({ page }) => {
|
||||
await page.goto(
|
||||
`/iframe.html?id=themes-biergarten-themes--gallery&globals=theme:biergarten-lager`,
|
||||
);
|
||||
await expect(page.getByRole('heading', { name: 'Biergarten Lager' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Biergarten Stout' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Biergarten Cassis' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Biergarten Weizen' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
19
web/frontend/tsconfig.json
Normal file
19
web/frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"noEmit": true,
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["app", ".react-router/types/**/*", "react-router.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
47
web/frontend/vite.config.ts
Normal file
47
web/frontend/vite.config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { reactRouter } from '@react-router/dev/vite';
|
||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
const dirname =
|
||||
typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||
const isStorybook =
|
||||
process.env.STORYBOOK === 'true' || process.argv.some((arg) => arg.includes('storybook'));
|
||||
|
||||
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||
export default defineConfig({
|
||||
plugins: isStorybook ? [] : [reactRouter()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom'],
|
||||
},
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
plugins: [
|
||||
// The plugin will run tests for the stories defined in your Storybook config
|
||||
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
|
||||
storybookTest({
|
||||
configDir: path.join(dirname, '.storybook'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
name: 'storybook',
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
provider: playwright({}),
|
||||
instances: [
|
||||
{
|
||||
browser: 'chromium',
|
||||
},
|
||||
],
|
||||
},
|
||||
setupFiles: ['.storybook/vitest.setup.ts'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
1
web/frontend/vitest.shims.d.ts
vendored
Normal file
1
web/frontend/vitest.shims.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="@vitest/browser-playwright" />
|
||||
Reference in New Issue
Block a user