diff --git a/docker-compose.yml b/docker-compose.yml index 6eeafb3..37e6add 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,7 +58,7 @@ services: build: context: ./frontend ports: - - "8080:80" + - "80:80" depends_on: backend: condition: service_healthy diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 835a0e8..f84844d 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -3,12 +3,19 @@ import { Navigate, Outlet, createBrowserRouter } from 'react-router-dom' import { AppShell } from '../components/layout/app-shell' import { useAuth } from '../features/auth/auth-store' import { AdminPromptsPage } from '../pages/admin/admin-prompts-page' +import { LandingPage } from '../pages/landing/landing-page' import { LoginPage } from '../pages/login/login-page' +import { NotFoundPage } from '../pages/not-found/not-found-page' import { PromptsPage } from '../pages/prompts/prompts-page' import { RecoveryPage } from '../pages/recovery/recovery-page' +import { RegisterPage } from '../pages/register/register-page' function ProtectedLayout() { - const { isAuthenticated } = useAuth() + const { isAuthenticated, isReady } = useAuth() + if (!isReady) { + return

Restoring session...

+ } + if (!isAuthenticated) { return } @@ -21,7 +28,11 @@ function ProtectedLayout() { } function PublicLoginPage() { - const { isAuthenticated } = useAuth() + const { isAuthenticated, isReady } = useAuth() + if (!isReady) { + return

Loading...

+ } + if (isAuthenticated) { return } @@ -38,10 +49,18 @@ function RoleGuard() { } export const router = createBrowserRouter([ + { + path: '/', + element: , + }, { path: '/login', element: , }, + { + path: '/register', + element: , + }, { path: '/recovery', element: , @@ -72,6 +91,6 @@ export const router = createBrowserRouter([ }, { path: '*', - element: , + element: , }, ]) diff --git a/frontend/src/components/layout/app-shell.tsx b/frontend/src/components/layout/app-shell.tsx index b43c20f..8ba721c 100644 --- a/frontend/src/components/layout/app-shell.tsx +++ b/frontend/src/components/layout/app-shell.tsx @@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom' import { useAuth } from '../../features/auth/auth-store' import { applyTheme, getInitialTheme, type ThemeMode } from '../../lib/utils/theme' import { Button } from '../ui/button' +import { Badge } from '../ui/badge' type AppShellProps = { children: ReactNode @@ -24,20 +25,21 @@ export function AppShell({ children }: AppShellProps) { return (
-
- Prompt Manager +
+
+ Prompt Catalog +

Personal library for prompts that work

+
- Prompts + Catalog {session.role === 'admin' || session.role === 'god' ? ( - Admin Monitor + Admin ) : null} - - {session.username} · {session.role} - + {session.username} · {session.role} @@ -46,7 +48,7 @@ export function AppShell({ children }: AppShellProps) {
-
{children}
+
{children}
) } diff --git a/frontend/src/components/prompts/prompt-list.tsx b/frontend/src/components/prompts/prompt-list.tsx index 5dc83b8..dfc7a4a 100644 --- a/frontend/src/components/prompts/prompt-list.tsx +++ b/frontend/src/components/prompts/prompt-list.tsx @@ -1,5 +1,6 @@ import type { PromptRecord } from '../../features/prompts/prompts-types' import { Button } from '../ui/button' +import { EmptyState } from '../ui/empty-state' type PromptListProps = { prompts: PromptRecord[] @@ -9,7 +10,12 @@ type PromptListProps = { export function PromptList({ prompts, onEdit, onDelete }: PromptListProps) { if (!prompts.length) { - return

No prompts yet.

+ return ( + + ) } return ( diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..5612e35 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,9 @@ +import type { HTMLAttributes } from 'react' + +type BadgeProps = HTMLAttributes & { + tone?: 'default' | 'accent' | 'danger' +} + +export function Badge({ tone = 'default', className = '', ...props }: BadgeProps) { + return +} diff --git a/frontend/src/components/ui/confirm-dialog.tsx b/frontend/src/components/ui/confirm-dialog.tsx new file mode 100644 index 0000000..f6419fe --- /dev/null +++ b/frontend/src/components/ui/confirm-dialog.tsx @@ -0,0 +1,48 @@ +import { Button } from './button' + +type ConfirmDialogProps = { + open: boolean + title: string + description: string + confirmLabel?: string + cancelLabel?: string + busy?: boolean + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmDialog({ + open, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + busy = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + if (!open) return null + + return ( +
+
event.stopPropagation()} + > +

{title}

+

{description}

+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/ui/empty-state.tsx b/frontend/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..a2d3ec0 --- /dev/null +++ b/frontend/src/components/ui/empty-state.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' + +type EmptyStateProps = { + title: string + description: string + action?: ReactNode +} + +export function EmptyState({ title, description, action }: EmptyStateProps) { + return ( +
+

{title}

+

{description}

+ {action ?
{action}
: null} +
+ ) +} diff --git a/frontend/src/components/ui/page-header.tsx b/frontend/src/components/ui/page-header.tsx new file mode 100644 index 0000000..65c26fc --- /dev/null +++ b/frontend/src/components/ui/page-header.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' + +type PageHeaderProps = { + eyebrow?: string + title: string + description?: string + actions?: ReactNode +} + +export function PageHeader({ eyebrow, title, description, actions }: PageHeaderProps) { + return ( +
+
+ {eyebrow ?

{eyebrow}

: null} +

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ) +} diff --git a/frontend/src/features/auth/auth-service.ts b/frontend/src/features/auth/auth-service.ts index da8f98b..48bfa35 100644 --- a/frontend/src/features/auth/auth-service.ts +++ b/frontend/src/features/auth/auth-service.ts @@ -2,10 +2,12 @@ import { apiRequest } from '../../lib/http/api-client' import type { LoginInput, LoginResponse, + RegisterInput, RecoveryGenerateInput, RecoveryGenerateResponse, RecoveryRedeemInput, RecoveryRedeemResponse, + SignupResponse, UserRecord, UserRole, } from './auth-types' @@ -35,6 +37,13 @@ export async function loginAndResolveUserId( } } +export function signup(input: RegisterInput): Promise { + return apiRequest('/auth/signup', { + method: 'POST', + body: JSON.stringify(input), + }) +} + export function getCurrentUser(token: string): Promise { return apiRequest('/auth/profile', { method: 'GET', diff --git a/frontend/src/features/auth/auth-store.tsx b/frontend/src/features/auth/auth-store.tsx index 174073c..d7aa92e 100644 --- a/frontend/src/features/auth/auth-store.tsx +++ b/frontend/src/features/auth/auth-store.tsx @@ -3,11 +3,12 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, useState, type ReactNode, } from 'react' -import { loginAndResolveUserId } from './auth-service' +import { getCurrentUser, loginAndResolveUserId } from './auth-service' import type { UserRole } from './auth-types' type SessionState = { @@ -20,11 +21,13 @@ type SessionState = { type AuthContextValue = { session: SessionState isAuthenticated: boolean + isReady: boolean login: (username: string, password: string) => Promise logout: () => void } const AuthContext = createContext(null) +const SESSION_TOKEN_KEY = 'prompt-catalog-token' type AuthProviderProps = { children: ReactNode @@ -37,9 +40,41 @@ export function AuthProvider({ children }: AuthProviderProps) { userId: null, role: null, }) + const [isReady, setIsReady] = useState(false) + + useEffect(() => { + const token = sessionStorage.getItem(SESSION_TOKEN_KEY) + if (!token) { + setIsReady(true) + return + } + + let cancelled = false + getCurrentUser(token) + .then((user) => { + if (cancelled) return + setSession({ + token, + username: user.username, + userId: user.id, + role: user.role, + }) + }) + .catch(() => { + sessionStorage.removeItem(SESSION_TOKEN_KEY) + }) + .finally(() => { + if (!cancelled) setIsReady(true) + }) + + return () => { + cancelled = true + } + }, []) const login = useCallback(async (username: string, password: string) => { const resolved = await loginAndResolveUserId({ username, password }) + sessionStorage.setItem(SESSION_TOKEN_KEY, resolved.token) setSession({ token: resolved.token, username: resolved.username, @@ -49,6 +84,7 @@ export function AuthProvider({ children }: AuthProviderProps) { }, []) const logout = useCallback(() => { + sessionStorage.removeItem(SESSION_TOKEN_KEY) setSession({ token: null, username: null, userId: null, role: null }) }, []) @@ -56,10 +92,11 @@ export function AuthProvider({ children }: AuthProviderProps) { () => ({ session, isAuthenticated: Boolean(session.token), + isReady, login, logout, }), - [login, logout, session], + [isReady, login, logout, session], ) return {children} diff --git a/frontend/src/features/auth/auth-types.ts b/frontend/src/features/auth/auth-types.ts index df96067..b0c77a7 100644 --- a/frontend/src/features/auth/auth-types.ts +++ b/frontend/src/features/auth/auth-types.ts @@ -8,6 +8,15 @@ export type LoginResponse = { token_type: string } +export type RegisterInput = { + username: string + password: string + name: string + last_name: string + email: string + role: 'user' +} + export type UserRole = 'user' | 'admin' | 'god' export type UserRecord = { @@ -19,6 +28,8 @@ export type UserRecord = { role: UserRole } +export type SignupResponse = UserRecord + export type RecoveryGenerateInput = { username: string } diff --git a/frontend/src/lib/validation/auth-schemas.ts b/frontend/src/lib/validation/auth-schemas.ts index f2b6398..4d4ddb5 100644 --- a/frontend/src/lib/validation/auth-schemas.ts +++ b/frontend/src/lib/validation/auth-schemas.ts @@ -5,6 +5,14 @@ export const loginSchema = z.object({ password: z.string().min(1, 'Password is required'), }) +export const registerSchema = z.object({ + name: z.string().min(1, 'First name is required'), + last_name: z.string().min(1, 'Last name is required'), + email: z.string().email('Enter a valid email'), + username: z.string().min(3, 'Username must be at least 3 characters'), + password: z.string().min(6, 'Password must be at least 6 characters'), +}) + export const recoveryRequestSchema = z.object({ username: z.string().min(1, 'Username is required'), }) @@ -14,5 +22,6 @@ export const recoveryRedeemSchema = z.object({ }) export type LoginFormValues = z.infer +export type RegisterFormValues = z.infer export type RecoveryRequestFormValues = z.infer export type RecoveryRedeemFormValues = z.infer diff --git a/frontend/src/pages/landing/landing-page.tsx b/frontend/src/pages/landing/landing-page.tsx new file mode 100644 index 0000000..bc598e7 --- /dev/null +++ b/frontend/src/pages/landing/landing-page.tsx @@ -0,0 +1,61 @@ +import { Link } from 'react-router-dom' +import { Badge } from '../../components/ui/badge' +import { Card } from '../../components/ui/card' + +const features = [ + 'Search prompts by content, model, category, and rating', + 'Reuse proven prompts with copy, edit, duplicate, and delete actions', + 'Admin-ready RBAC with prompt monitoring and future analytics', +] + +export function LandingPage() { + return ( +
+ +
+
+ Portfolio-ready full-stack product +

Build your personal catalog of prompts that actually work.

+

+ Save, classify, rate, and reuse AI prompts with a focused catalog experience backed by FastAPI, JWT auth, MariaDB, Redis, and React Query. +

+
+ Create account + Sign in +
+
+ +
+ GPT-4 + Rating 5/5 +
+

Reusable Architecture Reviewer

+

+ Review this API endpoint for authorization gaps, unsafe schemas, and missing regression tests. Return findings by severity. +

+

Category: code-review · Owner: you

+
+
+
+ {features.map((feature) => ( + +

{feature}

+

Designed to turn a basic CRUD into a product recruiters and users can understand quickly.

+
+ ))} +
+
+ React + TypeScript + Vite + FastAPI JWT APIs + MariaDB + Redis + Local API docs +
+
+ ) +} diff --git a/frontend/src/pages/login/login-page.tsx b/frontend/src/pages/login/login-page.tsx index ed93589..190bbf2 100644 --- a/frontend/src/pages/login/login-page.tsx +++ b/frontend/src/pages/login/login-page.tsx @@ -1,5 +1,5 @@ import { useState, type FormEvent } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Card } from '../../components/ui/card' import { InlineError } from '../../components/ui/inline-error' import { Button } from '../../components/ui/button' @@ -9,6 +9,7 @@ import { loginSchema } from '../../lib/validation/auth-schemas' export function LoginPage() { const navigate = useNavigate() + const [searchParams] = useSearchParams() const { login } = useAuth() const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -37,11 +38,14 @@ export function LoginPage() { } return ( -
+

Login

Use your API account credentials.

+ {searchParams.get('registered') ? ( +
Account created. Sign in with your new credentials.
+ ) : null} Forgot password? + + Need an account? Create one +
diff --git a/frontend/src/pages/not-found/not-found-page.tsx b/frontend/src/pages/not-found/not-found-page.tsx new file mode 100644 index 0000000..6dbca98 --- /dev/null +++ b/frontend/src/pages/not-found/not-found-page.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router-dom' +import { Card } from '../../components/ui/card' + +export function NotFoundPage() { + return ( +
+ +

404

+

Page not found

+

This route does not exist in Prompt Catalog.

+ Back to landing +
+
+ ) +} diff --git a/frontend/src/pages/prompts/prompts-page.tsx b/frontend/src/pages/prompts/prompts-page.tsx index 716329d..853521f 100644 --- a/frontend/src/pages/prompts/prompts-page.tsx +++ b/frontend/src/pages/prompts/prompts-page.tsx @@ -1,7 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { Card } from '../../components/ui/card' +import { ConfirmDialog } from '../../components/ui/confirm-dialog' import { InlineError } from '../../components/ui/inline-error' +import { PageHeader } from '../../components/ui/page-header' import { PromptForm } from '../../components/prompts/prompt-form' import { PromptList } from '../../components/prompts/prompt-list' import { useAuth } from '../../features/auth/auth-store' @@ -24,6 +26,7 @@ export function PromptsPage() { const { session } = useAuth() const queryClient = useQueryClient() const [editingPrompt, setEditingPrompt] = useState(null) + const [promptToDelete, setPromptToDelete] = useState(null) const [error, setError] = useState(null) const token = session.token @@ -120,16 +123,20 @@ export function PromptsPage() { await createMutation.mutateAsync(value) } - const handleDelete = (prompt: PromptRecord) => { - const confirmed = window.confirm('Delete this prompt?') - if (!confirmed) { - return - } - deleteMutation.mutate(prompt) + const handleDelete = () => { + if (!promptToDelete) return + deleteMutation.mutate(promptToDelete, { + onSuccess: () => setPromptToDelete(null), + }) } return (
+

{editingPrompt ? 'Edit prompt' : 'Create prompt'}

) : null}
+ setPromptToDelete(null)} + onConfirm={handleDelete} + />
) } diff --git a/frontend/src/pages/register/register-page.tsx b/frontend/src/pages/register/register-page.tsx new file mode 100644 index 0000000..aff1e45 --- /dev/null +++ b/frontend/src/pages/register/register-page.tsx @@ -0,0 +1,59 @@ +import { useState, type FormEvent } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { Button } from '../../components/ui/button' +import { Card } from '../../components/ui/card' +import { InlineError } from '../../components/ui/inline-error' +import { Input } from '../../components/ui/input' +import { signup } from '../../features/auth/auth-service' +import { registerSchema } from '../../lib/validation/auth-schemas' + +export function RegisterPage() { + const navigate = useNavigate() + const [form, setForm] = useState({ name: '', last_name: '', email: '', username: '', password: '' }) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const updateField = (field: keyof typeof form, value: string) => { + setForm((current) => ({ ...current, [field]: value })) + } + + const onSubmit = async (event: FormEvent) => { + event.preventDefault() + const parsed = registerSchema.safeParse(form) + if (!parsed.success) { + setError(parsed.error.issues[0]?.message ?? 'Check your registration details') + return + } + + try { + setLoading(true) + setError(null) + await signup({ ...parsed.data, role: 'user' }) + navigate('/login?registered=1', { replace: true }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to create account' + setError(message.includes('400') ? 'Username or email is already registered' : message) + } finally { + setLoading(false) + } + } + + return ( +
+ +

Create account

+

Start building a catalog of prompts you can trust and reuse.

+
+ updateField('name', event.target.value)} autoComplete="given-name" /> + updateField('last_name', event.target.value)} autoComplete="family-name" /> + updateField('email', event.target.value)} autoComplete="email" /> + updateField('username', event.target.value)} autoComplete="username" /> + updateField('password', event.target.value)} autoComplete="new-password" /> + {error ? : null} + + Already have an account? + +
+
+ ) +} diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 2ce5e6d..df057d8 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -12,7 +12,9 @@ body { margin: 0; font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; font-size: var(--font-md); - background: var(--bg); + background: + radial-gradient(circle at top left, color-mix(in srgb, var(--accent) 16%, transparent), transparent 30rem), + var(--bg); color: var(--text); } @@ -28,17 +30,25 @@ h1 { margin-bottom: var(--space-2); } +a { + color: inherit; +} + h2 { font-size: 1.125rem; margin-bottom: var(--space-3); } .page { - max-width: 960px; + max-width: 1120px; margin: 0 auto; padding: var(--space-6) var(--space-4); } +.app-main { + padding-bottom: var(--space-8); +} + .centered-page { min-height: 100vh; display: grid; @@ -46,6 +56,10 @@ h2 { padding: var(--space-4); } +.auth-gradient { + background: radial-gradient(circle at 20% 10%, color-mix(in srgb, var(--accent-strong) 20%, transparent), transparent 28rem); +} + .topbar { display: flex; justify-content: space-between; @@ -54,6 +68,18 @@ h2 { margin-bottom: var(--space-6); } +.app-topbar { + position: sticky; + top: var(--space-4); + z-index: 10; + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--surface) 90%, transparent); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); +} + .topbar-actions { display: flex; align-items: center; @@ -68,16 +94,46 @@ h2 { text-decoration: none; } +.nav-link { + padding: 0.45rem 0.7rem; + border-radius: 999px; +} + .nav-link.active, .text-link:hover { color: var(--accent); } +.nav-link.active { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + border: 1px solid var(--accent); + padding: 0.58rem 0.95rem; + background: var(--accent); + color: #fff; + font-size: var(--font-sm); + font-weight: 700; + text-decoration: none; +} + +.button-link-secondary { + background: transparent; + color: var(--text); + border-color: var(--border); +} + .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--space-4); + box-shadow: 0 1px 0 rgb(15 23 42 / 0.03); } .auth-card { @@ -94,6 +150,10 @@ h2 { align-items: center; } +.wrap { + flex-wrap: wrap; +} + .gap-sm { gap: var(--space-2); } @@ -233,6 +293,155 @@ h2 { font-size: var(--font-sm); } +.badge-accent { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.badge-danger { + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, transparent); +} + +.success-panel { + border: 1px solid color-mix(in srgb, var(--success) 30%, var(--border)); + border-radius: var(--radius-sm); + padding: var(--space-3); + color: var(--success); + background: color-mix(in srgb, var(--success) 10%, transparent); + font-size: var(--font-sm); +} + +.eyebrow { + color: var(--accent); + font-size: var(--font-sm); + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: var(--space-4); +} + +.page-header-actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.empty-state { + display: grid; + gap: var(--space-2); + place-items: center; + border: 1px dashed var(--border); + border-radius: var(--radius-md); + padding: var(--space-8); + text-align: center; + background: color-mix(in srgb, var(--accent) 6%, transparent); +} + +.dialog-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: var(--space-4); + background: rgb(2 6 23 / 0.55); +} + +.dialog-panel { + width: min(440px, 100%); + display: grid; + gap: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-6); + background: var(--surface); + box-shadow: var(--shadow); +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + flex-wrap: wrap; +} + +.marketing-page { + width: min(1180px, calc(100% - 2rem)); + margin: 0 auto; + padding: var(--space-6) 0 var(--space-12); +} + +.marketing-nav, +.architecture-strip { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} + +.marketing-nav { + margin-bottom: var(--space-12); +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(300px, 0.72fr); + gap: var(--space-8); + align-items: center; +} + +.hero-title { + max-width: 12ch; + font-size: var(--font-xl); + line-height: 0.95; + letter-spacing: -0.06em; +} + +.hero-copy { + max-width: 62ch; + color: var(--muted); + font-size: 1.08rem; + line-height: 1.75; +} + +.showcase-card { + display: grid; + gap: var(--space-4); + min-height: 320px; + align-content: center; + border-radius: var(--radius-lg); + background: + linear-gradient(145deg, color-mix(in srgb, var(--accent) 14%, transparent), transparent), + var(--surface); + box-shadow: var(--shadow); +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-4); + margin: var(--space-12) 0; +} + +.architecture-strip { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-4); + background: var(--surface-strong); + color: var(--muted); + font-size: var(--font-sm); +} + .result-panel { display: grid; gap: var(--space-2); @@ -257,6 +466,24 @@ h2 { align-items: flex-start; } + .app-topbar { + position: static; + } + + .page-header { + align-items: flex-start; + flex-direction: column; + } + + .hero-grid, + .feature-grid { + grid-template-columns: 1fr; + } + + .marketing-nav { + margin-bottom: var(--space-8); + } + .filter-grid { grid-template-columns: 1fr; } diff --git a/frontend/src/styles/themes.css b/frontend/src/styles/themes.css index 8066943..b1ad375 100644 --- a/frontend/src/styles/themes.css +++ b/frontend/src/styles/themes.css @@ -2,19 +2,27 @@ :root[data-theme='light'] { --bg: #f4f6f8; --surface: #ffffff; + --surface-strong: #eef2ff; --text: #1e293b; --muted: #64748b; --border: #dbe3ea; --accent: #2563eb; + --accent-strong: #6d28d9; --danger: #dc2626; + --success: #047857; + --shadow: 0 24px 70px rgb(30 41 59 / 0.12); } :root[data-theme='dark'] { --bg: #111827; --surface: #1f2937; + --surface-strong: #172033; --text: #e5e7eb; --muted: #94a3b8; --border: #334155; --accent: #60a5fa; + --accent-strong: #a78bfa; --danger: #f87171; -} \ No newline at end of file + --success: #34d399; + --shadow: 0 24px 70px rgb(0 0 0 / 0.28); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 20c93d9..bbb7f02 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -5,13 +5,16 @@ --space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem; + --space-12: 3rem; --radius-sm: 0.375rem; --radius-md: 0.625rem; + --radius-lg: 1rem; --font-sm: 0.875rem; --font-md: 1rem; --font-lg: 1.25rem; + --font-xl: clamp(2.2rem, 7vw, 4.75rem); --transition-fast: 120ms ease; -} \ No newline at end of file +} diff --git a/scripts/run-compose-stack.sh b/scripts/run-compose-stack.sh index 97c33b2..cf1f1ec 100755 --- a/scripts/run-compose-stack.sh +++ b/scripts/run-compose-stack.sh @@ -313,7 +313,7 @@ wait_for_http() { } verify_api_proxy() { - local url="http://127.0.0.1:8080/api/v1/auth/login" + local url="http://127.0.0.1:80/api/v1/auth/login" local status log "Checking nginx API proxy: ${url}" @@ -365,8 +365,8 @@ configure_ports() { fail "Host port 8000 is already used. docker-compose.yml hardcodes backend port 8000, so stop the conflicting container before clean startup." fi - if docker_published_port_in_use 8080; then - fail "Host port 8080 is already used. docker-compose.yml hardcodes frontend port 8080, so stop the conflicting container before clean startup." + if docker_published_port_in_use 80; then + fail "Host port 80 is already used. docker-compose.yml hardcodes frontend port 80, so stop the conflicting container before clean startup." fi return @@ -410,7 +410,7 @@ start_stack() { fi printf 'Clean project teardown: %s down --volumes --remove-orphans\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" >&2 else - printf '\nIf port 3306, 6379, 8000, or 8080 is already used by an old webapi container, stop it first.\n' >&2 + printf '\nIf port 3306, 6379, 8000, or 80 is already used by an old webapi container, stop it first.\n' >&2 printf 'Manual backend workflow cleanup example: docker rm -f webapi-mariadb webapi-redis webapi-backend\n' >&2 fi if [[ -n "${BUILD_FLAG}" ]]; then @@ -442,7 +442,7 @@ start_stack wait_for_health mariadb "${TIMEOUT_SECONDS}" wait_for_health redis "${TIMEOUT_SECONDS}" wait_for_health backend "${TIMEOUT_SECONDS}" -wait_for_http "frontend" "http://127.0.0.1:8080/" "${TIMEOUT_SECONDS}" +wait_for_http "frontend" "http://127.0.0.1:80/" "${TIMEOUT_SECONDS}" wait_for_http "backend direct endpoint" "http://127.0.0.1:8000/" "${TIMEOUT_SECONDS}" verify_api_proxy verify_database @@ -450,9 +450,9 @@ verify_redis log "Stack is ready." printf '\nURLs:\n' -printf ' Frontend: http://127.0.0.1:8080\n' +printf ' Frontend: http://127.0.0.1:80\n' printf ' Backend direct: http://127.0.0.1:8000/docs\n' -printf ' Backend through nginx: http://127.0.0.1:8080/api/v1/...\n' +printf ' Backend through nginx: http://127.0.0.1:80/api/v1/...\n' printf ' MariaDB host port: 127.0.0.1:%s\n' "${MARIADB_HOST_PORT:-3306}" printf ' Redis host port: 127.0.0.1:%s\n' "${REDIS_HOST_PORT:-6379}" printf '\nUseful commands:\n'