Files
my-portfolio/src/app/admin/page.tsx
T
Space-Banane dfbe14d284
Build Check / build (push) Failing after 18s
Build Check / deploy-coolify (push) Has been skipped
Build Check / push-image (push) Has been skipped
Little rework
2026-07-16 23:03:42 +02:00

544 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 &amp; 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>
);
}