From a06830281dbb6679b494147ce47f0e6e47fa0e47 Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Wed, 18 Mar 2026 14:33:21 -0400 Subject: [PATCH] Add robust themes, drag-drop layout, and manual screenshot controls --- server.js | 2 +- src/App.css | 308 +++++++++++- src/App.tsx | 1077 +++++++++++++++++++++++++++++++++++++----- src/data/projects.ts | 1 + src/index.css | 108 +++++ 5 files changed, 1375 insertions(+), 121 deletions(-) diff --git a/server.js b/server.js index 0e94c65..2fba5b0 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,7 @@ const DIST_DIR = path.join(__dirname, 'dist') const INDEX_FILE = path.join(DIST_DIR, 'index.html') const app = express() -app.use(express.json({ limit: '2mb' })) +app.use(express.json({ limit: '10mb' })) app.get('/api/admin-content', async (_req, res) => { try { diff --git a/src/App.css b/src/App.css index 82f012e..b47ed3c 100644 --- a/src/App.css +++ b/src/App.css @@ -9,11 +9,12 @@ .hero { border: 1px solid var(--line); - border-radius: 24px; + border-radius: var(--radius-panel); padding: 2rem; background: radial-gradient(circle at 90% 0%, var(--hero-glow) 0%, rgba(247, 216, 166, 0) 46%), linear-gradient(135deg, var(--surface-soft) 0%, var(--bg-bottom) 100%); + box-shadow: var(--shadow-elevated); animation: reveal 480ms ease-out both; } @@ -86,9 +87,10 @@ h1 { .stats article { border: 1px solid var(--line); - border-radius: 14px; + border-radius: var(--radius-tile); padding: 0.9rem 1rem; background: var(--surface); + box-shadow: var(--shadow); } .stats p { @@ -105,6 +107,29 @@ h1 { line-height: 1.25; } +.home-layout { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(260px, 0.9fr); + gap: 1rem; + align-items: start; +} + +.home-main, +.home-sidebar { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.home-block { + margin: 0; +} + +.home-sidebar .grid, +.home-sidebar .stats { + grid-template-columns: 1fr; +} + .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -113,12 +138,29 @@ h1 { .card { border: 1px solid var(--line); - border-radius: 18px; + border-radius: var(--radius-card); padding: 1rem; background: var(--surface); + box-shadow: var(--shadow); + transition: box-shadow 220ms ease, transform 220ms ease; animation: reveal 500ms ease-out both; } +.card:hover { + box-shadow: var(--shadow-elevated); + transform: translateY(-2px); +} + +.project-thumbnail { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border: 1px solid var(--line); + border-radius: var(--radius-note); + background: var(--surface-soft); + margin-bottom: 0.75rem; +} + .card h2 { margin: 0.65rem 0 0.4rem; font-family: var(--display); @@ -209,7 +251,7 @@ h1 { .stack li { padding: 0.28rem 0.5rem; background: var(--chip-bg); - border-radius: 8px; + border-radius: var(--radius-chip); font-size: 0.8rem; } @@ -242,7 +284,7 @@ h1 { .notes { border: 1px dashed var(--line); - border-radius: 12px; + border-radius: var(--radius-note); padding: 1rem; background: var(--surface-soft); } @@ -257,6 +299,40 @@ h1 { color: var(--ink-muted); } +.downloads-grid { + margin-top: 0.9rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.8rem; +} + +.download-card { + border: 1px solid var(--line); + border-radius: var(--radius-note); + padding: 0.9rem; + background: var(--surface); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.download-card h4 { + margin: 0; + font-family: var(--display); +} + +.download-card a { + width: fit-content; + text-decoration: none; + color: var(--accent); + font-weight: 700; +} + +.download-card a:hover { + text-decoration: underline; +} + .readme-panel a { margin-top: 0.7rem; display: inline-block; @@ -323,9 +399,10 @@ h1 { .detail-card { border: 1px solid var(--line); - border-radius: 14px; + border-radius: var(--radius-tile); background: var(--surface); padding: 1rem; + box-shadow: var(--shadow); } .detail-card h3 { @@ -343,6 +420,52 @@ h1 { font-size: 0.9rem; } +.admin-notice { + border: 1px solid var(--line); + border-radius: var(--radius-tile); + padding: 0.9rem 1rem; + background: var(--surface); +} + +.admin-notice p { + margin: 0; + font-weight: 600; +} + +.admin-notice.success { + border-color: #9fd0ae; + background: #edf8f0; + color: #145a32; +} + +.admin-notice.error { + border-color: #d9a69a; + background: #fff1ec; + color: #8c2c13; +} + +.admin-tabs { + display: flex; + gap: 0.55rem; + flex-wrap: wrap; +} + +.admin-tab { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.55rem 0.95rem; + background: var(--surface); + color: var(--ink); + font-weight: 700; + cursor: pointer; +} + +.admin-tab.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + .admin-panel { background: var(--surface); } @@ -363,7 +486,7 @@ h1 { .admin-input { width: 100%; border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-input); padding: 0.55rem 0.7rem; font: inherit; color: var(--ink); @@ -375,6 +498,87 @@ h1 { resize: vertical; } +.admin-checkbox-row { + display: flex; + align-items: center; + gap: 0.65rem; + color: var(--ink); +} + +.layout-manager { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.layout-manager h4 { + margin: 0; + font-family: var(--display); +} + +.layout-manager p { + margin: 0; +} + +.layout-row { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.85rem; + background: var(--surface-soft); + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + cursor: grab; +} + +.layout-row:active { + cursor: grabbing; +} + +.layout-row strong { + display: block; + color: var(--ink); +} + +.layout-row p { + margin-top: 0.2rem; +} + +.layout-select { + min-width: 10rem; +} + +.layout-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.drag-hint { + display: inline-block; + margin-bottom: 0.25rem; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.drag-pill { + display: inline-flex; + align-items: center; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.35rem 0.65rem; + background: var(--surface); + color: var(--ink-muted); + font-size: 0.8rem; + white-space: nowrap; +} + .scan-row { display: flex; flex-direction: column; @@ -430,6 +634,84 @@ h1 { border-color: var(--accent); } +.downloads-editor { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +.downloads-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; + flex-wrap: wrap; +} + +.downloads-editor-header h4 { + margin: 0; + font-family: var(--display); +} + +.download-editor-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.9rem; + background: var(--surface-soft); + display: grid; + gap: 0.5rem; +} + +.admin-secondary-button { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.5rem 0.8rem; + background: var(--surface); + color: var(--ink); + font-weight: 600; + cursor: pointer; + width: fit-content; +} + +.admin-secondary-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.thumbnail-actions { + display: flex; + gap: 0.55rem; + align-items: flex-end; + flex-wrap: wrap; +} + +.thumbnail-file-input { + width: min(100%, 320px); +} + +.admin-thumbnail-preview { + border: 1px solid var(--line); + border-radius: var(--radius-note); + padding: 0.6rem; + background: var(--surface); +} + +.admin-thumbnail-preview img { + width: 100%; + max-width: 420px; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: var(--radius-note); + border: 1px solid var(--line); +} + +.thumbnail-mode-note { + margin-top: 0.45rem; + font-size: 0.85rem; + color: var(--ink-muted); +} + code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background: #f0ece6; @@ -471,6 +753,18 @@ code { grid-template-columns: 1fr; } + .home-layout { + grid-template-columns: 1fr; + } + + .layout-row { + grid-template-columns: 1fr; + } + + .layout-actions { + justify-content: flex-start; + } + .stats article:last-child { grid-column: auto; } diff --git a/src/App.tsx b/src/App.tsx index 90ec1d1..9f26245 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import type { ChangeEvent } from 'react' import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -9,7 +10,22 @@ import type { Project } from './data/projects' const THEME_STORAGE_KEY = 'portfolio-theme-v1' const ADMIN_CONTENT_API = '/api/admin-content' -type ThemeName = 'sand' | 'ocean' | 'midnight' | 'forest' | 'sunset' +type ThemeName = 'sand' | 'ocean' | 'midnight' | 'forest' | 'sunset' | 'rose' | 'slate' | 'aurora' | 'amethyst' + +type HomeSectionId = 'summary' | 'projects' | 'quickTips' | 'downloads' + +type HomeSectionPlacement = 'main' | 'sidebar' + +type HomeSectionConfig = { + id: HomeSectionId + placement: HomeSectionPlacement +} + +type DownloadItem = { + title: string + url: string + description: string +} type SiteContent = { theme: ThemeName @@ -19,6 +35,25 @@ type SiteContent = { projectEyebrow: string quickTipsTitle: string quickTipsBody: string + downloadsTitle: string + downloadsIntro: string + downloads: DownloadItem[] + showSidebar: boolean + homeSections: HomeSectionConfig[] +} + +const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [ + { id: 'summary', placement: 'main' }, + { id: 'projects', placement: 'main' }, + { id: 'quickTips', placement: 'main' }, + { id: 'downloads', placement: 'main' }, +] + +const HOME_SECTION_LABELS: Record = { + summary: 'Project summary', + projects: 'Project cards', + quickTips: 'Quick tips', + downloads: 'Downloads', } const defaultSiteContent: SiteContent = { @@ -31,6 +66,18 @@ const defaultSiteContent: SiteContent = { quickTipsTitle: 'Quick edit tips', quickTipsBody: 'Open /admin to update card and featured page content. Changes are saved in your project data file via the API server.', + downloadsTitle: 'Downloads', + downloadsIntro: + 'Add shared files, installers, or documents here. Seafile share links work well for this section.', + downloads: [ + { + title: 'Shared File', + url: 'https://seafile.necloud.us', + description: 'Replace this with a direct Seafile share link or another download URL.', + }, + ], + showSidebar: false, + homeSections: DEFAULT_HOME_SECTIONS, } type ScanResult = { @@ -89,6 +136,7 @@ function createEmptyProject(existing: Project[]): Project { title: 'New Project', domain: 'new-project.necloud.us', url: 'https://new-project.necloud.us', + thumbnailUrl: '', readmeUrl: '', category: 'Custom', access: 'Public', @@ -110,6 +158,87 @@ function createEmptyProject(existing: Project[]): Project { } } +function getAutoThumbnailUrl(input: string): string { + const normalized = normalizeUrl(input) + return `https://image.thum.io/get/width/1200/noanimate/${normalized}` +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + return + } + + reject(new Error('Could not read image data')) + } + reader.onerror = () => reject(new Error('Could not read image data')) + reader.readAsDataURL(file) + }) +} + +function createDownloadItem(existing: DownloadItem[]): DownloadItem { + return { + title: `Download ${existing.length + 1}`, + url: 'https://seafile.necloud.us', + description: 'What this file is for and who should use it.', + } +} + +function normalizeHomeSections(value: unknown): HomeSectionConfig[] { + if (!Array.isArray(value)) { + return DEFAULT_HOME_SECTIONS + } + + const seen = new Set() + const normalized: HomeSectionConfig[] = [] + + for (const item of value) { + const id = item?.id + const placement = item?.placement + + if ( + (id === 'summary' || id === 'projects' || id === 'quickTips' || id === 'downloads') && + !seen.has(id) + ) { + normalized.push({ + id, + placement: placement === 'sidebar' ? 'sidebar' : 'main', + }) + seen.add(id) + } + } + + for (const item of DEFAULT_HOME_SECTIONS) { + if (!seen.has(item.id)) { + normalized.push(item) + } + } + + return normalized +} + +function reorderItemsById( + items: T[], + draggedId: string, + targetId: string, + getId: (item: T) => string, +): T[] { + const draggedIndex = items.findIndex((item) => getId(item) === draggedId) + const targetIndex = items.findIndex((item) => getId(item) === targetId) + + if (draggedIndex === -1 || targetIndex === -1 || draggedIndex === targetIndex) { + return items + } + + const copy = [...items] + const [draggedItem] = copy.splice(draggedIndex, 1) + copy.splice(targetIndex, 0, draggedItem) + return copy +} + function readStoredTheme(): ThemeName { const saved = localStorage.getItem(THEME_STORAGE_KEY) if ( @@ -117,7 +246,11 @@ function readStoredTheme(): ThemeName { saved === 'midnight' || saved === 'sand' || saved === 'forest' || - saved === 'sunset' + saved === 'sunset' || + saved === 'rose' || + saved === 'slate' || + saved === 'aurora' || + saved === 'amethyst' ) { return saved } @@ -155,7 +288,11 @@ async function fetchAdminContent(): Promise< payload.siteContent.theme === 'midnight' || payload.siteContent.theme === 'sand' || payload.siteContent.theme === 'forest' || - payload.siteContent.theme === 'sunset' + payload.siteContent.theme === 'sunset' || + payload.siteContent.theme === 'rose' || + payload.siteContent.theme === 'slate' || + payload.siteContent.theme === 'aurora' || + payload.siteContent.theme === 'amethyst' ? payload.siteContent.theme : defaultSiteContent.theme, homeEyebrow: payload.siteContent.homeEyebrow ?? defaultSiteContent.homeEyebrow, @@ -164,6 +301,28 @@ async function fetchAdminContent(): Promise< projectEyebrow: payload.siteContent.projectEyebrow ?? defaultSiteContent.projectEyebrow, quickTipsTitle: payload.siteContent.quickTipsTitle ?? defaultSiteContent.quickTipsTitle, quickTipsBody: payload.siteContent.quickTipsBody ?? defaultSiteContent.quickTipsBody, + downloadsTitle: payload.siteContent.downloadsTitle ?? defaultSiteContent.downloadsTitle, + downloadsIntro: payload.siteContent.downloadsIntro ?? defaultSiteContent.downloadsIntro, + downloads: + Array.isArray(payload.siteContent.downloads) && payload.siteContent.downloads.length > 0 + ? payload.siteContent.downloads.map((item) => ({ + title: + typeof item?.title === 'string' && item.title.trim() + ? item.title + : 'Download', + url: + typeof item?.url === 'string' && item.url.trim() + ? item.url + : 'https://seafile.necloud.us', + description: + typeof item?.description === 'string' ? item.description : '', + })) + : defaultSiteContent.downloads, + showSidebar: + typeof payload.siteContent.showSidebar === 'boolean' + ? payload.siteContent.showSidebar + : defaultSiteContent.showSidebar, + homeSections: normalizeHomeSections(payload.siteContent.homeSections), }, } } catch { @@ -172,11 +331,15 @@ async function fetchAdminContent(): Promise< } async function persistAdminContent(projects: Project[], siteContent: SiteContent) { - await fetch(ADMIN_CONTENT_API, { + const response = await fetch(ADMIN_CONTENT_API, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projects, siteContent }), }) + + if (!response.ok) { + throw new Error('Failed to save admin content') + } } function normalizeUrl(input: string): string { @@ -378,6 +541,132 @@ function HomePage({ return projectList.filter((project) => project.ownership === activeFilter) }, [activeFilter, projectList]) + const getProjectThumbnailUrl = (project: Project): string => { + if (project.thumbnailUrl?.trim()) { + return project.thumbnailUrl + } + + try { + return getAutoThumbnailUrl(project.url) + } catch { + return '' + } + } + + const renderHomeSection = (sectionId: HomeSectionId) => { + if (sectionId === 'summary') { + return ( +
+
+

Total

+ {projectList.length} +
+
+

Built

+ {projectList.filter((project) => project.ownership === 'built').length} +
+
+

Hosted

+ {projectList.filter((project) => project.ownership === 'hosted').length} +
+
+ ) + } + + if (sectionId === 'projects') { + return ( +
+ {filteredProjects.map((project) => ( +
+ {getProjectThumbnailUrl(project) ? ( + {`${project.title} { + event.currentTarget.style.display = 'none' + }} + /> + ) : null} + +
+ {project.domain} + {project.status} +
+ +

+ {project.iconUrl ? ( + + ) : ( + + )} + {project.title} +

+

{project.summary}

+ +
    +
  • {project.ownership === 'built' ? 'Built' : 'Hosted'}
  • +
  • {project.category}
  • +
  • {project.access}
  • +
  • NeCloud
  • + {project.scanSource === 'manual' ?
  • Manual metadata
  • : null} +
+ +
+ {project.ownership === 'built' ? 'Built by me' : 'Hosted by me'} +
+ About + + Open Site + +
+
+
+ ))} +
+ ) + } + + if (sectionId === 'quickTips') { + return ( +
+

{siteContent.quickTipsTitle}

+

{siteContent.quickTipsBody}

+
+ ) + } + + return ( +
+

{siteContent.downloadsTitle}

+

{siteContent.downloadsIntro}

+
+ {siteContent.downloads.map((item, index) => ( + + ))} +
+
+ ) + } + + const orderedSections = normalizeHomeSections(siteContent.homeSections) + const mainSections = siteContent.showSidebar + ? orderedSections.filter((section) => section.placement === 'main') + : orderedSections + const sidebarSections = siteContent.showSidebar + ? orderedSections.filter((section) => section.placement === 'sidebar') + : [] + return (
@@ -411,66 +700,16 @@ function HomePage({
-
-
-

Total

- {projectList.length} -
-
-

Built

- {projectList.filter((project) => project.ownership === 'built').length} -
-
-

Hosted

- {projectList.filter((project) => project.ownership === 'hosted').length} -
-
- -
- {filteredProjects.map((project) => ( -
-
- {project.domain} - {project.status} -
- -

- {project.iconUrl ? ( - - ) : ( - - )} - {project.title} -

-

{project.summary}

- -
    -
  • {project.ownership === 'built' ? 'Built' : 'Hosted'}
  • -
  • {project.category}
  • -
  • {project.access}
  • -
  • NeCloud
  • - {project.scanSource === 'manual' ?
  • Manual metadata
  • : null} -
- -
- {project.ownership === 'built' ? 'Built by me' : 'Hosted by me'} -
- About - - Open Site - -
-
-
- ))} -
- -
-

{siteContent.quickTipsTitle}

-

{siteContent.quickTipsBody}

-
+ {siteContent.showSidebar && sidebarSections.length > 0 ? ( +
+
{mainSections.map((section) => renderHomeSection(section.id))}
+ +
+ ) : ( + orderedSections.map((section) => renderHomeSection(section.id)) + )}
) } @@ -641,6 +880,7 @@ function ProjectPage({ function AdminPage({ projectList, onSave, + onReorderProjects, onResetProject, onResetAll, onDeleteProject, @@ -652,24 +892,37 @@ function AdminPage({ onResetSiteContent, }: { projectList: Project[] - onSave: (sourceSlug: string, project: Project) => void - onResetProject: (slug: string) => void - onResetAll: () => void - onDeleteProject: (slug: string) => void - onCreateProject: () => Project + onSave: (sourceSlug: string, project: Project) => Promise + onReorderProjects: (draggedSlug: string, targetSlug: string) => Promise + onResetProject: (slug: string) => Promise + onResetAll: () => Promise + onDeleteProject: (slug: string) => Promise + onCreateProject: () => Promise theme: ThemeName - onThemeChange: (theme: ThemeName) => void + onThemeChange: (theme: ThemeName) => Promise siteContent: SiteContent - onSaveSiteContent: (siteContent: SiteContent) => void - onResetSiteContent: () => void + onSaveSiteContent: (siteContent: SiteContent) => Promise + onResetSiteContent: () => Promise }) { + type AdminTab = 'global' | 'downloads' | 'layout' | 'project' | 'featured' + const [selectedSlug, setSelectedSlug] = useState(projectList[0]?.slug ?? '') const [draft, setDraft] = useState(projectList[0] ?? null) + const [activeTab, setActiveTab] = useState('global') const [scanState, setScanState] = useState<'idle' | 'loading' | 'error' | 'success'>( 'idle', ) const [scanMessage, setScanMessage] = useState('') const [siteDraft, setSiteDraft] = useState(siteContent) + const [thumbnailMode, setThumbnailMode] = useState<'auto' | 'manual'>( + projectList[0]?.thumbnailUrl?.trim() ? 'manual' : 'auto', + ) + const [draggedSectionId, setDraggedSectionId] = useState(null) + const [draggedProjectSlug, setDraggedProjectSlug] = useState(null) + const [saveNotice, setSaveNotice] = useState<{ + tone: 'success' | 'error' + message: string + } | null>(null) const selectedProject = useMemo( () => projectList.find((project) => project.slug === selectedSlug), @@ -685,6 +938,7 @@ function AdminPage({ if (selectedProject) { setDraft(structuredClone(selectedProject)) + setThumbnailMode(selectedProject.thumbnailUrl?.trim() ? 'manual' : 'auto') setScanState('idle') setScanMessage('') } @@ -694,6 +948,25 @@ function AdminPage({ setSiteDraft(siteContent) }, [siteContent]) + const setSuccessNotice = (message: string) => { + setSaveNotice({ tone: 'success', message }) + } + + const setErrorNotice = (message: string) => { + setSaveNotice({ tone: 'error', message }) + } + + const reorderHomeSections = (draggedId: HomeSectionId, targetId: HomeSectionId) => { + if (draggedId === targetId) { + return + } + + setSiteDraft({ + ...siteDraft, + homeSections: reorderItemsById(siteDraft.homeSections, draggedId, targetId, (item) => item.id), + }) + } + const handleScanFromUrl = async () => { if (!draft) { return @@ -718,6 +991,7 @@ function AdminPage({ category: result.category, access: result.access, iconUrl: result.iconUrl, + thumbnailUrl: getAutoThumbnailUrl(result.url), scanSource: 'scanned', summary: result.summary, details: result.summary, @@ -737,6 +1011,26 @@ function AdminPage({ } } + const handleThumbnailUpload = async ( + event: ChangeEvent, + currentDraft: Project, + ) => { + const file = event.target.files?.[0] + if (!file) { + return + } + + try { + const dataUrl = await readFileAsDataUrl(file) + setDraft({ ...currentDraft, thumbnailUrl: dataUrl }) + setSuccessNotice('Thumbnail uploaded. Save project to persist it.') + } catch { + setErrorNotice('Could not read the selected image file.') + } finally { + event.target.value = '' + } + } + if (!draft) { return (
@@ -751,32 +1045,102 @@ function AdminPage({ ) } + const isManualThumbnail = thumbnailMode === 'manual' + let autoThumbnailPreview = '' + try { + autoThumbnailPreview = getAutoThumbnailUrl(draft.url) + } catch { + autoThumbnailPreview = '' + } + return (

Admin

Edit Cards and About Pages

- Select a project, edit content, then click Save project. Edits persist in your - browser and immediately update both cards and featured pages. + Use tabs to move between global settings, downloads, project basics, and project + content. Saved changes are written through the API and update the live portfolio.

Back to portfolio -
+ {saveNotice ? ( +
+

{saveNotice.message}

+
+ ) : null} + +
+ + + + + +
+ + {activeTab === 'global' ? (

Global Settings

@@ -787,13 +1151,24 @@ function AdminPage({ id="theme-select" className="admin-input" value={theme} - onChange={(event) => onThemeChange(event.target.value as ThemeName)} + onChange={async (event) => { + try { + await onThemeChange(event.target.value as ThemeName) + setSuccessNotice('Theme saved.') + } catch { + setErrorNotice('Could not save the selected theme.') + } + }} > + + + +
- -
+ ) : null} + {activeTab === 'layout' ? (
-

Project Settings

+

Homepage Layout

+
+ + +
+ +
+

Section order and placement

+

+ Drag sections into the order you want and choose whether each one sits in the + main column or the sidebar. When the sidebar is off, everything falls back into a + single column. +

+ {siteDraft.homeSections.map((section) => ( +
setDraggedSectionId(section.id)} + onDragEnd={() => setDraggedSectionId(null)} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => { + event.preventDefault() + if (!draggedSectionId) { + return + } + + reorderHomeSections(draggedSectionId, section.id) + setDraggedSectionId(null) + }} + > +
+ Drag to reorder + {HOME_SECTION_LABELS[section.id]} +
+ +
+ Drop above or below another row +
+
+ ))} +
+ +
+

Project card order

+

Drag projects into the order you want for the cards on the home page.

+ {projectList.map((project) => ( +
setDraggedProjectSlug(project.slug)} + onDragEnd={() => setDraggedProjectSlug(null)} + onDragOver={(event) => event.preventDefault()} + onDrop={async (event) => { + event.preventDefault() + if (!draggedProjectSlug || draggedProjectSlug === project.slug) { + return + } + + try { + await onReorderProjects(draggedProjectSlug, project.slug) + setSuccessNotice('Project order updated.') + } catch { + setErrorNotice('Could not reorder this project.') + } finally { + setDraggedProjectSlug(null) + } + }} + > +
+ Drag to reorder + {project.title} +

{project.domain}

+
+
+ Drop onto another project row +
+
+ ))} +
+ +
+ + +
+
+ ) : null} + + {activeTab === 'downloads' ? ( +
+

Downloads

+
+ + + setSiteDraft({ ...siteDraft, downloadsTitle: event.target.value }) + } + /> + + +