chore: remove legacy workspace and restructure API + modular admin UI
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
28
packages/admin/src/hooks/useDarkMode.ts
Normal file
28
packages/admin/src/hooks/useDarkMode.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
12
packages/admin/src/modules/auth/api.ts
Normal file
12
packages/admin/src/modules/auth/api.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
19
packages/admin/src/modules/gifts/api.ts
Normal file
19
packages/admin/src/modules/gifts/api.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
19
packages/admin/src/modules/guests/api.ts
Normal file
19
packages/admin/src/modules/guests/api.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
24
packages/admin/src/modules/todos/api.ts
Normal file
24
packages/admin/src/modules/todos/api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
40
packages/admin/src/ui/Button.tsx
Normal file
40
packages/admin/src/ui/Button.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
25
packages/admin/src/ui/Card.tsx
Normal file
25
packages/admin/src/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
packages/admin/src/ui/Input.tsx
Normal file
10
packages/admin/src/ui/Input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
packages/admin/src/ui/PageShell.tsx
Normal file
23
packages/admin/src/ui/PageShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
packages/admin/src/ui/Topbar.tsx
Normal file
51
packages/admin/src/ui/Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user