Implemented. Sign in, then open Experience or Skills to:

- Add, edit, and delete experience entries and project links.
- Manage skill categories and individual skill labels.
- Edit the skills introduction and “How I work” text.
Changes save directly to Supabase—no code edits or Git push needed. Existing content is preserved.
This commit is contained in:
StormRunner06106
2026-09-13 10:03:21 -07:00
parent 6e3779e500
commit 404e0dc16d
25 changed files with 1337 additions and 82 deletions
+26 -22
View File
@@ -1,5 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { getExperience } from "../api";
import { useAdmin } from "../components/AdminSession";
import usePortfolioResource from "../usePortfolioResource";
import { ContentActions, DeleteContentDialog, ExperienceEditor } from "../components/PortfolioEditors";
import { useMemo, useState } from "react";
import { deleteExperience, getExperience } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status";
@@ -33,7 +36,7 @@ function durationLabel(start, end) {
return parts.join(" ") || "1 mo";
}
function TimelineItem({ item, index, isExpanded, onToggle }) {
function TimelineItem({ item, index, isExpanded, onToggle, actions }) {
const isCurrent = !item.end;
const year = item.start.slice(0, 4);
@@ -46,6 +49,7 @@ function TimelineItem({ item, index, isExpanded, onToggle }) {
<span />
</div>
<div className="timeline-card">
{actions}
<button
className="timeline-card__header"
onClick={onToggle}
@@ -110,24 +114,19 @@ function TimelineItem({ item, index, isExpanded, onToggle }) {
}
export default function ExperiencePage() {
const [experience, setExperience] = useState([]);
const { isAdmin } = useAdmin();
const { data, loading, error, reload, setData } = usePortfolioResource(getExperience);
const experience = data ?? [];
const [expandedId, setExpandedId] = useState("independent");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
getExperience(controller.signal)
.then(setExperience)
.catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
const [editor, setEditor] = useState(null);
function saved(record) {
setData((current) => [...current.filter((item) => item.id !== record.id), record].sort((a, b) => b.start.localeCompare(a.start)));
setExpandedId(record.id);
setEditor(null);
}
const visibleYears = useMemo(() => {
if (!experience.length) return "2013 — now";
if (!experience.length) return "Your timeline";
return `${experience.at(-1).start.slice(0, 4)} — now`;
}, [experience]);
@@ -139,19 +138,22 @@ export default function ExperiencePage() {
description="Product engineering, critical operations, and technical leadership—connected by a habit of making complicated systems easier to use."
aside={
<div className="heading-stat">
<strong>{experience.length || "6"}</strong>
<strong>{experience.length}</strong>
<span>career chapters<br />{visibleYears}</span>
</div>
}
/>
{loading && <LoadingState label="Mapping the timeline" />}
{error && <ErrorState message={error} />}
{isAdmin && <div className="portfolio-admin-toolbar"><p>Keep your career timeline up to date.</p><button type="button" className="button button--primary" disabled={!data} onClick={() => setEditor({ kind: "edit", item: null })}><Icon name="plus" size={17} /> Add experience</button></div>}
{loading && !data && <LoadingState label="Mapping the timeline" />}
{error && <ErrorState message={error} onRetry={reload} retrying={loading} />}
{!loading && !error && (
{data && (
<div className="timeline">
{!experience.length && <p className="state-card">No experience entries yet.</p>}
{experience.map((item, index) => (
<TimelineItem
actions={isAdmin && <ContentActions label={`${item.role} at ${item.company}`} onEdit={() => setEditor({ kind: "edit", item })} onDelete={() => setEditor({ kind: "delete", item })} />}
index={index}
isExpanded={expandedId === item.id}
item={item}
@@ -161,6 +163,8 @@ export default function ExperiencePage() {
))}
</div>
)}
{editor?.kind === "edit" && <ExperienceEditor item={editor.item} onClose={() => setEditor(null)} onSaved={saved} />}
{editor?.kind === "delete" && <DeleteContentDialog label={`${editor.item.role} at ${editor.item.company}`} onClose={() => setEditor(null)} onDelete={(token) => deleteExperience(editor.item.id, token)} onDeleted={() => { setData((current) => current.filter((item) => item.id !== editor.item.id)); setEditor(null); }} />}
</section>
);
}
+19 -7
View File
@@ -38,6 +38,7 @@ function ArticleForm({ slug }) {
const [articleText, setArticleText] = useState("");
const [banner, setBanner] = useState(null);
const [attachments, setAttachments] = useState([]);
const discardedUploads = useRef(new Set());
const [uploading, setUploading] = useState(false);
const [customTopic, setCustomTopic] = useState("");
const [topicMessage, setTopicMessage] = useState("");
@@ -121,6 +122,7 @@ function ArticleForm({ slug }) {
content: article,
banner,
attachments,
discarded_uploads: [...discardedUploads.current],
};
setStatus({ type: "sending", message: slug ? "Saving changes..." : "Publishing..." });
@@ -136,17 +138,20 @@ function ArticleForm({ slug }) {
async function uploadFile(file, purpose, options) {
const uploaded = await uploadMedia(file, purpose, token, options);
if (options.signal.aborted) return;
if (purpose === "banner") setBanner(uploaded);
if (purpose === "banner") setBanner((previous) => {
if (previous) discardedUploads.current.add(previous.url);
return uploaded;
});
else setAttachments((current) => [...current, uploaded]);
}
function moveAttachment(url, direction) {
function moveAttachment(url, targetUrl) {
setAttachments((current) => {
const index = current.findIndex((file) => file.url === url);
const next = index + direction;
if (index < 0 || next < 0 || next >= current.length) return current;
const next = current.findIndex((file) => file.url === targetUrl);
if (index < 0 || next < 0 || index === next) return current;
const ordered = [...current];
[ordered[index], ordered[next]] = [ordered[next], ordered[index]];
ordered.splice(next, 0, ordered.splice(index, 1)[0]);
return ordered;
});
}
@@ -273,8 +278,15 @@ function ArticleForm({ slug }) {
<p className="section-help">Use + Section to add a heading and a new section. Each section gets a divider and an automatic uppercase drop cap on its opening paragraph.</p>
<ArticleUploads banner={banner} attachments={attachments} busy={status.type === "sending"}
title={post.title} subtitle={post.excerpt} onPendingChange={setUploading} onMoveAttachment={moveAttachment}
onUpload={uploadFile} onRemoveBanner={() => setBanner(null)}
onRemoveAttachment={(url) => setAttachments((current) => current.filter((file) => file.url !== url))} />
onUpload={uploadFile} onRemoveBanner={() => {
if (banner) discardedUploads.current.add(banner.url);
setBanner(null);
}}
onRemoveAttachment={(url) => {
discardedUploads.current.add(url);
setAttachments((current) => current.filter((file) => file.url !== url));
}} />
<p className="section-help">Removed photos and files are deleted from storage when you {slug ? "save changes" : "publish"}.</p>
<div className="publisher-submit">
<Link className="text-button" to={slug ? `/blog/${slug}` : "/blog"}>Cancel</Link>
+24 -21
View File
@@ -1,24 +1,21 @@
import { useEffect, useState } from "react";
import { getSkills } from "../api";
import { useAdmin } from "../components/AdminSession";
import usePortfolioResource from "../usePortfolioResource";
import { ContentActions, DeleteContentDialog, SkillCategoryEditor, SkillsOverviewEditor } from "../components/PortfolioEditors";
import { useState } from "react";
import { deleteSkillCategory, getSkills } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status";
export default function SkillsPage() {
const [skills, setSkills] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
getSkills(controller.signal)
.then(setSkills)
.catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
const { isAdmin } = useAdmin();
const { data: skills, loading, error, reload, setData } = usePortfolioResource(getSkills);
const [editor, setEditor] = useState(null);
function savedCategory(record) {
setData((current) => ({ ...current, categories: current.categories.some((item) => item.id === record.id)
? current.categories.map((item) => item.id === record.id ? record : item) : [...current.categories, record] }));
setEditor(null);
}
return (
<section className="content-page container">
@@ -33,12 +30,13 @@ export default function SkillsPage() {
}
/>
{loading && <LoadingState label="Unpacking the toolkit" />}
{error && <ErrorState message={error} />}
{isAdmin && <div className="portfolio-admin-toolbar"><p>Shape your toolkit as your skills evolve.</p><div><button className="button" type="button" disabled={!skills} onClick={() => setEditor({ kind: "overview" })}>Edit overview</button><button className="button button--primary" type="button" disabled={!skills} onClick={() => setEditor({ kind: "category", item: null })}><Icon name="plus" size={17} /> Add category</button></div></div>}
{loading && !skills && <LoadingState label="Unpacking the toolkit" />}
{error && <ErrorState message={error} onRetry={reload} retrying={loading} />}
{skills && (
<>
<div className="principles-panel reveal">
{skills.principles.length > 0 && <div className="principles-panel reveal">
<p className="eyebrow">How I work</p>
<div className="principles-list">
{skills.principles.map((principle, index) => (
@@ -48,13 +46,14 @@ export default function SkillsPage() {
</div>
))}
</div>
</div>
</div>}
<div className="skills-grid">
{!skills.categories.length && <p className="state-card">No skill categories yet.</p>}
{skills.categories.map((category, index) => (
<article
className={`skill-card skill-card--${category.accent} reveal`}
key={category.name}
key={category.id}
style={{ "--delay": `${index * 55}ms` }}
>
<div className="skill-card__top">
@@ -68,11 +67,15 @@ export default function SkillsPage() {
<span key={item}>{item}</span>
))}
</div>
{isAdmin && <ContentActions label={category.name} onEdit={() => setEditor({ kind: "category", item: category })} onDelete={() => setEditor({ kind: "delete", item: category })} />}
</article>
))}
</div>
</>
)}
{editor?.kind === "category" && <SkillCategoryEditor item={editor.item} onClose={() => setEditor(null)} onSaved={savedCategory} />}
{editor?.kind === "overview" && <SkillsOverviewEditor skills={skills} onClose={() => setEditor(null)} onSaved={(record) => { setData((current) => ({ ...current, intro: record.intro, principles: record.principles })); setEditor(null); }} />}
{editor?.kind === "delete" && <DeleteContentDialog label={editor.item.name} onClose={() => setEditor(null)} onDelete={(token) => deleteSkillCategory(editor.item.id, token)} onDeleted={() => { setData((current) => ({ ...current, categories: current.categories.filter((item) => item.id !== editor.item.id) })); setEditor(null); }} />}
</section>
);
}