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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user