chore: remove legacy workspace and restructure API + modular admin UI

This commit is contained in:
mberlin
2026-03-19 09:35:10 -03:00
parent 996c6e9241
commit b60fb432ff
94 changed files with 532 additions and 4086 deletions

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"

View File

@@ -1,6 +1,6 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { Sidebar } from "./components/Sidebar";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { PageShell } from "./ui/PageShell";
import { LoginPage } from "./pages/Login";
import { DashboardPage } from "./pages/Dashboard";
import { GuestsPage } from "./pages/Guests";
@@ -11,49 +11,55 @@ import "./index.css";
function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div className="min-h-screen flex">
<Sidebar />
<main className="flex-1">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/guests"
element={
<ProtectedRoute>
<GuestsPage />
</ProtectedRoute>
}
/>
<Route
path="/gifts"
element={
<ProtectedRoute>
<GiftsPage />
</ProtectedRoute>
}
/>
<Route
path="/todos"
element={
<ProtectedRoute>
<TodosPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
</div>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<PageShell title="Dashboard">
<DashboardPage />
</PageShell>
</ProtectedRoute>
}
/>
<Route
path="/guests"
element={
<ProtectedRoute>
<PageShell title="Guests">
<GuestsPage />
</PageShell>
</ProtectedRoute>
}
/>
<Route
path="/gifts"
element={
<ProtectedRoute>
<PageShell title="Gifts">
<GiftsPage />
</PageShell>
</ProtectedRoute>
}
/>
<Route
path="/todos"
element={
<ProtectedRoute>
<PageShell title="Todos">
<TodosPage />
</PageShell>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}

View File

@@ -1,28 +1,41 @@
import { Home, Gift, ListChecks, Users } from "lucide-react";
import { NavLink } from "react-router-dom";
const linkClasses = ({ isActive }: { isActive: boolean }) =>
`block px-4 py-2 rounded-lg transition hover:bg-slate-200 dark:hover:bg-slate-800 ${
isActive ? "bg-slate-200 dark:bg-slate-800 font-semibold" : ""
`flex items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition hover:bg-slate-100 dark:hover:bg-slate-900 ${
isActive ? "bg-slate-100 dark:bg-slate-900 text-slate-900 dark:text-white" : "text-slate-600 dark:text-slate-300"
}`;
export function Sidebar() {
return (
<nav className="w-64 p-4 border-r border-slate-200 dark:border-slate-800">
<h2 className="text-xl font-bold mb-4">Planner Admin</h2>
<div className="space-y-2">
<nav className="flex h-screen w-64 flex-col border-r border-slate-200 bg-white py-6 dark:border-slate-800 dark:bg-slate-950">
<div className="px-6">
<h2 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white">Planner</h2>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">Admin console</p>
</div>
<div className="mt-6 flex-1 space-y-1 px-2">
<NavLink to="/" className={linkClasses} end>
Dashboard
<Home className="h-4 w-4" />
<span>Dashboard</span>
</NavLink>
<NavLink to="/guests" className={linkClasses}>
Guests
<Users className="h-4 w-4" />
<span>Guests</span>
</NavLink>
<NavLink to="/gifts" className={linkClasses}>
Gifts
<Gift className="h-4 w-4" />
<span>Gifts</span>
</NavLink>
<NavLink to="/todos" className={linkClasses}>
Todos
<ListChecks className="h-4 w-4" />
<span>Todos</span>
</NavLink>
</div>
<div className="border-t border-slate-200 px-6 pt-4 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
<p>Tip: Use the sidebar to navigate between sections.</p>
</div>
</nav>
);
}

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
const STORAGE_KEY = "planner_admin_dark";
export function useDarkMode() {
const [dark, setDark] = useState(() => {
if (typeof window === "undefined") return false;
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored !== null) return stored === "true";
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
});
useEffect(() => {
const root = document.documentElement;
if (dark) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
window.localStorage.setItem(STORAGE_KEY, dark ? "true" : "false");
}, [dark]);
function toggle() {
setDark((prev) => !prev);
}
return { dark, toggle };
}

View File

@@ -55,11 +55,11 @@
}
#root {
width: 1126px;
width: 100%;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
margin: 0;
padding: 0;
text-align: left;
min-height: 100svh;
display: flex;
flex-direction: column;

View File

@@ -12,6 +12,11 @@ export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function logout() {
clearToken();
window.location.href = "/login";
}
export function isLoggedIn() {
return Boolean(getToken());
}

View File

@@ -0,0 +1,12 @@
import { fetchJson } from "../../lib/api";
export type AuthTokenResponse = {
token: string;
};
export async function login(email: string, password: string) {
return fetchJson<AuthTokenResponse>("/api/organizador/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}

View File

@@ -0,0 +1,19 @@
import { fetchJson } from "../../lib/api";
export type Gift = {
id: string;
name: string;
status?: string;
ownerId?: string;
};
export async function listGifts() {
return fetchJson<Gift[]>("/api/organizador/gift");
}
export async function createGift(payload: { name: string }) {
return fetchJson<Gift>("/api/organizador/gift", {
method: "POST",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,19 @@
import { fetchJson } from "../../lib/api";
export type Guest = {
id: string;
name: string;
email?: string;
rsvp?: boolean;
};
export async function listGuests() {
return fetchJson<Guest[]>("/api/organizador/guest");
}
export async function createGuest(payload: { name: string; email?: string }) {
return fetchJson<Guest>("/api/organizador/guest", {
method: "POST",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,24 @@
import { fetchJson } from "../../lib/api";
export type Todo = {
id: string;
title: string;
completed: boolean;
};
export async function listTodos() {
return fetchJson<Todo[]>("/api/organizador/todo");
}
export async function createTodo(payload: { title: string }) {
return fetchJson<Todo>("/api/organizador/todo", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function completeTodo(id: string) {
return fetchJson<Todo>(`/api/organizador/todo/${id}/complete`, {
method: "PATCH",
});
}

View File

@@ -1,8 +1,25 @@
import { Card } from "../ui/Card";
export function DashboardPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold mb-4">Dashboard</h1>
<p className="text-sm text-slate-600 dark:text-slate-300">Select a section from the sidebar to manage guests, gifts, or todos.</p>
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-3">
<Card title="Guests" subtitle="Total invited">
<p className="text-3xl font-semibold"></p>
</Card>
<Card title="Gifts" subtitle="Gift ideas">
<p className="text-3xl font-semibold"></p>
</Card>
<Card title="Todos" subtitle="Pending tasks">
<p className="text-3xl font-semibold"></p>
</Card>
</div>
<Card title="Quick links">
<p className="text-sm text-slate-600 dark:text-slate-300">
Use the sidebar to navigate, or start by creating a new guest, gift, or todo.
</p>
</Card>
</div>
);
}

View File

@@ -1,14 +1,17 @@
import { useEffect, useState } from "react";
import { fetchJson } from "../lib/api";
import { createGift, listGifts, type Gift } from "../modules/gifts/api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Input } from "../ui/Input";
export function GiftsPage() {
const [gifts, setGifts] = useState<any[]>([]);
const [gifts, setGifts] = useState<Gift[]>([]);
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const data = await fetchJson<any[]>("/api/gift");
const data = await listGifts();
setGifts(data);
} catch (err: any) {
setError(err.message);
@@ -23,10 +26,7 @@ export function GiftsPage() {
e.preventDefault();
setError(null);
try {
await fetchJson("/api/gift", {
method: "POST",
body: JSON.stringify({ name }),
});
await createGift({ name });
setName("");
load();
} catch (err: any) {
@@ -35,41 +35,40 @@ export function GiftsPage() {
};
return (
<div className="p-6">
<h1 className="text-2xl font-semibold mb-4">Gifts</h1>
{error ? <div className="mb-4 text-sm text-red-600">{error}</div> : null}
<form onSubmit={handleCreate} className="mb-6 flex flex-col gap-3 max-w-md">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Gift name"
className="rounded border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
required
/>
<button className="w-full rounded bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500">
Add gift
</button>
</form>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Owner</th>
</tr>
</thead>
<tbody>
{gifts.map((gift) => (
<tr key={gift.id} className="border-t border-slate-200 dark:border-slate-800">
<td className="px-3 py-2">{gift.name}</td>
<td className="px-3 py-2">{gift.status}</td>
<td className="px-3 py-2">{gift.ownerId || "—"}</td>
<div className="space-y-6">
{error ? <div className="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200">{error}</div> : null}
<Card title="Gifts" subtitle="Manage gift ideas and assignments">
<form onSubmit={handleCreate} className="grid gap-3 md:grid-cols-3">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Gift name" required />
<div className="md:col-span-3">
<Button type="submit" className="w-full">
Add gift
</Button>
</div>
</form>
<div className="mt-6 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Status</th>
<th className="px-4 py-2">Owner</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{gifts.map((gift) => (
<tr key={gift.id} className="border-t border-slate-200 dark:border-slate-800">
<td className="px-4 py-2">{gift.name}</td>
<td className="px-4 py-2">{gift.status}</td>
<td className="px-4 py-2">{gift.ownerId || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -1,15 +1,18 @@
import { useEffect, useState } from "react";
import { fetchJson } from "../lib/api";
import { createGuest, listGuests, type Guest } from "../modules/guests/api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Input } from "../ui/Input";
export function GuestsPage() {
const [guests, setGuests] = useState<any[]>([]);
const [guests, setGuests] = useState<Guest[]>([]);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const data = await fetchJson<any[]>('/api/guest');
const data = await listGuests();
setGuests(data);
} catch (err: any) {
setError(err.message);
@@ -24,12 +27,9 @@ export function GuestsPage() {
e.preventDefault();
setError(null);
try {
await fetchJson('/api/guest', {
method: 'POST',
body: JSON.stringify({ name, email }),
});
setName('');
setEmail('');
await createGuest({ name, email });
setName("");
setEmail("");
load();
} catch (err: any) {
setError(err.message);
@@ -37,47 +37,39 @@ export function GuestsPage() {
};
return (
<div className="p-6">
<h1 className="text-2xl font-semibold mb-4">Guests</h1>
{error ? <div className="mb-4 text-sm text-red-600">{error}</div> : null}
<form onSubmit={handleCreate} className="mb-6 flex flex-col gap-3 max-w-md">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
className="rounded border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
required
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email (optional)"
className="rounded border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
<button className="w-full rounded bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500">
Add guest
</button>
</form>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">RSVP</th>
</tr>
</thead>
<tbody>
{guests.map((guest) => (
<tr key={guest.id} className="border-t border-slate-200 dark:border-slate-800">
<td className="px-3 py-2">{guest.name}</td>
<td className="px-3 py-2">{guest.email ?? "—"}</td>
<td className="px-3 py-2">{guest.rsvp ? "Yes" : "No"}</td>
<div className="space-y-6">
{error ? <div className="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200">{error}</div> : null}
<Card title="Guests" subtitle="Manage guest list and RSVPs">
<form onSubmit={handleCreate} className="grid gap-3 md:grid-cols-3">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" required />
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional)" />
<Button type="submit" className="md:col-span-3">
Add guest
</Button>
</form>
<div className="mt-6 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Email</th>
<th className="px-4 py-2">RSVP</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{guests.map((guest) => (
<tr key={guest.id} className="border-t border-slate-200 dark:border-slate-800">
<td className="px-4 py-2">{guest.name}</td>
<td className="px-4 py-2">{guest.email ?? "—"}</td>
<td className="px-4 py-2">{guest.rsvp ? "Yes" : "No"}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -1,7 +1,10 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { fetchJson } from "../lib/api";
import { login } from "../modules/auth/api";
import { setToken } from "../lib/auth";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Input } from "../ui/Input";
export function LoginPage() {
const navigate = useNavigate();
@@ -13,10 +16,7 @@ export function LoginPage() {
event.preventDefault();
setError(null);
try {
const result = await fetchJson<{ token: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
const result = await login(email, password);
setToken(result.token);
navigate("/");
} catch (err: any) {
@@ -25,41 +25,26 @@ export function LoginPage() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="w-full max-w-md rounded-xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<h1 className="text-2xl font-semibold mb-6">Login</h1>
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<Card className="w-full max-w-md">
<h1 className="text-2xl font-semibold mb-6">Sign in</h1>
{error ? <div className="mb-4 text-sm text-red-600">{error}</div> : null}
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">Email</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
required
/>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">Password</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
required
/>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<button
type="submit"
className="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500"
>
<Button type="submit" className="w-full">
Sign in
</button>
</Button>
</form>
</div>
</Card>
</div>
);
}

View File

@@ -1,14 +1,17 @@
import { useEffect, useState } from "react";
import { fetchJson } from "../lib/api";
import { createTodo, completeTodo, listTodos, type Todo } from "../modules/todos/api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Input } from "../ui/Input";
export function TodosPage() {
const [todos, setTodos] = useState<any[]>([]);
const [todos, setTodos] = useState<Todo[]>([]);
const [title, setTitle] = useState("");
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const data = await fetchJson<any[]>("/api/todo");
const data = await listTodos();
setTodos(data);
} catch (err: any) {
setError(err.message);
@@ -23,10 +26,7 @@ export function TodosPage() {
e.preventDefault();
setError(null);
try {
await fetchJson("/api/todo", {
method: "POST",
body: JSON.stringify({ title }),
});
await createTodo({ title });
setTitle("");
load();
} catch (err: any) {
@@ -36,7 +36,7 @@ export function TodosPage() {
const markComplete = async (id: string) => {
try {
await fetchJson(`/api/todo/${id}/complete`, { method: "PATCH" });
await completeTodo(id);
load();
} catch (err: any) {
setError(err.message);
@@ -44,52 +44,48 @@ export function TodosPage() {
};
return (
<div className="p-6">
<h1 className="text-2xl font-semibold mb-4">Todos</h1>
{error ? <div className="mb-4 text-sm text-red-600">{error}</div> : null}
<form onSubmit={handleCreate} className="mb-6 flex flex-col gap-3 max-w-md">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="To-do title"
className="rounded border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
required
/>
<button className="w-full rounded bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500">
Add todo
</button>
</form>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-3 py-2">Title</th>
<th className="px-3 py-2">Completed</th>
<th className="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{todos.map((todo) => (
<tr key={todo.id} className="border-t border-slate-200 dark:border-slate-800">
<td className="px-3 py-2">{todo.title}</td>
<td className="px-3 py-2">{todo.completed ? "Yes" : "No"}</td>
<td className="px-3 py-2">
{!todo.completed ? (
<button
className="rounded bg-green-600 px-3 py-1 text-xs font-semibold text-white hover:bg-green-500"
onClick={() => markComplete(todo.id)}
>
Complete
</button>
) : (
"—"
)}
</td>
<div className="space-y-6">
{error ? <div className="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-200">{error}</div> : null}
<Card title="Todos" subtitle="Keep track of tasks and due dates">
<form onSubmit={handleCreate} className="grid gap-3 md:grid-cols-3">
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="To-do title" required />
<div className="md:col-span-3">
<Button type="submit" className="w-full">
Add todo
</Button>
</div>
</form>
<div className="mt-6 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<th className="px-4 py-2">Title</th>
<th className="px-4 py-2">Completed</th>
<th className="px-4 py-2">Actions</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{todos.map((todo) => (
<tr key={todo.id} className="border-t border-slate-200 dark:border-slate-800">
<td className="px-4 py-2">{todo.title}</td>
<td className="px-4 py-2">{todo.completed ? "Yes" : "No"}</td>
<td className="px-4 py-2">
{!todo.completed ? (
<Button variant="secondary" size="sm" onClick={() => markComplete(todo.id)}>
Complete
</Button>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { ButtonHTMLAttributes } from "react";
type ButtonVariant = "primary" | "secondary" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
const variantClasses: Record<ButtonVariant, string> = {
primary:
"bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:ring-indigo-500",
secondary:
"bg-slate-200 text-slate-900 hover:bg-slate-300 focus-visible:ring-slate-400 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",
ghost:
"bg-transparent text-slate-700 hover:bg-slate-100 focus-visible:ring-slate-400 dark:text-slate-200 dark:hover:bg-slate-800",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-5 py-3 text-base",
};
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export function Button({
variant = "primary",
size = "md",
className = "",
...props
}: ButtonProps) {
return (
<button
type="button"
className={`inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
export function Card({
title,
subtitle,
children,
className = "",
}: {
title?: string;
subtitle?: string;
children: ReactNode;
className?: string;
}) {
return (
<section className={`rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950 ${className}`}>
{title ? (
<header className="mb-4">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{subtitle ? <p className="text-sm text-slate-600 dark:text-slate-400">{subtitle}</p> : null}
</header>
) : null}
{children}
</section>
);
}

View File

@@ -0,0 +1,10 @@
import type { InputHTMLAttributes } from "react";
export function Input({ className = "", ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={`w-full rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm shadow-sm transition focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 ${className}`}
{...props}
/>
);
}

View File

@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
import { Sidebar } from "../components/Sidebar";
import { Topbar } from "./Topbar";
export function PageShell({
children,
title,
}: {
children: ReactNode;
title?: string;
}) {
return (
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div className="flex min-h-screen">
<Sidebar />
<div className="flex flex-1 flex-col">
<Topbar title={title} />
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useMemo } from "react";
import { Moon, Sun, LogOut } from "lucide-react";
import { useDarkMode } from "../hooks/useDarkMode";
import { logout } from "../lib/auth";
import { Button } from "./Button";
const menuItems = [
{ label: "Dashboard", to: "/" },
{ label: "Guests", to: "/guests" },
{ label: "Gifts", to: "/gifts" },
{ label: "Todos", to: "/todos" },
];
export function Topbar({ title = "Dashboard" }: { title?: string }) {
const { dark, toggle } = useDarkMode();
const currentLabel = useMemo(() => {
const match = menuItems.find((item) => item.label === title);
return match ? match.label : title;
}, [title]);
return (
<header className="flex items-center justify-between border-b border-slate-200 bg-white px-6 py-4 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div>
<p className="text-sm font-semibold text-slate-600 dark:text-slate-300">{currentLabel}</p>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={toggle}>
{dark ? (
<span className="flex items-center gap-2">
<Sun className="h-4 w-4" />
Light
</span>
) : (
<span className="flex items-center gap-2">
<Moon className="h-4 w-4" />
Dark
</span>
)}
</Button>
<Button variant="ghost" size="sm" onClick={logout}>
<span className="flex items-center gap-2">
<LogOut className="h-4 w-4" />
Logout
</span>
</Button>
</div>
</header>
);
}