diff --git a/src/Website/.prettierignore b/src/Website/.prettierignore index 298dfc6..826ddb6 100644 --- a/src/Website/.prettierignore +++ b/src/Website/.prettierignore @@ -2,3 +2,6 @@ build node_modules .react-router package-lock.json +storybook-static +test-results +debug-storybook.log diff --git a/src/Website/.prettierrc.json b/src/Website/.prettierrc.json index 5bab065..aa38807 100644 --- a/src/Website/.prettierrc.json +++ b/src/Website/.prettierrc.json @@ -1,10 +1,11 @@ { - "$schema": "https://json.schemastore.org/prettierrc", - "printWidth": 100, - "singleQuote": false, - "trailingComma": "all", - "semi": true, - "tabWidth": 2, - "useTabs": false, - "arrowParens": "always" + "$schema": "https://json.schemastore.org/prettierrc", + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "tabWidth": 3, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" } diff --git a/src/Website/.storybook/main.ts b/src/Website/.storybook/main.ts index 2023f6d..3912271 100644 --- a/src/Website/.storybook/main.ts +++ b/src/Website/.storybook/main.ts @@ -1,50 +1,50 @@ -import type { StorybookConfig } from "@storybook/react-vite"; +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; - } + 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"); - }); + const pluginName = typeof plugin === 'object' && 'name' in plugin ? plugin.name : ''; + return !pluginName.startsWith('react-router'); + }); - config.build ??= {}; - config.build.rollupOptions ??= {}; + config.build ??= {}; + config.build.rollupOptions ??= {}; - const previousOnWarn = config.build.rollupOptions.onwarn; - config.build.rollupOptions.onwarn = (warning, warn) => { - if (warning.code === "MODULE_LEVEL_DIRECTIVE") { - return; - } + 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; - } + if (typeof previousOnWarn === 'function') { + previousOnWarn(warning, warn); + return; + } - warn(warning); - }; + warn(warning); + }; - return config; - }, + return config; + }, }; export default config; diff --git a/src/Website/.storybook/preview-head.html b/src/Website/.storybook/preview-head.html index 94cbfe1..3ed7673 100644 --- a/src/Website/.storybook/preview-head.html +++ b/src/Website/.storybook/preview-head.html @@ -1,6 +1,6 @@ diff --git a/src/Website/.storybook/preview.ts b/src/Website/.storybook/preview.ts index 448af60..223a6ba 100644 --- a/src/Website/.storybook/preview.ts +++ b/src/Website/.storybook/preview.ts @@ -1,63 +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"; +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, - })), + 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; + }, + 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, + 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)), + ), + ); }, - }, - layout: "padded", + ], + 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", - }, - }, + 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; diff --git a/src/Website/.storybook/vitest.setup.ts b/src/Website/.storybook/vitest.setup.ts index 44922d5..ea170b0 100644 --- a/src/Website/.storybook/vitest.setup.ts +++ b/src/Website/.storybook/vitest.setup.ts @@ -1,7 +1,7 @@ -import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; +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]); \ No newline at end of file +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/src/Website/app/app.css b/src/Website/app/app.css index 1f8727a..97129e4 100644 --- a/src/Website/app/app.css +++ b/src/Website/app/app.css @@ -1,13 +1,13 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @plugin "daisyui" { - themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen; + 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; + --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, @@ -17,210 +17,235 @@ h4, h5, h6, .card-title { - font-family: var(--font-serif); + 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"; - /* Base — muted parchment / brushed paper */ - --color-base-100: oklch(96% 0.012 82); - --color-base-200: oklch(92% 0.018 80); - --color-base-300: oklch(87% 0.025 78); - --color-base-content: oklch(30% 0.025 58); - /* Primary — mellow amber */ - --color-primary: oklch(65% 0.085 62); - --color-primary-content: oklch(20% 0.02 58); - /* Secondary — softened mahogany */ - --color-secondary: oklch(42% 0.05 42); - --color-secondary-content: oklch(96% 0.01 76); - /* Accent — frothy cream */ - --color-accent: oklch(93% 0.015 90); - --color-accent-content: oklch(32% 0.02 58); - /* Neutral — warm roast */ - --color-neutral: oklch(28% 0.02 46); - --color-neutral-content: oklch(92% 0.012 80); - /* Info — muted hop green */ - --color-info: oklch(46% 0.065 145); - --color-info-content: oklch(97% 0.008 145); - /* Success — soft barley */ - --color-success: oklch(70% 0.06 122); - --color-success-content: oklch(22% 0.02 122); - /* Warning — toned amber */ - --color-warning: oklch(72% 0.09 56); - --color-warning-content: oklch(20% 0.02 56); - /* Error — restrained cherry */ - --color-error: oklch(54% 0.09 22); - --color-error-content: oklch(97% 0.006 15); - --radius-selector: 0.375rem; - --radius-field: 0.5rem; - --radius-box: 0.875rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1px; - --depth: 1; - --noise: 1; + 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"; + name: 'biergarten-stout'; + default: false; + prefersdark: true; + color-scheme: 'dark'; - /* Base — charred barrel / roasted malt darkness */ - --color-base-100: oklch(14% 0.006 45); - --color-base-200: oklch(18% 0.008 43); - --color-base-300: oklch(23% 0.01 42); - --color-base-content: oklch(88% 0.008 75); + --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 */ - /* Primary — golden amber lager */ - --color-primary: oklch(68% 0.055 60); - --color-primary-content: oklch(14% 0.012 50); + --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 */ - /* Secondary — deep mahogany ale */ - --color-secondary: oklch(51% 0.025 40); - --color-secondary-content: oklch(97% 0.005 75); + --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 */ - /* Accent — frothy cream head */ - --color-accent: oklch(82% 0.01 88); - --color-accent-content: oklch(20% 0.01 55); + --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 */ - /* Neutral — near-black with warmth */ - --color-neutral: oklch(20% 0.008 45); - --color-neutral-content: oklch(88% 0.007 78); + --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 */ - /* Info — cool hop green */ - --color-info: oklch(54% 0.04 145); - --color-info-content: oklch(97% 0.005 145); + --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 */ - /* Success — fresh barley */ - --color-success: oklch(66% 0.038 120); - --color-success-content: oklch(14% 0.012 120); + --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 */ - /* Warning — amber harvest */ - --color-warning: oklch(70% 0.055 55); - --color-warning-content: oklch(14% 0.012 55); + --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 */ - /* Error — deep cherry kriek */ - --color-error: oklch(50% 0.06 20); - --color-error-content: oklch(97% 0.004 15); + --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 */ - --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; + --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"; + name: 'biergarten-cassis'; + default: false; + prefersdark: false; + color-scheme: 'dark'; - /* Base — blackberry-stained barrel, dark purple-black */ - --color-base-100: oklch(13% 0.01 295); - --color-base-200: oklch(17% 0.013 292); - --color-base-300: oklch(22% 0.016 290); - --color-base-content: oklch(90% 0.014 300); + --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 */ - /* Primary — cassis berry purple */ - --color-primary: oklch(52% 0.07 295); - --color-primary-content: oklch(97% 0.008 295); + --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 */ - /* Secondary — sour cherry */ - --color-secondary: oklch(46% 0.05 10); - --color-secondary-content: oklch(97% 0.006 10); + --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 */ - /* Accent — tart lime zest */ - --color-accent: oklch(75% 0.045 130); - --color-accent-content: oklch(18% 0.04 130); + --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 */ - /* Neutral — deep blackened grape */ - --color-neutral: oklch(18% 0.016 290); - --color-neutral-content: oklch(88% 0.01 295); + --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 */ - /* Info — muted indigo */ - --color-info: oklch(46% 0.035 250); - --color-info-content: oklch(97% 0.006 250); + --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 */ - /* Success — dark elderberry green */ - --color-success: oklch(50% 0.035 145); - --color-success-content: oklch(97% 0.006 145); + --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 */ - /* Warning — sour apricot */ - --color-warning: oklch(70% 0.05 65); - --color-warning-content: oklch(18% 0.03 65); + --color-warning: oklch(70% 0.05 65); /* sour apricot */ + --color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */ - /* Error — kriek red */ - --color-error: oklch(50% 0.055 22); - --color-error-content: oklch(97% 0.006 22); + --color-error: oklch(50% 0.055 22); /* kriek red */ + --color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */ - --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; + --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"; + name: 'biergarten-weizen'; + default: false; + prefersdark: false; + color-scheme: 'light'; - /* Base — near-white with the faintest young-barley green breath */ - --color-base-100: oklch(99% 0.007 112); - --color-base-200: oklch(96% 0.012 114); - --color-base-300: oklch(92% 0.019 116); - --color-base-content: oklch(20% 0.022 122); + --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 */ - /* Primary — fresh-cut barley, green-gold */ - --color-primary: oklch(70% 0.09 118); - --color-primary-content: oklch(16% 0.022 118); + --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 */ - /* Secondary — muted sage stem */ - --color-secondary: oklch(44% 0.055 128); - --color-secondary-content: oklch(97% 0.005 128); + --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 */ - /* Accent — pale morning dew */ - --color-accent: oklch(93% 0.03 148); - --color-accent-content: oklch(22% 0.022 148); + --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 */ - /* Neutral — dried straw with green memory */ - --color-neutral: oklch(88% 0.014 116); - --color-neutral-content: oklch(20% 0.02 118); + --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 */ - /* Info — clear summer sky */ - --color-info: oklch(46% 0.07 232); - --color-info-content: oklch(98% 0.005 232); + --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 */ - /* Success — vivid young shoot */ - --color-success: oklch(48% 0.09 145); - --color-success-content: oklch(98% 0.005 145); + --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 */ - /* Warning — ripening grain amber */ - --color-warning: oklch(68% 0.1 76); - --color-warning-content: oklch(18% 0.02 72); + --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 */ - /* Error — washed dusty rose */ - --color-error: oklch(52% 0.1 18); - --color-error-content: oklch(98% 0.005 15); + --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 */ - --radius-selector: 2rem; - --radius-field: 2rem; - --radius-box: 1rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1px; - --depth: 0; - --noise: 0; + --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; } diff --git a/src/Website/app/components/Navbar.tsx b/src/Website/app/components/Navbar.tsx index 59fed66..c4c47a3 100644 --- a/src/Website/app/components/Navbar.tsx +++ b/src/Website/app/components/Navbar.tsx @@ -1,138 +1,144 @@ import { - Disclosure, - DisclosureButton, - DisclosurePanel, - Menu, - MenuButton, - MenuItem, - MenuItems, -} from "@headlessui/react"; -import { Link } from "react-router"; + 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; + 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" }, - ]; + const navLinks = [ + { to: '/theme', label: 'Theme' }, + { to: '/beers', label: 'Beers' }, + { to: '/breweries', label: 'Breweries' }, + { to: '/beer-styles', label: 'Beer Styles' }, + ]; - return ( - - {({ open }) => ( - <> -
-
- - - {open ? ( - - ) : ( - - )} - - + return ( + + {({ open }) => ( + <> +
+
+ + + {open ? ( + + ) : ( + + )} + + - - 🍺 The Biergarten App - -
+ + 🍺 The Biergarten App + +
-
- {navLinks.map((link) => ( - - {link.label} - - ))} -
+
+ {navLinks.map((link) => ( + + {link.label} + + ))} +
-
- {!auth && ( - - Register User - - )} +
+ {!auth && ( + + Register User + + )} - {auth ? ( - <> - - Dashboard - + {auth ? ( + <> + + Dashboard + - - {auth.username} - - - {({ focus }) => ( - - Dashboard - - )} - - - {({ focus }) => ( - - Logout - - )} - - - - - ) : ( - - Login - - )} -
-
+ + + {auth.username} + + + + {({ focus }) => ( + + Dashboard + + )} + + + {({ focus }) => ( + + Logout + + )} + + + + + ) : ( + + Login + + )} +
+
- -
- {navLinks.map((link) => ( - - {link.label} - - ))} - {!auth && ( - - Register User - - )} -
-
- - )} -
- ); + +
+ {navLinks.map((link) => ( + + {link.label} + + ))} + {!auth && ( + + Register User + + )} +
+
+ + )} + + ); } diff --git a/src/Website/app/components/forms/FormField.tsx b/src/Website/app/components/forms/FormField.tsx index 674e17f..ae54272 100644 --- a/src/Website/app/components/forms/FormField.tsx +++ b/src/Website/app/components/forms/FormField.tsx @@ -1,40 +1,40 @@ -import { Description, Field, Label } from "@headlessui/react"; +import { Description, Field, Label } from '@headlessui/react'; type FormFieldProps = React.InputHTMLAttributes & { - label: string; - error?: string; - hint?: string; - labelClassName?: string; - inputClassName?: string; - hintClassName?: string; + label: string; + error?: string; + hint?: string; + labelClassName?: string; + inputClassName?: string; + hintClassName?: string; }; export default function FormField({ - label, - error, - hint, - className, - labelClassName, - inputClassName, - hintClassName, - ...inputProps + label, + error, + hint, + className, + labelClassName, + inputClassName, + hintClassName, + ...inputProps }: FormFieldProps) { - return ( - - + return ( + + - + - {error ? ( - {error} - ) : hint ? ( - {hint} - ) : null} - - ); + {error ? ( + {error} + ) : hint ? ( + {hint} + ) : null} + + ); } diff --git a/src/Website/app/components/forms/SubmitButton.tsx b/src/Website/app/components/forms/SubmitButton.tsx index 7c227f5..aacbdfe 100644 --- a/src/Website/app/components/forms/SubmitButton.tsx +++ b/src/Website/app/components/forms/SubmitButton.tsx @@ -1,31 +1,31 @@ -import { Button } from "@headlessui/react"; +import { Button } from '@headlessui/react'; interface SubmitButtonProps { - isSubmitting: boolean; - idleText: string; - submittingText: string; - className?: string; + isSubmitting: boolean; + idleText: string; + submittingText: string; + className?: string; } export default function SubmitButton({ - isSubmitting, - idleText, - submittingText, - className, + isSubmitting, + idleText, + submittingText, + className, }: SubmitButtonProps) { - return ( - - ); + return ( + + ); } diff --git a/src/Website/app/components/toast/ToastProvider.tsx b/src/Website/app/components/toast/ToastProvider.tsx index 5b057c4..c5e3e0f 100644 --- a/src/Website/app/components/toast/ToastProvider.tsx +++ b/src/Website/app/components/toast/ToastProvider.tsx @@ -1,25 +1,25 @@ -import { Toaster } from "react-hot-toast"; +import { Toaster } from 'react-hot-toast'; export default function ToastProvider() { - return ( - - ); + return ( + + ); } diff --git a/src/Website/app/components/toast/toast.ts b/src/Website/app/components/toast/toast.ts index ef2c815..a3cdd49 100644 --- a/src/Website/app/components/toast/toast.ts +++ b/src/Website/app/components/toast/toast.ts @@ -1,4 +1,4 @@ -import toast from "react-hot-toast"; +import toast from 'react-hot-toast'; export const showSuccessToast = (message: string) => toast.success(message); export const showErrorToast = (message: string) => toast.error(message); diff --git a/src/Website/app/lib/auth.server.ts b/src/Website/app/lib/auth.server.ts index 6036836..760ce14 100644 --- a/src/Website/app/lib/auth.server.ts +++ b/src/Website/app/lib/auth.server.ts @@ -1,175 +1,162 @@ -import { createCookieSessionStorage, redirect } from "react-router"; +import { createCookieSessionStorage, redirect } from 'react-router'; -const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:8080"; +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; export interface AuthTokens { - accessToken: string; - refreshToken: string; - userAccountId: string; - username: string; + accessToken: string; + refreshToken: string; + userAccountId: string; + username: string; } interface ApiResponse { - message: string; - payload: T; + message: string; + payload: T; } interface LoginPayload { - userAccountId: string; - username: string; - refreshToken: string; - accessToken: string; + userAccountId: string; + username: string; + refreshToken: string; + accessToken: string; } interface RegistrationPayload extends LoginPayload { - confirmationEmailSent: boolean; + 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", - }, + 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")); + return sessionStorage.getSession(request.headers.get('Cookie')); } -export async function commitSession( - session: Awaited>, -) { - return sessionStorage.commitSession(session); +export async function commitSession(session: Awaited>) { + return sessionStorage.commitSession(session); } -export async function destroySession( - session: Awaited>, -) { - return sessionStorage.destroySession(session); +export async function destroySession(session: Awaited>) { + return sessionStorage.destroySession(session); } export async function requireAuth(request: Request): Promise { - const session = await getSession(request); - const accessToken = session.get("accessToken"); - const refreshToken = session.get("refreshToken"); + const session = await getSession(request); + const accessToken = session.get('accessToken'); + const refreshToken = session.get('refreshToken'); - if (!accessToken || !refreshToken) { - throw redirect("/login"); - } + if (!accessToken || !refreshToken) { + throw redirect('/login'); + } - return { - accessToken, - refreshToken, - userAccountId: session.get("userAccountId"), - username: session.get("username"), - }; + return { + accessToken, + refreshToken, + userAccountId: session.get('userAccountId'), + username: session.get('username'), + }; } -export async function getOptionalAuth( - request: Request, -): Promise { - const session = await getSession(request); - const accessToken = session.get("accessToken"); +export async function getOptionalAuth(request: Request): Promise { + const session = await getSession(request); + const accessToken = session.get('accessToken'); - if (!accessToken) return null; + if (!accessToken) return null; - return { - accessToken, - refreshToken: session.get("refreshToken"), - userAccountId: session.get("userAccountId"), - username: session.get("username"), - }; + 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 }), - }); + 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})`); - } + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Login failed (${res.status})`); + } - const data: ApiResponse = await res.json(); - return data.payload; + const data: ApiResponse = await res.json(); + return data.payload; } export async function register(body: { - username: string; - firstName: string; - lastName: string; - email: string; - dateOfBirth: string; - password: string; + 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), - }); + 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})`); - } + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Registration failed (${res.status})`); + } - const data: ApiResponse = await res.json(); - return data.payload; + const data: ApiResponse = 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 }), - }); + 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"); - } + if (!res.ok) { + throw new Error('Token refresh failed'); + } - const data: ApiResponse = await res.json(); - return data.payload; + const data: ApiResponse = 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", + 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})`); - } + 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; + 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); +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) }, - }); + return redirect(redirectTo, { + headers: { 'Set-Cookie': await commitSession(session) }, + }); } diff --git a/src/Website/app/lib/schemas.ts b/src/Website/app/lib/schemas.ts index 55783ee..90b83da 100644 --- a/src/Website/app/lib/schemas.ts +++ b/src/Website/app/lib/schemas.ts @@ -1,33 +1,33 @@ -import { z } from "zod"; +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"), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), }); export type LoginSchema = z.infer; 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"], - }); + .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; diff --git a/src/Website/app/lib/themes.ts b/src/Website/app/lib/themes.ts index 95ae708..bc773b0 100644 --- a/src/Website/app/lib/themes.ts +++ b/src/Website/app/lib/themes.ts @@ -1,41 +1,41 @@ export type ThemeName = - | "biergarten-lager" - | "biergarten-stout" - | "biergarten-cassis" - | "biergarten-weizen"; + | 'biergarten-lager' + | 'biergarten-stout' + | 'biergarten-cassis' + | 'biergarten-weizen'; export interface ThemeOption { - value: ThemeName; - label: string; - vibe: string; + value: ThemeName; + label: string; + vibe: string; } -export const defaultThemeName: ThemeName = "biergarten-lager"; -export const themeStorageKey = "biergarten-theme"; +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", - }, + { + 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); + return biergartenThemes.some((theme) => theme.value === value); } diff --git a/src/Website/app/root.tsx b/src/Website/app/root.tsx index 7016f9a..dac0d97 100644 --- a/src/Website/app/root.tsx +++ b/src/Website/app/root.tsx @@ -1,88 +1,90 @@ import { - isRouteErrorResponse, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, -} from "react-router"; + 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"; +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", - }, + { 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 }; + const auth = await getOptionalAuth(request); + return { auth }; }; export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - {children} - - - - - ); + return ( + + + + + + + + + {children} + + + + + ); } export default function App({ loaderData }: Route.ComponentProps) { - const { auth } = loaderData; - return ( - <> - - - - - ); + const { auth } = loaderData; + return ( + <> + + + + + ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - let message = "Oops!"; - let details = "An unexpected error occurred."; - let stack: string | undefined; + 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; - } + 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 ( -
-

{message}

-

{details}

- {stack && ( -
-          {stack}
-        
- )} -
- ); + return ( +
+

{message}

+

{details}

+ {stack && ( +
+               {stack}
+            
+ )} +
+ ); } diff --git a/src/Website/app/routes.ts b/src/Website/app/routes.ts index 53edaf3..e397edb 100644 --- a/src/Website/app/routes.ts +++ b/src/Website/app/routes.ts @@ -1,14 +1,14 @@ -import { type RouteConfig, index, route } from "@react-router/dev/routes"; +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"), + 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; diff --git a/src/Website/app/routes/beer-styles.tsx b/src/Website/app/routes/beer-styles.tsx index 26c2e76..cd03724 100644 --- a/src/Website/app/routes/beer-styles.tsx +++ b/src/Website/app/routes/beer-styles.tsx @@ -1,18 +1,16 @@ -import type { Route } from "./+types/beer-styles"; +import type { Route } from './+types/beer-styles'; export function meta({}: Route.MetaArgs) { - return [{ title: "Beer Styles | The Biergarten App" }]; + return [{ title: 'Beer Styles | The Biergarten App' }]; } export default function BeerStyles() { - return ( -
-
-

Beer Styles

-

- Learn about different beer styles. -

+ return ( +
+
+

Beer Styles

+

Learn about different beer styles.

+
-
- ); + ); } diff --git a/src/Website/app/routes/beers.tsx b/src/Website/app/routes/beers.tsx index 7bd1b56..793df2c 100644 --- a/src/Website/app/routes/beers.tsx +++ b/src/Website/app/routes/beers.tsx @@ -1,16 +1,16 @@ -import type { Route } from "./+types/beers"; +import type { Route } from './+types/beers'; export function meta({}: Route.MetaArgs) { - return [{ title: "Beers | The Biergarten App" }]; + return [{ title: 'Beers | The Biergarten App' }]; } export default function Beers() { - return ( -
-
-

Beers

-

Explore our collection of beers.

+ return ( +
+
+

Beers

+

Explore our collection of beers.

+
-
- ); + ); } diff --git a/src/Website/app/routes/breweries.tsx b/src/Website/app/routes/breweries.tsx index 1e7987a..0a620a5 100644 --- a/src/Website/app/routes/breweries.tsx +++ b/src/Website/app/routes/breweries.tsx @@ -1,16 +1,16 @@ -import type { Route } from "./+types/breweries"; +import type { Route } from './+types/breweries'; export function meta({}: Route.MetaArgs) { - return [{ title: "Breweries | The Biergarten App" }]; + return [{ title: 'Breweries | The Biergarten App' }]; } export default function Breweries() { - return ( -
-
-

Breweries

-

Discover our partner breweries.

+ return ( +
+
+

Breweries

+

Discover our partner breweries.

+
-
- ); + ); } diff --git a/src/Website/app/routes/confirm.tsx b/src/Website/app/routes/confirm.tsx index 8fc425c..bae6f2c 100644 --- a/src/Website/app/routes/confirm.tsx +++ b/src/Website/app/routes/confirm.tsx @@ -1,90 +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"; +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" }]; + 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"); + 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." }; - } + 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.", - }; - } + 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; - } + useEffect(() => { + if (loaderData.success) { + showSuccessToast('Email confirmed successfully.'); + return; + } - showErrorToast(loaderData.error); - }, [loaderData]); + showErrorToast(loaderData.error); + }, [loaderData]); - return ( -
-
-
- {loaderData.success ? ( - <> -
-

Email Confirmed!

-

- Your email address has been successfully verified. -

-
- - Confirmed at - -

- {new Date(loaderData.confirmedDate).toLocaleString()} -

-
-
- - Go to Dashboard - -
- - ) : ( - <> -
-

Confirmation Failed

-
- {loaderData.error} -
-

- The confirmation link may have expired (valid for 30 minutes) or already been used. -

-
- - Back to Dashboard - -
- - )} -
+ return ( +
+
+
+ {loaderData.success ? ( + <> +
+

Email Confirmed!

+

+ Your email address has been successfully verified. +

+
+ + Confirmed at + +

+ {new Date(loaderData.confirmedDate).toLocaleString()} +

+
+
+ + Go to Dashboard + +
+ + ) : ( + <> +
+

Confirmation Failed

+
+ {loaderData.error} +
+

+ The confirmation link may have expired (valid for 30 minutes) or already + been used. +

+
+ + Back to Dashboard + +
+ + )} +
+
-
- ); + ); } diff --git a/src/Website/app/routes/dashboard.tsx b/src/Website/app/routes/dashboard.tsx index 763f11e..55d9e46 100644 --- a/src/Website/app/routes/dashboard.tsx +++ b/src/Website/app/routes/dashboard.tsx @@ -1,110 +1,105 @@ -import { requireAuth } from "../lib/auth.server"; -import type { Route } from "./+types/dashboard"; +import { requireAuth } from '../lib/auth.server'; +import type { Route } from './+types/dashboard'; export function meta({}: Route.MetaArgs) { - return [{ title: "Dashboard | The Biergarten App" }]; + 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, - }; + const auth = await requireAuth(request); + return { + username: auth.username, + userAccountId: auth.userAccountId, + }; } export default function Dashboard({ loaderData }: Route.ComponentProps) { - const { username, userAccountId } = loaderData; + const { username, userAccountId } = loaderData; - return ( -
-
-
-
-

Welcome, {username}!

-

- You are successfully authenticated. This is a protected page that - requires a valid session. -

+ return ( +
+
+
+
+

Welcome, {username}!

+

+ You are successfully authenticated. This is a protected page that requires a + valid session. +

-
-

- Session Info -

-
-
-
Username
-
{username}
-
-
-
User ID
-
- {userAccountId} +
+

+ Session Info +

+
+
+
Username
+
{username}
+
+
+
User ID
+
{userAccountId}
+
+
-
-
+
-
-
-
-
-

Auth Flow Demo

-

- This demo showcases the following authentication features: -

-
    -
  • -
    -

    Login

    -

    - POST to /api/auth/login{" "} - with username & password +

    +
    +

    Auth Flow Demo

    +

    + This demo showcases the following authentication features:

    -
    -
  • -
  • -
    -

    Register

    -

    - POST to{" "} - /api/auth/register with - full user details -

    -
    -
  • -
  • -
    -

    Session

    -

    - JWT access & refresh tokens stored in an HTTP-only - cookie -

    -
    -
  • -
  • -
    -

    Protected Routes

    -

    - This dashboard requires authentication via{" "} - requireAuth() -

    -
    -
  • -
  • -
    -

    Token Refresh

    -

    - POST to{" "} - /api/auth/refresh with - refresh token -

    -
    -
  • -
-
-
+
    +
  • +
    +

    Login

    +

    + POST to /api/auth/login with + username & password +

    +
    +
  • +
  • +
    +

    Register

    +

    + POST to /api/auth/register with + full user details +

    +
    +
  • +
  • +
    +

    Session

    +

    + JWT access & refresh tokens stored in an HTTP-only cookie +

    +
    +
  • +
  • +
    +

    Protected Routes

    +

    + This dashboard requires authentication via{' '} + requireAuth() +

    +
    +
  • +
  • +
    +

    Token Refresh

    +

    + POST to /api/auth/refresh with + refresh token +

    +
    +
  • +
+
+
+
-
- ); + ); } diff --git a/src/Website/app/routes/home.tsx b/src/Website/app/routes/home.tsx index 1e92079..abdfbf9 100644 --- a/src/Website/app/routes/home.tsx +++ b/src/Website/app/routes/home.tsx @@ -1,56 +1,56 @@ -import { Link } from "react-router"; -import { getOptionalAuth } from "../lib/auth.server"; -import type { Route } from "./+types/home"; +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" }, - ]; + 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 }; + const auth = await getOptionalAuth(request); + return { username: auth?.username ?? null }; } export default function Home({ loaderData }: Route.ComponentProps) { - const { username } = loaderData; + const { username } = loaderData; - return ( -
-
-
-

🍺 The Biergarten App

-

Authentication Demo

+ return ( +
+
+
+

🍺 The Biergarten App

+

Authentication Demo

- {username ? ( - <> -

- Welcome back,{" "} - {username}! -

-
- - Dashboard - - - Logout - -
- - ) : ( -
- - Login - - - Register - + {username ? ( + <> +

+ Welcome back, {username} + ! +

+
+ + Dashboard + + + Logout + +
+ + ) : ( +
+ + Login + + + Register + +
+ )}
- )} -
+
-
- ); + ); } diff --git a/src/Website/app/routes/login.tsx b/src/Website/app/routes/login.tsx index 58fa7c0..f4de996 100644 --- a/src/Website/app/routes/login.tsx +++ b/src/Website/app/routes/login.tsx @@ -1,128 +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"; +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" }]; + 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; + 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"), - }); + 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 }; - } + 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." }; - } + 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 navigation = useNavigation(); + const submit = useSubmit(); + const isSubmitting = navigation.state === 'submitting'; - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ resolver: zodResolver(loginSchema) }); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(loginSchema) }); - const onSubmit = handleSubmit((data) => { - submit(data, { method: "post" }); - }); + const onSubmit = handleSubmit((data) => { + submit(data, { method: 'post' }); + }); - useEffect(() => { - if (actionData?.error) { - showErrorToast(actionData.error); - } - }, [actionData?.error]); + useEffect(() => { + if (actionData?.error) { + showErrorToast(actionData.error); + } + }, [actionData?.error]); - return ( -
-
-
-
-

-

-

Sign in to your Biergarten account

-
+ return ( +
+
+
+
+

+

+

Sign in to your Biergarten account

+
- {actionData?.error && ( -
- {actionData.error} + {actionData?.error && ( +
+ {actionData.error} +
+ )} + +
+ + + + + + + +
New here?
+ +
+ +
- )} - -
- - - - - - - -
New here?
- -
- -
-
+
-
- ); + ); } diff --git a/src/Website/app/routes/logout.tsx b/src/Website/app/routes/logout.tsx index cca66d0..144ec0e 100644 --- a/src/Website/app/routes/logout.tsx +++ b/src/Website/app/routes/logout.tsx @@ -1,10 +1,10 @@ -import { redirect } from "react-router"; -import { destroySession, getSession } from "../lib/auth.server"; -import type { Route } from "./+types/logout"; +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) }, - }); + const session = await getSession(request); + return redirect('/', { + headers: { 'Set-Cookie': await destroySession(session) }, + }); } diff --git a/src/Website/app/routes/register.tsx b/src/Website/app/routes/register.tsx index 4c53498..4dbeceb 100644 --- a/src/Website/app/routes/register.tsx +++ b/src/Website/app/routes/register.tsx @@ -1,189 +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"; +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" }]; + 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; + 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"), - }); + 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 }; - } + 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, - }; - } + 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 navigation = useNavigation(); + const submit = useSubmit(); + const isSubmitting = navigation.state === 'submitting'; - const { - register: field, - handleSubmit, - formState: { errors }, - } = useForm({ resolver: zodResolver(registerSchema) }); + const { + register: field, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(registerSchema) }); - const onSubmit = handleSubmit((data) => { - submit(data, { method: "post" }); - }); + const onSubmit = handleSubmit((data) => { + submit(data, { method: 'post' }); + }); - useEffect(() => { - if (actionData?.error) { - showErrorToast(actionData.error); - } - }, [actionData?.error]); + useEffect(() => { + if (actionData?.error) { + showErrorToast(actionData.error); + } + }, [actionData?.error]); - return ( -
-
-
-
-

Register

-

Create your Biergarten account

-
+ return ( +
+
+
+
+

Register

+

Create your Biergarten account

+
- {actionData?.error && ( -
- {actionData.error} + {actionData?.error && ( +
+ {actionData.error} +
+ )} + +
+ + +
+ + + +
+ + + + + + + + + + + + +
Already have an account?
+ +
+ + Sign in + + + ← Back to home + +
- )} - -
- - -
- - - -
- - - - - - - - - - - - -
Already have an account?
- -
- - Sign in - - - ← Back to home - -
-
+
-
- ); + ); } diff --git a/src/Website/app/routes/theme.tsx b/src/Website/app/routes/theme.tsx index 115e5fd..03edbf6 100644 --- a/src/Website/app/routes/theme.tsx +++ b/src/Website/app/routes/theme.tsx @@ -1,158 +1,169 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState } from 'react'; import { - biergartenThemes, - defaultThemeName, - isBiergartenTheme, - themeStorageKey, - type ThemeName, -} from "../lib/themes"; -import type { Route } from "./+types/theme"; + 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", - }, - ]; + 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); + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(themeStorageKey, theme); } export default function ThemePage() { - const [selectedTheme, setSelectedTheme] = useState(() => { - if (typeof window === "undefined") { - return defaultThemeName; - } + const [selectedTheme, setSelectedTheme] = useState(() => { + if (typeof window === 'undefined') { + return defaultThemeName; + } - const savedTheme = localStorage.getItem(themeStorageKey); - return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName; - }); + const savedTheme = localStorage.getItem(themeStorageKey); + return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName; + }); - useEffect(() => { - applyTheme(selectedTheme); - }, [selectedTheme]); + useEffect(() => { + applyTheme(selectedTheme); + }, [selectedTheme]); - const activeTheme = - biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0]; + const activeTheme = + biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0]; - return ( -
-
-
-
-

Theme Guide

-

- 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. -

-
- - Active theme: {activeTheme.label} — {activeTheme.vibe} - -
-
-
+ return ( +
+
+
+
+

Theme Guide

+

+ 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. +

+
+ + Active theme: {activeTheme.label} — {activeTheme.vibe} + +
+
+
-
-
-

Theme switcher

-

Pick a theme and preview it immediately.

+
+
+

Theme switcher

+

Pick a theme and preview it immediately.

-
- {biergartenThemes.map((theme) => { - const checked = selectedTheme === theme.value; - - return ( - - ); - })} -
-
-
+ {biergartenThemes.map((theme) => { + const checked = selectedTheme === theme.value; -
-
-
-

Brand colors

-
-
Primary
-
Secondary
-
Accent
-
Neutral
-
-
-
+ return ( + + ); + })} +
+
+ -
-
-

Status colors

-
-
Info
-
Success
-
Warning
-
Error
-
-
-
+
+
+
+

Brand colors

+
+
+ Primary +
+
+ Secondary +
+
Accent
+
+ Neutral +
+
+
+
-
-
-

Core style outline

-
    -
  • Warm serif headings paired with clear sans-serif body text
  • -
  • Rounded, tactile surfaces with subtle depth and grain
  • -
  • Semantic token usage to keep contrast consistent in both themes
  • -
-
-
-
+
+
+

Status colors

+
+
Info
+
+ Success +
+
+ Warning +
+
Error
+
+
+
-
-
-

Component preview

-
- - - - -
-
-
- Theme tokens are applied consistently. -
-
- Use semantic colors over hard-coded color values. -
-
-
-
-
-
- ); +
+
+

Core style outline

+
    +
  • Warm serif headings paired with clear sans-serif body text
  • +
  • Rounded, tactile surfaces with subtle depth and grain
  • +
  • Semantic token usage to keep contrast consistent in both themes
  • +
+
+
+ + +
+
+

Component preview

+
+ + + + +
+
+
+ Theme tokens are applied consistently. +
+
+ Use semantic colors over hard-coded color values. +
+
+
+
+
+ + ); } diff --git a/src/Website/eslint.config.mjs b/src/Website/eslint.config.mjs index 613a70b..e039543 100644 --- a/src/Website/eslint.config.mjs +++ b/src/Website/eslint.config.mjs @@ -1,47 +1,52 @@ // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from "eslint-plugin-storybook"; +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"; +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, +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: "^_", + plugins: { + 'react-hooks': reactHooks, }, - ], - }, -}, prettierConfig, storybook.configs["flat/recommended"]); + rules: { + ...reactHooks.configs.recommended.rules, + 'no-empty-pattern': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, + prettierConfig, + storybook.configs['flat/recommended'], +); diff --git a/src/Website/package.json b/src/Website/package.json index 6985f32..59deb6a 100644 --- a/src/Website/package.json +++ b/src/Website/package.json @@ -1,68 +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" - } + "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" + } } diff --git a/src/Website/playwright.storybook.config.ts b/src/Website/playwright.storybook.config.ts index 9ac326a..3058468 100644 --- a/src/Website/playwright.storybook.config.ts +++ b/src/Website/playwright.storybook.config.ts @@ -1,20 +1,20 @@ -import { defineConfig } from "@playwright/test"; +import { defineConfig } from '@playwright/test'; -const port = process.env.STORYBOOK_PORT ?? "6006"; +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, - }, + 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, + }, }); diff --git a/src/Website/postcss.config.js b/src/Website/postcss.config.js index c2ddf74..8998e7d 100644 --- a/src/Website/postcss.config.js +++ b/src/Website/postcss.config.js @@ -1,5 +1,5 @@ export default { - plugins: { - "@tailwindcss/postcss": {}, - }, + plugins: { + '@tailwindcss/postcss': {}, + }, }; diff --git a/src/Website/react-router.config.ts b/src/Website/react-router.config.ts index e45e273..1dc6cc4 100644 --- a/src/Website/react-router.config.ts +++ b/src/Website/react-router.config.ts @@ -1,5 +1,5 @@ -import type { Config } from "@react-router/dev/config"; +import type { Config } from '@react-router/dev/config'; export default { - ssr: true, + ssr: true, } satisfies Config; diff --git a/src/Website/stories/Configure.mdx b/src/Website/stories/Configure.mdx index d4b8e5f..35948c2 100644 --- a/src/Website/stories/Configure.mdx +++ b/src/Website/stories/Configure.mdx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/addon-docs/blocks"; +import { Meta } from '@storybook/addon-docs/blocks'; diff --git a/src/Website/stories/FormField.stories.tsx b/src/Website/stories/FormField.stories.tsx index 293602b..b35e5b1 100644 --- a/src/Website/stories/FormField.stories.tsx +++ b/src/Website/stories/FormField.stories.tsx @@ -1,61 +1,68 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; -import FormField from "../app/components/forms/FormField"; +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", - }, - render: (args) => ( -
- -
- ), + 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) => ( +
+ +
+ ), } satisfies Meta; export default meta; type Story = StoryObj; 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(); - }, + 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"); - }, + 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.", - }, + args: { + id: 'password', + name: 'password', + type: 'password', + label: 'Password', + placeholder: 'Enter a strong password', + hint: 'Use 12 or more characters.', + }, }; diff --git a/src/Website/stories/Navbar.stories.tsx b/src/Website/stories/Navbar.stories.tsx index bbc412c..aa9ff02 100644 --- a/src/Website/stories/Navbar.stories.tsx +++ b/src/Website/stories/Navbar.stories.tsx @@ -1,62 +1,69 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, userEvent, within } from "storybook/test"; -import Navbar from "../app/components/Navbar"; +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", - }, + title: 'Navigation/Navbar', + component: Navbar, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: navbarDescription, + }, + }, + }, } satisfies Meta; export default meta; type Story = StoryObj; 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(); - }, + 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(); - }, + 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(); - }, + 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(); + }, }; diff --git a/src/Website/stories/SubmitButton.stories.tsx b/src/Website/stories/SubmitButton.stories.tsx index 6649e96..15796da 100644 --- a/src/Website/stories/SubmitButton.stories.tsx +++ b/src/Website/stories/SubmitButton.stories.tsx @@ -1,45 +1,52 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; -import SubmitButton from "../app/components/forms/SubmitButton"; +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", - }, + 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; export default meta; type Story = StoryObj; export const Idle: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(canvas.getByRole("button", { name: /save changes/i })).toBeEnabled(); - }, + 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(); - }, + 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", - }, + args: { + className: 'btn btn-secondary min-w-64', + idleText: 'Register user', + submittingText: 'Registering user', + }, }; diff --git a/src/Website/stories/Themes.stories.tsx b/src/Website/stories/Themes.stories.tsx index 6fb0d4b..78e9dff 100644 --- a/src/Website/stories/Themes.stories.tsx +++ b/src/Website/stories/Themes.stories.tsx @@ -1,67 +1,157 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; -import { biergartenThemes } from "../app/lib/themes"; +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
{label}
; + return
{label}
; +} + +/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */ +function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color: string }) { + return ( +
+ {label} +
+ ); +} + +function TextTokenSample({ + label, + background, + text, +}: { + label: string; + background: string; + text: string; +}) { + return ( +
+

+ {label} +

+

+ Secondary copy, placeholders, and helper text. +

+
+ ); } function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) { - return ( -
-
-
-

{label}

-

{vibe}

-
+ return ( +
+
+
+

{label}

+

{vibe}

+
-
- - - - -
+ {/* Core palette */} +
+

+ Core +

+
+ + + + +
+
-
- - - -
+ {/* Status tokens */} +
+

+ Status +

+
+ + + + +
+
-
- Semantic tokens stay stable while the atmosphere changes. -
-
-
- ); + {/* Content tokens (custom) */} +
+

+ Content +

+
+ + + +
+
+ +
+
+ +
+ + + +
+ +
+ Semantic tokens stay stable while the atmosphere changes. +
+
+
+ ); } const meta = { - title: "Themes/Biergarten Themes", - parameters: { - layout: "fullscreen", - }, - tags: ["autodocs"], - render: () => ( -
- {biergartenThemes.map((theme) => ( - - ))} -
- ), + title: 'Themes/Biergarten Themes', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: themesDescription, + }, + }, + }, + tags: ['autodocs'], + render: () => ( +
+ {biergartenThemes.map((theme) => ( + + ))} +
+ ), } satisfies Meta; export default meta; type Story = StoryObj; 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(); - } - }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + for (const theme of biergartenThemes) { + await expect(canvas.getByRole('heading', { name: theme.label })).toBeInTheDocument(); + } + }, }; diff --git a/src/Website/stories/Toast.stories.tsx b/src/Website/stories/Toast.stories.tsx index 08cccb0..fc004a7 100644 --- a/src/Website/stories/Toast.stories.tsx +++ b/src/Website/stories/Toast.stories.tsx @@ -1,63 +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 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"; + 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 ( -
- -
-
-

Toast demo

-

Use these actions to preview toast styles.

-
- - - - -
-
+ return ( +
+ +
+
+

Toast demo

+

+ Use these actions to preview toast styles. +

+
+ + + + +
+
+
-
- ); + ); } const meta = { - title: "Feedback/Toast", - component: ToastDemo, - tags: ["autodocs"], + title: 'Feedback/Toast', + component: ToastDemo, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: toastDescription, + }, + }, + }, } satisfies Meta; export default meta; type Story = StoryObj; 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(); - }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: /success/i })); + await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument(); + }, }; diff --git a/src/Website/tailwind.config.js b/src/Website/tailwind.config.js index e9ac06b..71c098a 100644 --- a/src/Website/tailwind.config.js +++ b/src/Website/tailwind.config.js @@ -1,8 +1,8 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ["./app/**/*.{ts,tsx}"], - theme: { - extend: {}, - }, - plugins: [require("daisyui")], + content: ['./app/**/*.{ts,tsx}'], + theme: { + extend: {}, + }, + plugins: [require('daisyui')], }; diff --git a/src/Website/tests/playwright/storybook.components.spec.ts b/src/Website/tests/playwright/storybook.components.spec.ts index 257e13d..0700440 100644 --- a/src/Website/tests/playwright/storybook.components.spec.ts +++ b/src/Website/tests/playwright/storybook.components.spec.ts @@ -1,49 +1,49 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test'; const themes = [ - "biergarten-lager", - "biergarten-stout", - "biergarten-cassis", - "biergarten-weizen", + '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.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(`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 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('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(); - }); + 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(); + }); }); diff --git a/src/Website/tsconfig.json b/src/Website/tsconfig.json index 931afce..84ca439 100644 --- a/src/Website/tsconfig.json +++ b/src/Website/tsconfig.json @@ -1,19 +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"] + "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"] } diff --git a/src/Website/vite.config.ts b/src/Website/vite.config.ts index 5ad51f8..44f0ed0 100644 --- a/src/Website/vite.config.ts +++ b/src/Website/vite.config.ts @@ -1,47 +1,47 @@ /// -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"; +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)); + typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); const isStorybook = - process.env.STORYBOOK === "true" || process.argv.some((arg) => arg.includes("storybook")); + 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", - }, + 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'), + }), ], - }, - setupFiles: [".storybook/vitest.setup.ts"], - }, - }, - ], - }, + test: { + name: 'storybook', + browser: { + enabled: true, + headless: true, + provider: playwright({}), + instances: [ + { + browser: 'chromium', + }, + ], + }, + setupFiles: ['.storybook/vitest.setup.ts'], + }, + }, + ], + }, }); diff --git a/src/Website/vitest.shims.d.ts b/src/Website/vitest.shims.d.ts index 7782f28..03b1801 100644 --- a/src/Website/vitest.shims.d.ts +++ b/src/Website/vitest.shims.d.ts @@ -1 +1 @@ -/// \ No newline at end of file +///