Initial portfolio CMS: Fastify API + Vite SPA

Single-container app with Postgres catalog, Typesense search,
Authelia Remote-User admin, portfolio views, resume/PDF, media
uploads, and ArtStation import tooling.
This commit is contained in:
2026-07-23 17:01:34 -04:00
commit 43928f7a49
61 changed files with 13218 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useState } from "react";
import { api, type Resume } from "../../lib/api";
export function AdminResume() {
const [form, setForm] = useState({
fullName: "",
title: "",
email: "",
phone: "",
location: "",
website: "",
summary: "",
sectionsJson: "[]",
htmlContent: "",
});
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api.adminResume().then((r) => {
if (!r) return;
setForm({
fullName: r.fullName,
title: r.title || "",
email: r.email || "",
phone: r.phone || "",
location: r.location || "",
website: r.website || "",
summary: r.summary || "",
sectionsJson: JSON.stringify(r.sections ?? [], null, 2),
htmlContent: r.htmlContent || "",
});
});
}, []);
async function save(e: React.FormEvent) {
e.preventDefault();
setErr(null);
setMsg(null);
let sections: unknown = [];
try {
sections = JSON.parse(form.sectionsJson || "[]");
} catch {
setErr("Sections JSON is invalid");
return;
}
try {
await api.saveResume({
fullName: form.fullName,
title: form.title,
email: form.email || null,
phone: form.phone || null,
location: form.location || null,
website: form.website || null,
summary: form.summary,
sections,
htmlContent: form.htmlContent || null,
});
setMsg("Resume saved");
} catch (e) {
setErr((e as Error).message);
}
}
return (
<div className="admin-page">
<h1>Resume</h1>
<p className="admin-page__sub">
Public at <a href="/resume">/resume</a> · PDF at{" "}
<a href="/api/v1/resume/pdf">/api/v1/resume/pdf</a>
</p>
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
{err && <div className="admin-msg admin-msg--err">{err}</div>}
<form className="admin-form admin-form--wide" onSubmit={save}>
<div className="field">
<label>Full name</label>
<input
required
value={form.fullName}
onChange={(e) => setForm({ ...form, fullName: e.target.value })}
/>
</div>
<div className="field">
<label>Title</label>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
<div className="field">
<label>Email</label>
<input
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div className="field">
<label>Phone</label>
<input
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</div>
<div className="field">
<label>Location</label>
<input
value={form.location}
onChange={(e) => setForm({ ...form, location: e.target.value })}
/>
</div>
<div className="field">
<label>Website</label>
<input
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
/>
</div>
</div>
<div className="field">
<label>Summary</label>
<textarea
rows={4}
value={form.summary}
onChange={(e) => setForm({ ...form, summary: e.target.value })}
/>
</div>
<div className="field">
<label>Sections (JSON)</label>
<textarea
rows={16}
style={{ fontFamily: "ui-monospace, monospace", fontSize: 13 }}
value={form.sectionsJson}
onChange={(e) => setForm({ ...form, sectionsJson: e.target.value })}
/>
</div>
<div className="field">
<label>Optional HTML override (replaces structured layout when set)</label>
<textarea
rows={6}
value={form.htmlContent}
onChange={(e) => setForm({ ...form, htmlContent: e.target.value })}
/>
</div>
<button type="submit" className="btn btn--primary">
Save resume
</button>
</form>
</div>
);
}