diff --git a/7project/frontend/src/appearance.ts b/7project/frontend/src/appearance.ts new file mode 100644 index 0000000..2b14b99 --- /dev/null +++ b/7project/frontend/src/appearance.ts @@ -0,0 +1,39 @@ +export type Theme = 'system' | 'light' | 'dark'; +export type FontSize = 'small' | 'medium' | 'large'; + +const THEME_KEY = 'app_theme'; +const FONT_KEY = 'app_font_size'; + +export function applyTheme(theme: Theme) { + const root = document.documentElement; + const body = document.body; + const effective = theme === 'system' ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme; + body.setAttribute('data-theme', effective); +} + +export function applyFontSize(size: FontSize) { + const root = document.documentElement; + const map: Record = { + small: '14px', + medium: '16px', + large: '18px', + }; + root.style.fontSize = map[size]; +} + +export function saveAppearance(theme: Theme, size: FontSize) { + localStorage.setItem(THEME_KEY, theme); + localStorage.setItem(FONT_KEY, size); +} + +export function loadAppearance(): { theme: Theme; size: FontSize } { + const theme = (localStorage.getItem(THEME_KEY) as Theme) || 'light'; + const size = (localStorage.getItem(FONT_KEY) as FontSize) || 'medium'; + return { theme, size }; +} + +export function applyAppearanceFromStorage() { + const { theme, size } = loadAppearance(); + applyTheme(theme); + applyFontSize(size); +} diff --git a/7project/frontend/src/pages/AccountPage.tsx b/7project/frontend/src/pages/AccountPage.tsx new file mode 100644 index 0000000..c31804f --- /dev/null +++ b/7project/frontend/src/pages/AccountPage.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { deleteMe, getMe, type UpdateMeInput, type User, updateMe } from '../api'; + +export default function AccountPage({ onDeleted }: { onDeleted: () => void }) { + const [user, setUser] = useState(null); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + const u = await getMe(); + setUser(u); + setFirstName(u.first_name || ''); + setLastName(u.last_name || ''); + } catch (e: any) { + setError(e?.message || 'Failed to load account'); + } finally { + setLoading(false); + } + })(); + }, []); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const payload: UpdateMeInput = { first_name: firstName || null as any, last_name: lastName || null as any }; + const updated = await updateMe(payload); + setUser(updated); + } catch (e: any) { + setError(e?.message || 'Failed to update'); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!confirm('Are you sure you want to delete your account? This cannot be undone.')) return; + try { + await deleteMe(); + onDeleted(); + } catch (e: any) { + alert(e?.message || 'Failed to delete account'); + } + } + + return ( +
+

Account

+ {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : !user ? ( +
Not signed in
+ ) : ( +
+
Email: {user.email}
+
+
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
+
+
+ +
+
+
+
+ +
+
+ )} +
+ ); +} diff --git a/7project/frontend/src/pages/AppearancePage.tsx b/7project/frontend/src/pages/AppearancePage.tsx new file mode 100644 index 0000000..3aeaa1f --- /dev/null +++ b/7project/frontend/src/pages/AppearancePage.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { applyFontSize, applyTheme, loadAppearance, saveAppearance, type FontSize, type Theme } from '../appearance'; + +export default function AppearancePage() { + const [theme, setTheme] = useState('light'); + const [size, setSize] = useState('medium'); + + useEffect(() => { + const { theme, size } = loadAppearance(); + setTheme(theme); + setSize(size); + }, []); + + function onThemeChange(next: Theme) { + setTheme(next); + applyTheme(next); + saveAppearance(next, size); + } + + function onSizeChange(next: FontSize) { + setSize(next); + applyFontSize(next); + saveAppearance(theme, next); + } + + return ( +
+

Appearance

+
+
+
Theme
+
+ + + +
+
+
+
Font size
+
+ + + +
+
+
+
+ ); +} diff --git a/7project/frontend/src/ui.css b/7project/frontend/src/ui.css new file mode 100644 index 0000000..ec6092f --- /dev/null +++ b/7project/frontend/src/ui.css @@ -0,0 +1,85 @@ +:root { + --bg: #f7f7fb; + --panel: #ffffff; + --text: #9aa3b2; + --muted: #6b7280; + --primary: #6f49fe; + --primary-600: #5a37fb; + --border: #e5e7eb; + --radius: 12px; + --shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.08); + + font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + color: var(--text); +} + +* { box-sizing: border-box; } + +html, body, #root { height: 100%; } + +body { background: var(--bg); margin: 0; display: block; } + +/* Dark theme variables */ +body[data-theme="dark"] { + --bg: #161a2b; + --panel: #283046; + --text: #283046; + --muted: #cbd5e1; + --primary: #8b7bff; + --primary-600: #7b69ff; + --border: #283046; +} + +/* Layout */ +.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100%; } +.sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; } +.sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; } +.nav { margin-top: 12px; display: grid; gap: 4px; } +.nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; } +.nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; } + +.content { display: flex; flex-direction: column; height: 100%; } +.topbar { height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); } +.topbar .user { color: var(--muted); } +.page { padding: 24px; max-width: 1100px; margin: auto; } + +/* Cards */ +.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; } +.card h3 { margin: 0 0 12px; } + +/* Forms */ +.input, select, textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: #fff; color: var(--text); } +.input:focus, select:focus, textarea:focus { outline: 2px solid var(--primary); border-color: var(--primary); } +.form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); } +.form-row > * { min-width: 140px; } +.actions { display: flex; align-items: center; gap: 8px; } + +/* Buttons */ +.btn { border: 1px solid var(--border); background: #fff; color: var(--text); padding: 10px 14px; border-radius: 10px; cursor: pointer; } +.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; } +.btn.primary:hover { background: var(--primary-600); } +.btn.ghost { background: transparent; color: var(--muted); } + +/* Tables */ +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { padding: 10px; border-bottom: 1px solid var(--border); } +.table th { text-align: left; color: var(--muted); font-weight: 600; } +.table td.amount { text-align: right; font-variant-numeric: tabular-nums; } + +/* Segmented control */ +.segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); } +.segmented button { border: 0; background: transparent; padding: 8px 12px; border-radius: 8px; color: var(--muted); cursor: pointer; } +.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow); } + +/* Auth layout */ +body.auth-page #root { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + width: 100%; +} + +/* Utility */ +.muted { color: var(--muted); } +.space-y > * + * { margin-top: 12px; }