544 lines
20 KiB
TypeScript
544 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import { useRouter } from "next/navigation";
|
||
import { PROJECT_SIZES, SIZE_LABELS, type ProjectSize } from "../../types";
|
||
|
||
type Resource = "projects" | "experience" | "affiliates";
|
||
|
||
const RESOURCES: { key: Resource; label: string }[] = [
|
||
{ key: "projects", label: "Projects" },
|
||
{ key: "experience", label: "Work Experience" },
|
||
{ key: "affiliates", label: "Affiliates" },
|
||
];
|
||
|
||
// A record being edited; `_new` marks an unsaved draft (POST vs PUT).
|
||
type Row = Record<string, unknown> & { id?: string; _new?: boolean };
|
||
|
||
const TEMPLATES: Record<Resource, () => Row> = {
|
||
projects: () => ({
|
||
_new: true,
|
||
name: "",
|
||
label: "",
|
||
size: "MediumSized",
|
||
imageUrl: "",
|
||
link: "",
|
||
linkIsDemo: false,
|
||
sourceCode: "",
|
||
description: "",
|
||
why: "",
|
||
note: "",
|
||
tags: [],
|
||
loc: null,
|
||
locEndpoint: "",
|
||
}),
|
||
experience: () => ({
|
||
_new: true,
|
||
company: "",
|
||
role: "",
|
||
fromDate: "",
|
||
toDate: "",
|
||
url: "",
|
||
iconUrl: "",
|
||
summary: "",
|
||
tags: [],
|
||
sortIndex: 0,
|
||
}),
|
||
affiliates: () => ({
|
||
_new: true,
|
||
name: "",
|
||
link: "",
|
||
icon: "",
|
||
location: "",
|
||
provides: [],
|
||
good: [],
|
||
bad: [],
|
||
sortIndex: 0,
|
||
}),
|
||
};
|
||
|
||
const inputCls =
|
||
"w-full bg-black/40 border border-white/10 rounded-lg p-2 text-sm text-gray-100 outline-none focus:border-blue-500/50";
|
||
const labelCls = "text-xs font-bold text-gray-500 uppercase";
|
||
|
||
function toDateInput(value: unknown): string {
|
||
if (!value) return "";
|
||
const d = new Date(String(value));
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
export default function AdminPage() {
|
||
const router = useRouter();
|
||
|
||
const [authChecked, setAuthChecked] = useState(false);
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||
const [password, setPassword] = useState("");
|
||
const [loginError, setLoginError] = useState("");
|
||
const [loggingIn, setLoggingIn] = useState(false);
|
||
|
||
const [resource, setResource] = useState<Resource>("projects");
|
||
const [rows, setRows] = useState<Row[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
||
const [savingId, setSavingId] = useState<string | null>(null);
|
||
|
||
const showToast = (type: "success" | "error", message: string) => {
|
||
setToast({ type, message });
|
||
setTimeout(() => setToast(null), 3500);
|
||
};
|
||
|
||
// --- auth ---
|
||
useEffect(() => {
|
||
fetch("/api/admin/session")
|
||
.then((r) => r.json())
|
||
.then((d) => setIsAuthenticated(Boolean(d.authenticated)))
|
||
.catch(() => setIsAuthenticated(false))
|
||
.finally(() => setAuthChecked(true));
|
||
}, []);
|
||
|
||
const handleLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLoggingIn(true);
|
||
setLoginError("");
|
||
try {
|
||
const res = await fetch("/api/admin/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ password }),
|
||
});
|
||
if (res.ok) {
|
||
setIsAuthenticated(true);
|
||
setPassword("");
|
||
} else {
|
||
setLoginError("Invalid password");
|
||
}
|
||
} catch {
|
||
setLoginError("Network error");
|
||
} finally {
|
||
setLoggingIn(false);
|
||
}
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
await fetch("/api/admin/logout", { method: "POST" });
|
||
setIsAuthenticated(false);
|
||
};
|
||
|
||
// --- data ---
|
||
const loadRows = useCallback(async (res: Resource) => {
|
||
setLoading(true);
|
||
try {
|
||
const r = await fetch(`/api/admin/${res}`);
|
||
if (r.ok) {
|
||
const json = await r.json();
|
||
const normalized = Array.isArray(json)
|
||
? json.map((item: Row) =>
|
||
res === "experience"
|
||
? { ...item, fromDate: toDateInput(item.fromDate), toDate: toDateInput(item.toDate) }
|
||
: item,
|
||
)
|
||
: [];
|
||
setRows(normalized);
|
||
} else if (r.status === 401) {
|
||
setIsAuthenticated(false);
|
||
} else {
|
||
showToast("error", `Failed to load (${r.status})`);
|
||
}
|
||
} catch {
|
||
showToast("error", "Failed to load data");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (isAuthenticated) loadRows(resource);
|
||
}, [isAuthenticated, resource, loadRows]);
|
||
|
||
const update = (idx: number, field: string, value: unknown) => {
|
||
setRows((prev) => prev.map((row, i) => (i === idx ? { ...row, [field]: value } : row)));
|
||
};
|
||
|
||
const addRow = () => setRows((prev) => [TEMPLATES[resource](), ...prev]);
|
||
|
||
const saveRow = async (idx: number) => {
|
||
const row = rows[idx];
|
||
const isNew = row._new || !row.id;
|
||
setSavingId(row.id || `new-${idx}`);
|
||
try {
|
||
const url = isNew ? `/api/admin/${resource}` : `/api/admin/${resource}/${row.id}`;
|
||
const { _new, ...payload } = row;
|
||
const res = await fetch(url, {
|
||
method: isNew ? "POST" : "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (res.ok) {
|
||
showToast("success", "Saved");
|
||
await loadRows(resource);
|
||
} else {
|
||
const err = await res.json().catch(() => ({}));
|
||
showToast("error", err.error || `Save failed (${res.status})`);
|
||
}
|
||
} catch {
|
||
showToast("error", "Network error");
|
||
} finally {
|
||
setSavingId(null);
|
||
}
|
||
};
|
||
|
||
const deleteRow = async (idx: number) => {
|
||
const row = rows[idx];
|
||
if (row._new || !row.id) {
|
||
setRows((prev) => prev.filter((_, i) => i !== idx));
|
||
return;
|
||
}
|
||
if (!confirm("Delete this entry permanently?")) return;
|
||
try {
|
||
const res = await fetch(`/api/admin/${resource}/${row.id}`, { method: "DELETE" });
|
||
if (res.ok) {
|
||
showToast("success", "Deleted");
|
||
setRows((prev) => prev.filter((_, i) => i !== idx));
|
||
} else {
|
||
showToast("error", `Delete failed (${res.status})`);
|
||
}
|
||
} catch {
|
||
showToast("error", "Network error");
|
||
}
|
||
};
|
||
|
||
// --- login screen ---
|
||
if (!authChecked) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-[#0a0a0a] text-gray-500">
|
||
Checking session…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-[#0a0a0a] px-4 font-sans text-white">
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
className="w-full max-w-sm p-8 bg-white/5 backdrop-blur-3xl border border-white/10 rounded-2xl shadow-2xl"
|
||
>
|
||
<h1 className="text-2xl font-black mb-6 text-center text-white">ADMIN ACCESS</h1>
|
||
<form onSubmit={handleLogin} className="space-y-4">
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder="Password"
|
||
className="w-full bg-black/50 border border-white/10 rounded-xl px-4 py-3 focus:ring-2 focus:ring-purple-500/50 outline-none"
|
||
autoFocus
|
||
/>
|
||
{loginError && <p className="text-red-400 text-xs text-center">{loginError}</p>}
|
||
<button
|
||
type="submit"
|
||
disabled={loggingIn}
|
||
className="w-full bg-white text-black font-bold py-3 rounded-xl hover:opacity-90 disabled:opacity-50"
|
||
>
|
||
{loggingIn ? "Validating…" : "Unlock"}
|
||
</button>
|
||
</form>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- panel ---
|
||
return (
|
||
<div className="min-h-screen bg-[#0a0a0a] text-gray-200 font-sans">
|
||
<div className="max-w-6xl mx-auto p-6">
|
||
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-10">
|
||
<div>
|
||
<h1 className="text-3xl font-black text-white">PORTFOLIO CMS</h1>
|
||
<p className="text-gray-500 text-sm">Manage your projects, experience & affiliates</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button onClick={() => router.push("/")} className="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm">
|
||
View Site
|
||
</button>
|
||
<button onClick={handleLogout} className="px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-sm">
|
||
Logout
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="flex flex-col lg:flex-row gap-8">
|
||
<aside className="lg:w-56 flex-shrink-0 space-y-2">
|
||
{RESOURCES.map((r) => (
|
||
<button
|
||
key={r.key}
|
||
onClick={() => setResource(r.key)}
|
||
className={`w-full text-left px-4 py-3 rounded-xl border text-sm font-bold ${
|
||
resource === r.key
|
||
? "bg-white text-black border-white"
|
||
: "bg-white/5 border-transparent text-gray-400 hover:bg-white/10"
|
||
}`}
|
||
>
|
||
{r.label}
|
||
</button>
|
||
))}
|
||
</aside>
|
||
|
||
<main className="flex-1 space-y-6">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-xl font-bold text-white">{RESOURCES.find((r) => r.key === resource)?.label}</h2>
|
||
<button onClick={addRow} className="px-4 py-2 rounded-lg bg-green-600/20 text-green-400 border border-green-500/30 hover:bg-green-600/30 text-xs font-bold">
|
||
+ New Entry
|
||
</button>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="h-64 flex items-center justify-center text-gray-500 italic animate-pulse">Loading…</div>
|
||
) : rows.length === 0 ? (
|
||
<div className="h-40 flex items-center justify-center text-gray-500 border border-white/10 rounded-2xl">
|
||
Nothing here yet. Add a new entry.
|
||
</div>
|
||
) : (
|
||
<AnimatePresence>
|
||
{rows.map((row, idx) => (
|
||
<motion.div
|
||
key={row.id || `new-${idx}`}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="p-6 bg-white/5 border border-white/10 rounded-2xl space-y-4"
|
||
>
|
||
<div className="flex justify-between items-center border-b border-white/5 pb-2">
|
||
<span className="text-[10px] font-mono text-gray-500 uppercase">
|
||
{row._new ? "NEW" : (row.id as string)?.slice(0, 8)}
|
||
</span>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => saveRow(idx)}
|
||
disabled={savingId !== null}
|
||
className="text-xs font-bold px-3 py-1 rounded-lg bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-50"
|
||
>
|
||
{savingId === (row.id || `new-${idx}`) ? "Saving…" : "Save"}
|
||
</button>
|
||
<button onClick={() => deleteRow(idx)} className="text-red-500/60 hover:text-red-400 text-xs">
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{resource === "projects" && <ProjectFields row={row} idx={idx} update={update} />}
|
||
{resource === "experience" && <ExperienceFields row={row} idx={idx} update={update} />}
|
||
{resource === "affiliates" && <AffiliateFields row={row} idx={idx} update={update} />}
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
)}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
{toast && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 100 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className={`fixed bottom-8 left-1/2 -translate-x-1/2 px-8 py-4 rounded-2xl shadow-2xl border font-bold text-sm ${
|
||
toast.type === "success" ? "bg-green-500 text-white border-green-400" : "bg-red-500 text-white border-red-400"
|
||
}`}
|
||
>
|
||
{toast.message}
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- field group components ----------
|
||
|
||
type FieldProps = { row: Row; idx: number; update: (idx: number, field: string, value: unknown) => void };
|
||
|
||
function Text({ row, idx, update, field, label, placeholder }: FieldProps & { field: string; label: string; placeholder?: string }) {
|
||
return (
|
||
<div className="space-y-1">
|
||
<label className={labelCls}>{label}</label>
|
||
<input
|
||
type="text"
|
||
value={(row[field] as string) ?? ""}
|
||
onChange={(e) => update(idx, field, e.target.value)}
|
||
className={inputCls}
|
||
placeholder={placeholder}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Area({ row, idx, update, field, label }: FieldProps & { field: string; label: string }) {
|
||
return (
|
||
<div className="md:col-span-2 space-y-1">
|
||
<label className={labelCls}>{label}</label>
|
||
<textarea
|
||
rows={3}
|
||
value={(row[field] as string) ?? ""}
|
||
onChange={(e) => update(idx, field, e.target.value)}
|
||
className={inputCls}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Toggle({ row, idx, update, field, label }: FieldProps & { field: string; label: string }) {
|
||
const on = Boolean(row[field]);
|
||
return (
|
||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-black/30 p-3">
|
||
<label className={labelCls}>{label}</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => update(idx, field, !on)}
|
||
className={`px-3 py-1 rounded-lg text-xs font-bold ${on ? "bg-green-500/20 text-green-300" : "bg-white/10 text-gray-400"}`}
|
||
>
|
||
{on ? "Yes" : "No"}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TagField({ row, idx, update, field, label, max }: FieldProps & { field: string; label: string; max?: number }) {
|
||
const [draft, setDraft] = useState("");
|
||
const list = (row[field] as string[]) || [];
|
||
const add = () => {
|
||
const v = draft.trim();
|
||
if (!v) return;
|
||
if (max && list.length >= max) return;
|
||
update(idx, field, [...list, v]);
|
||
setDraft("");
|
||
};
|
||
return (
|
||
<div className="md:col-span-2 space-y-1">
|
||
<label className={labelCls}>
|
||
{label} {max ? <span className="text-gray-600">(max {max})</span> : null}
|
||
</label>
|
||
<div className="space-y-2 rounded-lg border border-white/10 bg-black/30 p-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{list.map((tag, i) => (
|
||
<span key={`${tag}-${i}`} className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs">
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
onClick={() => update(idx, field, list.filter((_, j) => j !== i))}
|
||
className="text-red-400/70 hover:text-red-400"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
{list.length === 0 && <span className="text-xs text-gray-600">None yet.</span>}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
add();
|
||
}
|
||
}}
|
||
className={inputCls}
|
||
placeholder={max && list.length >= max ? "Max reached" : "Add…"}
|
||
disabled={Boolean(max && list.length >= max)}
|
||
/>
|
||
<button type="button" onClick={add} className="px-3 py-2 rounded-lg border border-green-500/40 text-green-300 hover:bg-green-500/15 text-xs font-bold">
|
||
Add
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProjectFields({ row, idx, update }: FieldProps) {
|
||
return (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<Text row={row} idx={idx} update={update} field="name" label="Name" />
|
||
<Text row={row} idx={idx} update={update} field="label" label="Label" placeholder="e.g. Dev Tool" />
|
||
<div className="space-y-1">
|
||
<label className={labelCls}>Size</label>
|
||
<select
|
||
value={(row.size as string) || "MediumSized"}
|
||
onChange={(e) => update(idx, "size", e.target.value as ProjectSize)}
|
||
className={inputCls}
|
||
>
|
||
{PROJECT_SIZES.map((s) => (
|
||
<option key={s} value={s}>
|
||
{SIZE_LABELS[s]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<Text row={row} idx={idx} update={update} field="link" label="Link" />
|
||
<Text row={row} idx={idx} update={update} field="sourceCode" label="Source Code (GitHub/Gitea)" />
|
||
<Text row={row} idx={idx} update={update} field="imageUrl" label="Image URL" />
|
||
<Area row={row} idx={idx} update={update} field="description" label="Description" />
|
||
<Area row={row} idx={idx} update={update} field="why" label="Why" />
|
||
<Area row={row} idx={idx} update={update} field="note" label="Note" />
|
||
<TagField row={row} idx={idx} update={update} field="tags" label="Tags" max={3} />
|
||
<div className="space-y-1">
|
||
<label className={labelCls}>LoC (manual)</label>
|
||
<input
|
||
type="number"
|
||
value={row.loc === null || row.loc === undefined ? "" : (row.loc as number)}
|
||
onChange={(e) => update(idx, "loc", e.target.value === "" ? null : Number(e.target.value))}
|
||
className={inputCls}
|
||
placeholder="Optional"
|
||
/>
|
||
</div>
|
||
<Text row={row} idx={idx} update={update} field="locEndpoint" label="LoC Endpoint (plaintext number)" />
|
||
<Toggle row={row} idx={idx} update={update} field="linkIsDemo" label="Link is a live demo" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ExperienceFields({ row, idx, update }: FieldProps) {
|
||
return (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<Text row={row} idx={idx} update={update} field="company" label="Company" />
|
||
<Text row={row} idx={idx} update={update} field="role" label="Role" />
|
||
<div className="space-y-1">
|
||
<label className={labelCls}>From</label>
|
||
<input type="date" value={(row.fromDate as string) || ""} onChange={(e) => update(idx, "fromDate", e.target.value)} className={inputCls} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className={labelCls}>Until (empty = Present)</label>
|
||
<input type="date" value={(row.toDate as string) || ""} onChange={(e) => update(idx, "toDate", e.target.value)} className={inputCls} />
|
||
</div>
|
||
<Text row={row} idx={idx} update={update} field="url" label="Company URL" />
|
||
<Text row={row} idx={idx} update={update} field="iconUrl" label="Icon URL" />
|
||
<Area row={row} idx={idx} update={update} field="summary" label="Summary of Activities" />
|
||
<TagField row={row} idx={idx} update={update} field="tags" label="Tags" max={6} />
|
||
<div className="space-y-1">
|
||
<label className={labelCls}>Sort Index</label>
|
||
<input type="number" value={(row.sortIndex as number) ?? 0} onChange={(e) => update(idx, "sortIndex", Number(e.target.value))} className={inputCls} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AffiliateFields({ row, idx, update }: FieldProps) {
|
||
return (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<Text row={row} idx={idx} update={update} field="name" label="Name" />
|
||
<Text row={row} idx={idx} update={update} field="link" label="Affiliate Link" />
|
||
<Text row={row} idx={idx} update={update} field="icon" label="Icon URL" />
|
||
<Text row={row} idx={idx} update={update} field="location" label="Location" />
|
||
<TagField row={row} idx={idx} update={update} field="provides" label="Provides" />
|
||
<TagField row={row} idx={idx} update={update} field="good" label="Good" />
|
||
<TagField row={row} idx={idx} update={update} field="bad" label="Bad" />
|
||
<div className="space-y-1">
|
||
<label className={labelCls}>Sort Index</label>
|
||
<input type="number" value={(row.sortIndex as number) ?? 0} onChange={(e) => update(idx, "sortIndex", Number(e.target.value))} className={inputCls} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|