diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e38da20 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +settings.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..576d93f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# Claude Code Guidelines + +## Branch naming + +Pattern: `/-` + +Types observed in this repo: +- `client/` — web-client only changes (UI, components, pages, theme, client deps) +- `feature/` — new functionality that touches backend or crosses client/server layers +- `chore/` — tooling, deps, config, migrations +- `infra/` — Docker, CI, proxy, infrastructure +- `docs/` — documentation only + +Examples: +- `client/44-members-page` +- `client/43-app-shell` +- `chore/53-web-client-ci` +- `feature/27-hook-client-to-server-end-points` — client+server change, so feature/ + +## Commit messages + +Conventional commit prefix, lowercase, imperative, no period. Short and specific. + +Prefixes: +- `feat:` — new feature or behaviour +- `fix:` — bug fix +- `chore:` — tooling, deps, config, no production code change +- `refactor:` — restructuring without behaviour change +- `style:` — formatting, CSS, theme, no logic change +- `docs:` — documentation only +- `test:` — adding or updating tests +- `ci:` — CI/CD pipeline changes + +Good: +- `feat: add sidebar layout component` +- `fix: axios interceptor on 401` +- `chore: migrate auth token storage to zustand` +- `style: apply bebas neue to page titles` + +Bad: +- `Add sidebar layout component` — missing prefix +- `Fixed the thing` — vague, past tense +- `WIP` + +## PR titles + +Pattern: ` #: ` + +Type is title-case, matching the branch type: +- `Client #44: Members page` +- `Client #43: App shell with sidebar layout` +- `Chore #53: Web client CI pipeline` +- `Infra #31: Implemented path routing for services` +- `Feature #27: Hook client to server endpoints` — crosses layers, so Feature + +Always include the issue number. + +## PR body + +Use this structure every time: + +``` +## Why +One or two sentences. What problem does this solve, or what goal does it serve? +Not what the code does — why it exists. + +## What changed +- Bullet per meaningful change, grouped by area if needed +- Keep each line scannable (one idea, one line) + +## Notes +Non-obvious decisions, tradeoffs, known limitations, or follow-up issues. +Omit this section if there is nothing worth flagging. + +## Testing +How was this verified? (e.g. "pnpm build passes", "manually tested dark mode toggle", "all routes navigate correctly") + +Closes # +``` + +Rules: +- Always close the linked issue with `Closes #` +- **Why** is mandatory — never skip it +- **Notes** is optional — only include if there is something a reviewer would otherwise have to figure out themselves +- No walls of prose; no vague "various improvements" bullets +- If setup steps are needed (migrations, one-time installs), add them under **Notes** diff --git a/web-client/package.json b/web-client/package.json index 5372315..9ece49a 100644 --- a/web-client/package.json +++ b/web-client/package.json @@ -19,8 +19,12 @@ "test:watch": "vitest" }, "dependencies": { + "@fontsource/bebas-neue": "^5.2.7", + "@fontsource/poppins": "^5.2.7", "@tailwindcss/vite": "^4.3.0", "axios": "^1.16.1", + "daisyui": "^5.5.20", + "lucide-react": "^1.17.0", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1", @@ -39,10 +43,10 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^25.0.1", + "openapi-typescript": "^7.8.0", "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", - "vitest": "^2.1.9", - "openapi-typescript": "^7.8.0" + "vitest": "^2.1.9" } } diff --git a/web-client/pnpm-lock.yaml b/web-client/pnpm-lock.yaml index ea2b315..12939ee 100644 --- a/web-client/pnpm-lock.yaml +++ b/web-client/pnpm-lock.yaml @@ -8,12 +8,24 @@ importers: .: dependencies: + '@fontsource/bebas-neue': + specifier: ^5.2.7 + version: 5.2.7 + '@fontsource/poppins': + specifier: ^5.2.7 + version: 5.2.7 '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@8.0.13(@types/node@24.12.4)(jiti@2.7.0)) axios: specifier: ^1.16.1 version: 1.16.1 + daisyui: + specifier: ^5.5.20 + version: 5.5.20 + lucide-react: + specifier: ^1.17.0 + version: 1.17.0(react@19.2.6) react: specifier: ^19.2.6 version: 19.2.6 @@ -365,6 +377,12 @@ packages: resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fontsource/bebas-neue@5.2.7': + resolution: {integrity: sha512-DsmBrmq55d9BCU0mt4DT4RZDdH8vhWRKEUOfbuNB1EEjMuwbtFvM8N+3gIlkYSFbsb10P8Q19BV5OdpMu2h0fA==} + + '@fontsource/poppins@5.2.7': + resolution: {integrity: sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1055,6 +1073,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + daisyui@5.5.20: + resolution: {integrity: sha512-HemJcjl0Gk9rQ8BcgofN6p+EURrqftQG9wK1Hkxs98i49xe68+QxpNvry+PyxwkIUgrbMpNmZ5ZWjmtffAjfhQ==} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -1478,6 +1499,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2204,6 +2230,10 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@fontsource/bebas-neue@5.2.7': {} + + '@fontsource/poppins@5.2.7': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2782,6 +2812,8 @@ snapshots: csstype@3.2.3: {} + daisyui@5.5.20: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -3206,6 +3238,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.17.0(react@19.2.6): + dependencies: + react: 19.2.6 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/web-client/src/app/router/AppRouter.tsx b/web-client/src/app/router/AppRouter.tsx index 8f4d482..84eac43 100644 --- a/web-client/src/app/router/AppRouter.tsx +++ b/web-client/src/app/router/AppRouter.tsx @@ -6,6 +6,7 @@ import { getLettersHello } from '@/features/letters/api' import { getMembersHello } from '@/features/members/api' import { getOrganizationHello } from '@/features/organization/api' import { getPaymentsHello } from '@/features/payments/api' +import { ThemeToggle } from '@/app/theme/ThemeToggle' type ServicePlaceholderPageProps = { title: string @@ -43,37 +44,40 @@ function ServicePlaceholderPage({ title, loadMessage }: ServicePlaceholderPagePr }, [loadMessage]) return ( -
-

{title}

-

- Placeholder page for initial client navigation. -

+
+
+

{title}

+

+ Placeholder page for initial client navigation. +

-
- {loading &&

Loading hello endpoint response...

} - {message &&

{message}

} - {error &&

Failed to load response: {error}

} +
+ {loading &&

Loading hello endpoint response...

} + {message &&

{message}

} + {error &&

Failed to load response: {error}

} +
) } const navLinkClass = ({ isActive }: { isActive: boolean }) => - `rounded-md px-3 py-2 text-sm ${isActive ? 'bg-slate-900 text-white' : 'bg-slate-200 text-slate-700'}` + `btn btn-sm ${isActive ? 'btn-primary' : 'btn-ghost'}` export function AppRouter() { return ( -
-
+
+
-

Team Devoops Client

-
diff --git a/web-client/src/app/theme/ThemeContext.ts b/web-client/src/app/theme/ThemeContext.ts new file mode 100644 index 0000000..dd9d897 --- /dev/null +++ b/web-client/src/app/theme/ThemeContext.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react' + +export interface ThemeContextType { + theme: 'lumio' | 'lumio-dark' + toggleTheme: () => void +} + +export const ThemeContext = createContext(undefined) diff --git a/web-client/src/app/theme/ThemeProvider.tsx b/web-client/src/app/theme/ThemeProvider.tsx new file mode 100644 index 0000000..ba3428e --- /dev/null +++ b/web-client/src/app/theme/ThemeProvider.tsx @@ -0,0 +1,29 @@ +import { type ReactNode, useEffect, useState } from 'react' +import { ThemeContext } from './ThemeContext' + +type Theme = 'lumio' | 'lumio-dark' + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem('theme') as Theme | null + if (stored === 'lumio' || stored === 'lumio-dark') return stored + + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + return prefersDark ? 'lumio-dark' : 'lumio' + }) + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme) + localStorage.setItem('theme', theme) + }, [theme]) + + const toggleTheme = () => { + setTheme((prev) => (prev === 'lumio' ? 'lumio-dark' : 'lumio')) + } + + return ( + + {children} + + ) +} diff --git a/web-client/src/app/theme/ThemeToggle.tsx b/web-client/src/app/theme/ThemeToggle.tsx new file mode 100644 index 0000000..57210c1 --- /dev/null +++ b/web-client/src/app/theme/ThemeToggle.tsx @@ -0,0 +1,17 @@ +import { Moon, Sun } from 'lucide-react' +import { useTheme } from './useTheme' + +export function ThemeToggle() { + const { theme, toggleTheme } = useTheme() + const isDark = theme === 'lumio-dark' + + return ( + + ) +} diff --git a/web-client/src/app/theme/useTheme.ts b/web-client/src/app/theme/useTheme.ts new file mode 100644 index 0000000..f2743ce --- /dev/null +++ b/web-client/src/app/theme/useTheme.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { ThemeContext } from './ThemeContext' + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within ThemeProvider') + } + return context +} diff --git a/web-client/src/features/payments/client.ts b/web-client/src/features/payments/client.ts index 29e2d1a..6c286ce 100644 --- a/web-client/src/features/payments/client.ts +++ b/web-client/src/features/payments/client.ts @@ -1,5 +1,5 @@ import axios from 'axios' export const paymentsClient = axios.create({ - baseURL: '/api/v1/finances', + baseURL: '/api/v1/finance', }) diff --git a/web-client/src/index.css b/web-client/src/index.css index d4b5078..5a5638c 100644 --- a/web-client/src/index.css +++ b/web-client/src/index.css @@ -1 +1,167 @@ +@import '@fontsource/bebas-neue/400.css'; +@import '@fontsource/poppins/400.css'; +@import '@fontsource/poppins/500.css'; +@import '@fontsource/poppins/600.css'; +@import '@fontsource/poppins/700.css'; @import 'tailwindcss'; +@plugin "daisyui"; + +@theme { + --font-sans: 'Poppins', ui-sans-serif, system-ui, sans-serif; + --font-display: 'Bebas Neue', ui-sans-serif, system-ui, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + + /* Display scale — Bebas Neue, use with font-display uppercase */ + --text-display-xs: 1.5rem; + --text-display-xs--line-height: 1.5rem; + --text-display-xs--font-weight: 400; + + --text-display-sm: 2rem; + --text-display-sm--line-height: 2rem; + --text-display-sm--font-weight: 400; + + --text-display-md: 2.5rem; + --text-display-md--line-height: 2.5rem; + --text-display-md--font-weight: 400; + + --text-display-lg: 3rem; + --text-display-lg--line-height: 3rem; + --text-display-lg--font-weight: 400; + + --text-display-xl: 4rem; + --text-display-xl--line-height: 4rem; + --text-display-xl--font-weight: 400; + + --text-display-2xl: 5rem; + --text-display-2xl--line-height: 5rem; + --text-display-2xl--font-weight: 400; + + /* Prose scale — Poppins */ + --text-h1: 2.25rem; + --text-h1--line-height: 2.75rem; + --text-h1--font-weight: 700; + + --text-h2: 1.875rem; + --text-h2--line-height: 2.375rem; + --text-h2--font-weight: 700; + + --text-h3: 1.5rem; + --text-h3--line-height: 2rem; + --text-h3--font-weight: 600; + + --text-h4: 1.25rem; + --text-h4--line-height: 1.75rem; + --text-h4--font-weight: 600; + + --text-body: 1rem; + --text-body--line-height: 1.5rem; + --text-body--font-weight: 400; + + --text-body-sm: 0.875rem; + --text-body-sm--line-height: 1.25rem; + --text-body-sm--font-weight: 400; + + --text-caption: 0.75rem; + --text-caption--line-height: 1rem; + --text-caption--font-weight: 400; + + --text-mono: 0.875rem; + --text-mono--line-height: 1.5rem; + --text-mono--font-weight: 400; +} + +/* Custom light theme — default */ +:root, +:root:has(input.theme-controller[value="lumio"]:checked), +[data-theme="lumio"] { + color-scheme: light; + --color-base-100: oklch(98% 0 0); + --color-base-200: oklch(97% 0 0); + --color-base-300: oklch(92% 0 0); + --color-base-content: oklch(20% 0 0); + --color-primary: oklch(89% 0.196 126.665); + --color-primary-content: oklch(27% 0.072 132.109); + --color-secondary: oklch(87% 0 0); + --color-secondary-content: oklch(14% 0 0); + --color-accent: oklch(86% 0.005 56.366); + --color-accent-content: oklch(14% 0.004 49.25); + --color-neutral: oklch(20% 0 0); + --color-neutral-content: oklch(98% 0 0); + --color-info: oklch(60% 0.126 221.723); + --color-info-content: oklch(98% 0.019 200.873); + --color-success: oklch(62% 0.194 149.214); + --color-success-content: oklch(98% 0.018 155.826); + --color-warning: oklch(64% 0.222 41.116); + --color-warning-content: oklch(98% 0.016 73.684); + --color-error: oklch(58% 0.253 17.585); + --color-error-content: oklch(96% 0.015 12.422); + --radius-selector: 0.5rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 0; +} + +/* Custom dark theme */ +[data-theme="lumio-dark"], +:root:has(input.theme-controller[value="lumio-dark"]:checked) { + color-scheme: dark; + --color-base-100: oklch(16% 0.01 260); + --color-base-200: oklch(20% 0.012 260); + --color-base-300: oklch(24% 0.014 260); + --color-base-content: oklch(94% 0 0); + --color-primary: oklch(78% 0.21 128); + --color-primary-content: oklch(18% 0.05 130); + --color-secondary: oklch(28% 0.01 260); + --color-secondary-content: oklch(92% 0 0); + --color-accent: oklch(32% 0.02 60); + --color-accent-content: oklch(95% 0 0); + --color-neutral: oklch(12% 0.01 260); + --color-neutral-content: oklch(96% 0 0); + --color-info: oklch(70% 0.12 220); + --color-info-content: oklch(15% 0.02 220); + --color-success: oklch(72% 0.18 150); + --color-success-content: oklch(15% 0.03 150); + --color-warning: oklch(76% 0.18 70); + --color-warning-content: oklch(18% 0.03 70); + --color-error: oklch(70% 0.22 20); + --color-error-content: oklch(15% 0.03 20); + --radius-selector: 0.5rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 0; +} + +/* Respect OS dark mode preference when no theme is explicitly set */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + color-scheme: dark; + --color-base-100: oklch(16% 0.01 260); + --color-base-200: oklch(20% 0.012 260); + --color-base-300: oklch(24% 0.014 260); + --color-base-content: oklch(94% 0 0); + --color-primary: oklch(78% 0.21 128); + --color-primary-content: oklch(18% 0.05 130); + --color-secondary: oklch(28% 0.01 260); + --color-secondary-content: oklch(92% 0 0); + --color-accent: oklch(32% 0.02 60); + --color-accent-content: oklch(95% 0 0); + --color-neutral: oklch(12% 0.01 260); + --color-neutral-content: oklch(96% 0 0); + --color-info: oklch(70% 0.12 220); + --color-info-content: oklch(15% 0.02 220); + --color-success: oklch(72% 0.18 150); + --color-success-content: oklch(15% 0.03 150); + --color-warning: oklch(76% 0.18 70); + --color-warning-content: oklch(18% 0.03 70); + --color-error: oklch(70% 0.22 20); + --color-error-content: oklch(15% 0.03 20); + } +} diff --git a/web-client/src/main.tsx b/web-client/src/main.tsx index c61f210..ff9f2a2 100644 --- a/web-client/src/main.tsx +++ b/web-client/src/main.tsx @@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import '@/index.css' import App from './App.tsx' +import { ThemeProvider } from './app/theme/ThemeProvider' createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/web-client/tailwind.config.js b/web-client/tailwind.config.js new file mode 100644 index 0000000..c8f54a2 --- /dev/null +++ b/web-client/tailwind.config.js @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], +}; diff --git a/web-client/vite.config.ts b/web-client/vite.config.ts index 472fcae..4166105 100644 --- a/web-client/vite.config.ts +++ b/web-client/vite.config.ts @@ -14,6 +14,12 @@ export default defineConfig({ server: { port: 3000, open: true, + proxy: { + '/api': { + target: 'http://localhost', + changeOrigin: true, + }, + }, }, test: { environment: 'jsdom',