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:
+14
-2
@@ -24,6 +24,7 @@ async function request(path, { timeoutMs = 0, ...options } = {}) {
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (typeof body.detail === "string") detail = body.detail;
|
||||
else if (Array.isArray(body.detail)) detail = body.detail.map((item) => item.msg?.replace(/^Value error, /, "")).filter(Boolean).join(" ") || detail;
|
||||
} catch { /* Keep the fallback for proxy errors. */ }
|
||||
const error = new Error(detail);
|
||||
error.status = response.status;
|
||||
@@ -64,8 +65,19 @@ async function readRequest(path, options = {}) {
|
||||
}
|
||||
|
||||
export const getProfile = (signal) => request("/api/profile", { signal });
|
||||
export const getExperience = (signal) => request("/api/experience", { signal });
|
||||
export const getSkills = (signal) => request("/api/skills", { signal });
|
||||
export const getExperience = (signal) => readRequest("/api/experience", { signal });
|
||||
export const getSkills = (signal) => readRequest("/api/skills", { signal });
|
||||
const saveContent = (path, method, payload, token) => request(path, {
|
||||
method, headers: { Authorization: `Bearer ${token}` },
|
||||
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
|
||||
});
|
||||
export const createExperience = (payload, token) => saveContent("/api/experience", "POST", payload, token);
|
||||
export const updateExperience = (id, payload, token) => saveContent(`/api/experience/${encodeURIComponent(id)}`, "PUT", payload, token);
|
||||
export const deleteExperience = (id, token) => saveContent(`/api/experience/${encodeURIComponent(id)}`, "DELETE", undefined, token);
|
||||
export const createSkillCategory = (payload, token) => saveContent("/api/skills/categories", "POST", payload, token);
|
||||
export const updateSkillCategory = (id, payload, token) => saveContent(`/api/skills/categories/${encodeURIComponent(id)}`, "PUT", payload, token);
|
||||
export const deleteSkillCategory = (id, token) => saveContent(`/api/skills/categories/${encodeURIComponent(id)}`, "DELETE", undefined, token);
|
||||
export const updateSkillsOverview = (payload, token) => saveContent("/api/skills", "PATCH", payload, token);
|
||||
export const getPosts = (signal) => readRequest("/api/posts", { signal });
|
||||
export const getPost = (slug, signal) => readRequest(`/api/posts/${slug}`, { signal });
|
||||
export const mediaUrl = (media) => `${API_BASE}${media.url}`;
|
||||
|
||||
@@ -108,12 +108,14 @@ export default function AdminProvider({ children }) {
|
||||
{open && <Modal title={isAdmin ? "Admin account" : "Admin sign in"} onClose={close} busy={busy}>
|
||||
{isAdmin ? <div className="admin-account">
|
||||
<span className="publisher-badge"><span /> Admin</span>
|
||||
<p>You can create, edit, and delete journal articles.</p>
|
||||
<p>Manage your articles, experience, and skills.</p>
|
||||
<Link className="button button--primary" to="/blog/manage" onClick={close}><Icon name="plus" size={18} /> New article</Link>
|
||||
<Link className="button" to="/blog" onClick={close}>Manage articles</Link>
|
||||
<Link className="button" to="/experience" onClick={close}>Manage experience</Link>
|
||||
<Link className="button" to="/skills" onClick={close}>Manage skills</Link>
|
||||
<button className="text-button" type="button" onClick={signOut}><Icon name="logout" size={18} /> Sign out</button>
|
||||
</div> : <form className="admin-signin" onSubmit={signIn}>
|
||||
<p>Enter the admin password to manage journal articles.</p>
|
||||
<p>Enter the admin password to manage your website content.</p>
|
||||
<label className="publisher-field">
|
||||
<span>Admin password</span>
|
||||
<input autoFocus autoComplete="current-password" type="password" required maxLength={200}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function ArticleAdminActions({ post, onDeleted }) {
|
||||
<Icon name="trash" size={16} /> Delete
|
||||
</button>
|
||||
{confirming && <Modal title="Delete article?" onClose={() => setConfirming(false)} busy={busy}>
|
||||
<p>“{post.title}” will be removed from the journal. This cannot be undone.</p>
|
||||
<p>“{post.title}” and its uploaded files will be deleted. Files used by another article will be kept. This cannot be undone.</p>
|
||||
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button autoFocus className="button" type="button" disabled={busy} onClick={() => setConfirming(false)}>Cancel</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { mediaUrl } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import AttachmentList from "./AttachmentList";
|
||||
|
||||
const imageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
const isImage = (file) => imageTypes.includes(file.type) || (!file.type && /\.(jpe?g|png|webp|gif)$/i.test(file.name));
|
||||
@@ -147,17 +148,7 @@ export default function ArticleUploads({ banner, attachments, busy, title, subti
|
||||
<div className="upload-section">
|
||||
<div className="upload-section__heading"><div><h3>Additional photos & files</h3><p>Shown below your article, in this order.</p></div><span className="upload-label">{attachmentCount}/10</span></div>
|
||||
<Dropzone purpose="attachment" disabled={busy || attachmentCount >= 10} onFiles={addFiles} />
|
||||
{attachments.length > 0 && <ul className="upload-list">
|
||||
{attachments.map((file, index) => <li key={file.url}>
|
||||
<span className="upload-file-icon">{file.media_type.startsWith("image/") ? <img src={mediaUrl(file)} alt="" /> : <Icon name="file" size={24} />}</span>
|
||||
<div className="upload-file-info"><strong>{file.name}</strong><small>{fileSize(file.size)} · <span className="upload-ready">Ready</span></small></div>
|
||||
<div className="upload-file-actions">
|
||||
<button type="button" className="upload-icon-button" disabled={index === 0} aria-label={`Move ${file.name} up`} title="Move up" onClick={() => onMoveAttachment(file.url, -1)}><Icon name="up" size={17} /></button>
|
||||
<button type="button" className="upload-icon-button" disabled={index === attachments.length - 1} aria-label={`Move ${file.name} down`} title="Move down" onClick={() => onMoveAttachment(file.url, 1)}><Icon name="down" size={17} /></button>
|
||||
<button type="button" className="upload-icon-button" aria-label={`Remove ${file.name}`} title="Remove file" onClick={() => onRemoveAttachment(file.url)}><Icon name="close" size={17} /></button>
|
||||
</div>
|
||||
</li>)}
|
||||
</ul>}
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} busy={busy} onMove={onMoveAttachment} onRemove={onRemoveAttachment} formatSize={fileSize} />}
|
||||
</div>
|
||||
|
||||
{messages.length > 0 && <div className="upload-feedback" role="alert"><strong>Some files could not be added</strong><ul>{messages.map((message, index) => <li key={index}>{message}</li>)}</ul><button type="button" className="text-button" onClick={() => setMessages([])}>Dismiss</button></div>}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { mediaUrl } from "../api";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const verticalDrag = ({ transform }) => ({ ...transform, x: 0 });
|
||||
|
||||
function SortableAttachment({ file, busy, canSort, sorting, onRemove, formatSize }) {
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: file.url, disabled: !canSort, transition: { duration: 180, easing: "ease" },
|
||||
});
|
||||
return <li ref={setNodeRef} className={`upload-sortable ${isDragging ? "is-sorting" : ""} ${!canSort ? "is-disabled" : ""}`}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onMouseDown={(event) => {
|
||||
// Keep touch scrolling available on the row, with dragging on the handle.
|
||||
if (!event.target.closest("button")) listeners?.onMouseDown?.(event);
|
||||
}}>
|
||||
<button ref={setActivatorNodeRef} type="button" className="upload-drag-handle"
|
||||
{...attributes} {...listeners} disabled={!canSort} aria-label={`Reorder ${file.name}`}
|
||||
title="Drag to reorder. Or press Space, use arrow keys, then Space to drop.">
|
||||
<Icon name="grip" size={18} />
|
||||
</button>
|
||||
<span className="upload-file-icon">{file.media_type.startsWith("image/") ? <img src={mediaUrl(file)} alt="" draggable={false} /> : <Icon name="file" size={24} />}</span>
|
||||
<div className="upload-file-info"><strong>{file.name}</strong><small>{formatSize(file.size)} · <span className="upload-ready">Ready</span></small></div>
|
||||
<button type="button" className="upload-icon-button" disabled={busy || sorting}
|
||||
aria-label={`Remove ${file.name}`} title="Remove file" onClick={() => onRemove(file.url)}><Icon name="close" size={17} /></button>
|
||||
</li>;
|
||||
}
|
||||
|
||||
export default function AttachmentList({ attachments, busy, onMove, onRemove, formatSize }) {
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, scrollBehavior: "auto" }),
|
||||
);
|
||||
const describe = (id) => attachments.find((file) => file.url === id)?.name ?? "File";
|
||||
const position = (id) => attachments.findIndex((file) => file.url === id) + 1;
|
||||
return <>
|
||||
{attachments.length > 1 && <p className="upload-tip upload-sort-hint">Drag to reorder. Keyboard: <kbd>Space</kbd> to pick up, <kbd>↑</kbd>/<kbd>↓</kbd> to move, <kbd>Space</kbd> to drop.</p>}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} modifiers={[verticalDrag]}
|
||||
accessibility={{
|
||||
screenReaderInstructions: { draggable: "Press Space to pick up a file, arrow keys to move it, and Space to drop. Press Escape to cancel." },
|
||||
announcements: {
|
||||
onDragStart: ({ active }) => `Picked up ${describe(active.id)}, position ${position(active.id)} of ${attachments.length}.`,
|
||||
onDragOver: ({ active, over }) => over ? `${describe(active.id)}, position ${position(over.id)} of ${attachments.length}.` : undefined,
|
||||
onDragEnd: ({ active, over }) => over ? `Dropped ${describe(active.id)} at position ${position(over.id)} of ${attachments.length}.` : "Reordering canceled.",
|
||||
onDragCancel: () => "Reordering canceled. File order unchanged.",
|
||||
},
|
||||
}}
|
||||
onDragStart={({ active }) => setActiveId(active.id)}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
onDragEnd={({ active, over }) => {
|
||||
setActiveId(null);
|
||||
if (!busy && over && active.id !== over.id) onMove(active.id, over.id);
|
||||
}}>
|
||||
<SortableContext items={attachments.map((file) => file.url)} strategy={verticalListSortingStrategy}>
|
||||
<ul className="upload-list upload-sortable-list" aria-label="Attachment order">
|
||||
{attachments.map((file) => <SortableAttachment key={file.url} file={file} formatSize={formatSize}
|
||||
busy={busy} canSort={!busy && attachments.length > 1} sorting={activeId !== null} onRemove={onRemove} />)}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const paths = {
|
||||
grip: <path d="M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" strokeWidth="3" />,
|
||||
upload: <path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5" />,
|
||||
photo: <><rect x="3" y="3" width="18" height="18" rx="3" /><circle cx="8" cy="8" r="1.5" /><path d="m3 17 6-6 4 4 3-3 5 5" /></>,
|
||||
file: <><path d="M14 2H5v20h14V7l-5-5Zm0 0v6h5M8 13h8M8 17h5" /></>,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { createExperience, updateExperience, createSkillCategory, updateSkillCategory, updateSkillsOverview } from "../api";
|
||||
import { useAdmin } from "./AdminSession";
|
||||
import Icon from "./Icon";
|
||||
import Modal from "./Modal";
|
||||
|
||||
const lines = (text) => text.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
function EditorDialog({ title, children, onClose, onSave, onSaved, submitLabel = "Save changes", destructive = false }) {
|
||||
const { token, isAdmin, openSignIn } = useAdmin();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
if (busy || !isAdmin) return;
|
||||
setBusy(true); setError("");
|
||||
try { const result = await onSave(token); onSaved(result); }
|
||||
catch (problem) { setError(problem.message); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <Modal title={title} busy={busy} onClose={onClose}>
|
||||
<form className="portfolio-editor" onSubmit={submit}>
|
||||
<fieldset disabled={busy || !isAdmin}>{children}</fieldset>
|
||||
{error && <p className="form-status form-status--error" role="alert">{error}</p>}
|
||||
{!isAdmin && <p className="form-status" role="status">Sign in again to save. Your edits are still here.</p>}
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button" disabled={busy} onClick={onClose}>Cancel</button>
|
||||
{isAdmin ? <button type="submit" className={`button ${destructive ? "button--danger" : "button--primary"}`} disabled={busy}>{busy ? "Saving…" : submitLabel}</button>
|
||||
: <button type="button" className="button button--primary" onClick={openSignIn}>Sign in</button>}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
export function ContentActions({ label, onEdit, onDelete }) {
|
||||
return <div className="portfolio-item-actions">
|
||||
<button type="button" className="text-button" aria-label={`Edit ${label}`} onClick={onEdit}><Icon name="edit" size={15} /> Edit</button>
|
||||
<button type="button" className="text-button text-button--danger" aria-label={`Delete ${label}`} onClick={onDelete}><Icon name="trash" size={15} /> Delete</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function DeleteContentDialog({ label, onDelete, onClose, onDeleted }) {
|
||||
return <EditorDialog title="Delete this entry?" destructive submitLabel="Delete entry" onClose={onClose} onSave={onDelete} onSaved={onDeleted}>
|
||||
<p>“{label}” will be removed from the website. This cannot be undone.</p>
|
||||
</EditorDialog>;
|
||||
}
|
||||
|
||||
function TagEditor({ label, items, onChange, limit }) {
|
||||
const [text, setText] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
function add() {
|
||||
const value = text.trim();
|
||||
if (!value) return;
|
||||
if (items.some((item) => item.toLowerCase() === value.toLowerCase())) { setMessage("Already added."); return; }
|
||||
if (items.length >= limit) { setMessage(`You can add up to ${limit} labels.`); return; }
|
||||
onChange([...items, value]); setText(""); setMessage("");
|
||||
}
|
||||
return <div className="portfolio-tags-editor">
|
||||
<label className="publisher-field"><span>{label}</span>
|
||||
<input value={text} maxLength={100} placeholder="Type a label and press Enter" onChange={(event) => { setText(event.target.value); setMessage(""); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); add(); } }} />
|
||||
</label>
|
||||
<div className="portfolio-tag-list">{items.map((item) => <span key={item}>{item}<button type="button" aria-label={`Remove ${item}`} onClick={() => onChange(items.filter((value) => value !== item))}><Icon name="close" size={13} /></button></span>)}</div>
|
||||
<div className="portfolio-tag-help"><small>Press Enter to add. Use × to remove.</small><button type="button" className="text-button" onClick={add}>Add label</button></div>
|
||||
{message && <small role="status">{message}</small>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ExperienceEditor({ item, onClose, onSaved }) {
|
||||
const [value, setValue] = useState(() => ({ company: item?.company ?? "", role: item?.role ?? "", location: item?.location ?? "", start: item?.start ?? "", end: item?.end ?? "", summary: item?.summary ?? "", technologies: item?.technologies ?? [], projects: item?.projects ?? [] }));
|
||||
const [current, setCurrent] = useState(!item?.end);
|
||||
const [highlights, setHighlights] = useState((item?.highlights ?? []).join("\n"));
|
||||
const field = (name) => ({ value: value[name], onChange: (event) => setValue((before) => ({ ...before, [name]: event.target.value })) });
|
||||
const projectField = (index, name, text) => setValue((before) => ({ ...before, projects: before.projects.map((project, i) => i === index ? { ...project, [name]: text } : project) }));
|
||||
function save(token) {
|
||||
if (!current && value.end < value.start) throw new Error("End date must be on or after the start date.");
|
||||
const payload = { ...value, end: current ? null : value.end, highlights: lines(highlights) };
|
||||
return item ? updateExperience(item.id, payload, token) : createExperience(payload, token);
|
||||
}
|
||||
return <EditorDialog title={item ? "Edit experience" : "Add experience"} onClose={onClose} onSave={save} onSaved={onSaved} submitLabel={item ? "Save changes" : "Add experience"}>
|
||||
<label className="publisher-field"><span>Role</span><input autoFocus required maxLength={200} {...field("role")} /></label>
|
||||
<div className="portfolio-form-grid">
|
||||
<label className="publisher-field"><span>Company</span><input required maxLength={100} {...field("company")} /></label>
|
||||
<label className="publisher-field"><span>Location</span><input required maxLength={200} {...field("location")} /></label>
|
||||
<label className="publisher-field"><span>Start date</span><input type="month" required {...field("start")} /></label>
|
||||
<label className="publisher-field"><span>End date</span><input type="month" min={value.start || undefined} required={!current} disabled={current} {...field("end")} /></label>
|
||||
</div>
|
||||
<label className="portfolio-checkbox"><input type="checkbox" checked={current} onChange={(event) => setCurrent(event.target.checked)} /> I currently work here</label>
|
||||
<label className="publisher-field"><span>Summary</span><textarea aria-label="Summary" required rows={4} maxLength={4000} {...field("summary")} /></label>
|
||||
<label className="publisher-field"><span>Highlights — one per line</span><textarea aria-label="Highlights" rows={5} value={highlights} onChange={(event) => setHighlights(event.target.value)} /></label>
|
||||
<TagEditor label="Technologies" items={value.technologies} limit={60} onChange={(technologies) => setValue((before) => ({ ...before, technologies }))} />
|
||||
<div className="portfolio-projects">
|
||||
<div className="portfolio-section-heading"><strong>Project links</strong><button type="button" className="text-button" disabled={value.projects.length >= 20} onClick={() => setValue((before) => ({ ...before, projects: [...before.projects, { name: "", href: "", period: "" }] }))}><Icon name="plus" size={15} /> Add project</button></div>
|
||||
{value.projects.map((project, index) => <div className="portfolio-project" key={index}>
|
||||
<label className="publisher-field"><span>Project name</span><input required maxLength={100} value={project.name} onChange={(event) => projectField(index, "name", event.target.value)} /></label>
|
||||
<label className="publisher-field"><span>Project URL</span><input required type="url" placeholder="https://" value={project.href} onChange={(event) => projectField(index, "href", event.target.value)} /></label>
|
||||
<label className="publisher-field"><span>Project period (optional)</span><input maxLength={100} value={project.period} onChange={(event) => projectField(index, "period", event.target.value)} /></label>
|
||||
<button type="button" className="text-button text-button--danger" onClick={() => setValue((before) => ({ ...before, projects: before.projects.filter((_, i) => i !== index) }))}>Remove project</button>
|
||||
</div>)}
|
||||
</div>
|
||||
</EditorDialog>;
|
||||
}
|
||||
|
||||
export function SkillCategoryEditor({ item, onClose, onSaved }) {
|
||||
const [value, setValue] = useState({ name: item?.name ?? "", description: item?.description ?? "", accent: item?.accent ?? "mint", items: item?.items ?? [] });
|
||||
const field = (name) => ({ value: value[name], onChange: (event) => setValue((before) => ({ ...before, [name]: event.target.value })) });
|
||||
return <EditorDialog title={item ? "Edit skill category" : "Add skill category"} onClose={onClose} onSave={(token) => item ? updateSkillCategory(item.id, value, token) : createSkillCategory(value, token)} onSaved={onSaved} submitLabel={item ? "Save changes" : "Add category"}>
|
||||
<label className="publisher-field"><span>Category name</span><input autoFocus required maxLength={100} {...field("name")} /></label>
|
||||
<label className="publisher-field"><span>Description</span><textarea aria-label="Description" required rows={3} maxLength={1000} {...field("description")} /></label>
|
||||
<label className="publisher-field"><span>Card color</span><select {...field("accent")}>{["mint", "blue", "lavender", "peach", "yellow"].map((color) => <option key={color} value={color}>{color[0].toUpperCase() + color.slice(1)}</option>)}</select></label>
|
||||
<TagEditor label="Skills" items={value.items} limit={100} onChange={(items) => setValue((before) => ({ ...before, items }))} />
|
||||
</EditorDialog>;
|
||||
}
|
||||
|
||||
export function SkillsOverviewEditor({ skills, onClose, onSaved }) {
|
||||
const [intro, setIntro] = useState(skills.intro);
|
||||
const [principles, setPrinciples] = useState(skills.principles.join("\n"));
|
||||
return <EditorDialog title="Edit skills overview" onClose={onClose} onSave={(token) => updateSkillsOverview({ intro, principles: lines(principles) }, token)} onSaved={onSaved}>
|
||||
<label className="publisher-field"><span>Introduction</span><textarea aria-label="Introduction" autoFocus required rows={4} maxLength={2000} value={intro} onChange={(event) => setIntro(event.target.value)} /></label>
|
||||
<label className="publisher-field"><span>How I work — one principle per line</span><textarea aria-label="How I work" rows={6} value={principles} onChange={(event) => setPrinciples(event.target.value)} /></label>
|
||||
</EditorDialog>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2855,6 +2855,18 @@ button {
|
||||
.upload-icon-button { display: grid; place-items: center; width: 34px; height: 36px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--ink-soft); cursor: pointer; }
|
||||
.upload-icon-button:hover { background: var(--mint); color: var(--sage-deep); }
|
||||
.upload-icon-button:disabled { opacity: 0.25; }
|
||||
.upload-sortable-list { position: relative; isolation: isolate; }
|
||||
.upload-list .upload-sortable { position: relative; cursor: grab; user-select: none; padding-left: 4px; gap: 8px; }
|
||||
.upload-list .upload-sortable.is-disabled { cursor: default; }
|
||||
.upload-list .upload-sortable.is-sorting { z-index: 2; cursor: grabbing; border-color: var(--sage-deep); background: #f2f7f3; box-shadow: 0 8px 22px rgba(35, 65, 53, .14); }
|
||||
.upload-drag-handle { display: grid; place-items: center; flex: 0 0 32px; width: 32px; height: 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--ink-soft); cursor: grab; touch-action: none; }
|
||||
.upload-drag-handle:hover, .upload-drag-handle:focus-visible { color: var(--sage-deep); background: var(--mint); }
|
||||
.upload-drag-handle:active { cursor: grabbing; }
|
||||
.upload-drag-handle:disabled { opacity: .25; cursor: default; }
|
||||
.upload-sortable > .upload-icon-button { flex-shrink: 0; }
|
||||
.upload-sort-hint kbd { font: inherit; color: var(--sage-deep); }
|
||||
@media (pointer: coarse) { .upload-drag-handle { flex-basis: 44px; width: 44px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .upload-list .upload-sortable { transition: none !important; } }
|
||||
.upload-queue { display: grid; gap: 12px; }
|
||||
.upload-file-info progress { width: 100%; height: 6px; margin-top: 4px; accent-color: var(--sage-deep); }
|
||||
.upload-file-info p { margin: 0; color: #9a3434; font-size: 11px; line-height: 1.6; }
|
||||
@@ -2871,6 +2883,8 @@ button {
|
||||
.upload-file-actions { margin-left: auto; }
|
||||
.upload-file-info { flex-basis: calc(100% - 54px); }
|
||||
.upload-icon-button { width: 40px; height: 40px; }
|
||||
.upload-list .upload-sortable { flex-wrap: nowrap; }
|
||||
.upload-sortable .upload-file-info { flex-basis: auto; }
|
||||
.upload-saved-banner { align-items: flex-start; }
|
||||
}
|
||||
.article-attachments { margin-top: 55px; padding-top: 48px; border-top: 1px solid var(--line); }
|
||||
@@ -2919,6 +2933,32 @@ button {
|
||||
.admin-signin p, .admin-account p { margin: 0; }
|
||||
.admin-signin .publisher-field { margin: 0; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
|
||||
.admin-dialog:has(.portfolio-editor) { width: min(680px, calc(100vw - 32px)); }
|
||||
.portfolio-editor > fieldset { display: grid; gap: 20px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.portfolio-editor .publisher-field { min-width: 0; }
|
||||
.portfolio-editor textarea { resize: vertical; }
|
||||
.portfolio-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.portfolio-checkbox { display: flex; align-items: center; gap: 10px; font-size: 14px; }
|
||||
.portfolio-checkbox input { accent-color: var(--sage-deep); }
|
||||
.portfolio-projects, .portfolio-tags-editor { display: grid; gap: 12px; }
|
||||
.portfolio-project { display: grid; gap: 12px; border: 1px solid var(--line); border-radius: 14px; padding: 16px; }
|
||||
.portfolio-section-heading, .portfolio-tag-help { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.portfolio-tag-list { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.portfolio-tag-list > span { display: inline-flex; align-items: center; gap: 6px; padding: 5px 8px 5px 12px; border-radius: 20px; background: var(--mint); color: var(--sage-deep); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.portfolio-tag-list button { display: grid; place-items: center; flex-shrink: 0; width: 26px; height: 26px; background: transparent; color: inherit; border: 0; border-radius: 50%; cursor: pointer; }
|
||||
.portfolio-tag-list button:hover { background: rgba(35, 65, 53, .08); }
|
||||
.portfolio-tag-help small { color: var(--ink-soft); }
|
||||
.portfolio-admin-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 30px; padding: 20px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
|
||||
.portfolio-admin-toolbar p { margin: 0; color: var(--ink-soft); font-size: 14px; }
|
||||
.portfolio-admin-toolbar > div { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.portfolio-item-actions { display: flex; flex-wrap: wrap; gap: 20px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.timeline-card > .portfolio-item-actions { margin: 0; padding: 14px 24px; border-top: 0; border-bottom: 1px solid var(--line); }
|
||||
.portfolio-item-actions .text-button { font-size: 12px; }
|
||||
@media (max-width: 520px) {
|
||||
.portfolio-form-grid { grid-template-columns: 1fr; }
|
||||
.portfolio-section-heading, .portfolio-tag-help { align-items: flex-start; flex-wrap: wrap; }
|
||||
.portfolio-admin-toolbar .button { flex: 1; }
|
||||
}
|
||||
.article-admin-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; }
|
||||
.post-card:has(.article-admin-actions) { display: flex; flex-direction: column; }
|
||||
.post-card:has(.article-admin-actions) > a { height: auto; flex: 1; }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAdmin } from "./components/AdminSession";
|
||||
|
||||
export default function usePortfolioResource(fetchContent) {
|
||||
const { sessionRevision } = useAdmin();
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState({ data: null, loading: true, error: "" });
|
||||
const reload = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setState((current) => ({ ...current, loading: true, error: "" }));
|
||||
fetchContent(controller.signal).then((data) => {
|
||||
if (!controller.signal.aborted) setState({ data, loading: false, error: "" });
|
||||
}).catch((error) => {
|
||||
if (!controller.signal.aborted) setState((current) => ({ ...current, loading: false, error: error.message }));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [fetchContent, sessionRevision, attempt]);
|
||||
useEffect(() => {
|
||||
if (!state.error) return;
|
||||
window.addEventListener("online", reload);
|
||||
window.addEventListener("focus", reload);
|
||||
return () => { window.removeEventListener("online", reload); window.removeEventListener("focus", reload); };
|
||||
}, [state.error, reload]);
|
||||
const setData = useCallback((update) => setState((current) => ({ ...current, data: typeof update === "function" ? update(current.data) : update, error: "" })), []);
|
||||
return { ...state, reload, setData };
|
||||
}
|
||||
Reference in New Issue
Block a user