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