Files
Siteforge/src/App.tsx
T

1138 lines
33 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom'
import './App.css'
import { projects as baseProjects } from './data/projects'
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'
type SiteContent = {
homeEyebrow: string
homeTitle: string
homeIntro: string
projectEyebrow: string
quickTipsTitle: string
quickTipsBody: string
}
const defaultSiteContent: SiteContent = {
homeEyebrow: 'Siteforge',
homeTitle: 'Sites and Apps I Build',
homeIntro:
'Live links generated from your domain list. Use the Admin panel to update cards and About pages without editing code.',
projectEyebrow: 'Project Details',
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.',
}
type ScanResult = {
domain: string
url: string
title: string
summary: string
category: string
access: Project['access']
iconUrl?: string
highlights: string[]
stack: string[]
source: string
}
function splitLines(value: string): string[] {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean)
}
function joinLines(values: string[]): string {
return values.join('\n')
}
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function createUniqueSlug(baseSlug: string, list: Project[]): string {
const trimmed = slugify(baseSlug) || 'new-project'
const existing = new Set(list.map((item) => item.slug))
if (!existing.has(trimmed)) {
return trimmed
}
let index = 2
while (existing.has(`${trimmed}-${index}`)) {
index += 1
}
return `${trimmed}-${index}`
}
function createEmptyProject(existing: Project[]): Project {
const slug = createUniqueSlug('new-project', existing)
return {
slug,
title: 'New Project',
domain: 'new-project.necloud.us',
url: 'https://new-project.necloud.us',
category: 'Custom',
access: 'Public',
ownership: 'built',
status: 'Live',
summary: 'Short card summary for this project.',
details: 'Detailed description for this project.',
features: ['Feature one'],
scanSource: 'manual',
featured: {
headline: 'Featured page headline for this project.',
problem: 'Describe the user problem this project solves.',
solution: 'Describe how your app solves that problem.',
stack: ['React', 'TypeScript'],
highlights: ['Primary capability'],
nextSteps: ['Next improvement'],
updateNote: 'Updated from Admin.',
},
}
}
function readStoredTheme(): ThemeName {
const saved = localStorage.getItem(THEME_STORAGE_KEY)
if (saved === 'ocean' || saved === 'midnight' || saved === 'sand') {
return saved
}
return 'sand'
}
async function fetchAdminContent(): Promise<
| {
projects: Project[]
siteContent: SiteContent
}
| null
> {
try {
const response = await fetch(ADMIN_CONTENT_API)
if (!response.ok) {
return null
}
const payload = (await response.json()) as {
projects?: Project[]
siteContent?: Partial<SiteContent>
}
if (!payload.projects || !Array.isArray(payload.projects) || !payload.siteContent) {
return null
}
return {
projects: payload.projects,
siteContent: {
homeEyebrow: payload.siteContent.homeEyebrow ?? defaultSiteContent.homeEyebrow,
homeTitle: payload.siteContent.homeTitle ?? defaultSiteContent.homeTitle,
homeIntro: payload.siteContent.homeIntro ?? defaultSiteContent.homeIntro,
projectEyebrow: payload.siteContent.projectEyebrow ?? defaultSiteContent.projectEyebrow,
quickTipsTitle: payload.siteContent.quickTipsTitle ?? defaultSiteContent.quickTipsTitle,
quickTipsBody: payload.siteContent.quickTipsBody ?? defaultSiteContent.quickTipsBody,
},
}
} catch {
return null
}
}
async function persistAdminContent(projects: Project[], siteContent: SiteContent) {
await fetch(ADMIN_CONTENT_API, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projects, siteContent }),
})
}
function normalizeUrl(input: string): string {
const value = input.trim()
if (!value) {
throw new Error('URL is required')
}
if (/^https?:\/\//i.test(value)) {
return new URL(value).href
}
return new URL(`https://${value}`).href
}
function titleFromDomain(domain: string): string {
const base = domain.split('.')[0] ?? domain
return base
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function inferAccess(text: string): Project['access'] {
const lowered = text.toLowerCase()
const hasLogin = lowered.includes('log in') || lowered.includes('sign in')
const hasPublic = lowered.includes('get started') || lowered.includes('home')
if (hasLogin && hasPublic) {
return 'Mixed'
}
if (hasLogin) {
return 'Login required'
}
return 'Public'
}
function inferCategory(text: string): string {
const lowered = text.toLowerCase()
if (lowered.includes('weather') || lowered.includes('forecast')) return 'Weather'
if (lowered.includes('budget') || lowered.includes('envelope')) return 'Finance'
if (lowered.includes('bible') || lowered.includes('prayer')) return 'Faith'
if (lowered.includes('music') || lowered.includes('playlist')) return 'Music'
if (lowered.includes('recipe') || lowered.includes('meal')) return 'Food'
if (lowered.includes('file') || lowered.includes('sync') || lowered.includes('storage')) {
return 'File Tools'
}
return 'Custom'
}
function firstUsefulLine(text: string): string | null {
const lines = text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
for (const line of lines) {
if (line.startsWith('#')) continue
if (line.startsWith('![')) continue
if (line.startsWith('[')) continue
if (line.length < 30) continue
if (line.length > 220) continue
return line
}
return null
}
function uniqueItems(items: string[]): string[] {
return Array.from(new Set(items.filter(Boolean)))
}
function extractScanResult(text: string, url: string, source: string): ScanResult {
const parsed = new URL(url)
const domain = parsed.hostname
const headingMatch = text.match(/^#\s+(.+)$/m)
const title = (headingMatch?.[1]?.trim() || titleFromDomain(domain)).replace(/\s+/g, ' ')
const summary =
firstUsefulLine(text) ||
`Project page detected for ${title}. Review and refine this summary in Admin.`
const imageMatch = text.match(/!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i)
const iconUrl = imageMatch?.[1]
const lowered = text.toLowerCase()
const highlights = uniqueItems([
lowered.includes('dashboard') ? 'Dashboard' : '',
lowered.includes('playlist') ? 'Playlist support' : '',
lowered.includes('forecast') ? 'Forecast views' : '',
lowered.includes('bookmark') ? 'Bookmarks' : '',
lowered.includes('calendar') ? 'Calendar support' : '',
lowered.includes('login') ? 'Account access' : '',
])
return {
domain,
url: `${parsed.protocol}//${parsed.host}`,
title,
summary,
category: inferCategory(text),
access: inferAccess(text),
iconUrl,
highlights: highlights.length ? highlights : ['Core functionality'],
stack: ['Web app'],
source,
}
}
async function scanMetadataFromUrl(input: string): Promise<ScanResult> {
const normalized = normalizeUrl(input)
const parsed = new URL(normalized)
const domain = parsed.hostname
const attempts = [
{ url: normalized, source: 'direct site response' },
{ url: `https://r.jina.ai/http://${domain}`, source: 'r.jina.ai http proxy' },
{ url: `https://r.jina.ai/https://${domain}`, source: 'r.jina.ai https proxy' },
]
for (const attempt of attempts) {
try {
const response = await fetch(attempt.url)
if (!response.ok) {
continue
}
const text = await response.text()
if (text.trim().length < 40) {
continue
}
return extractScanResult(text, normalized, attempt.source)
} catch {
// Try the next source.
}
}
throw new Error('Unable to scan this URL automatically.')
}
function HomePage({
projectList,
siteContent,
}: {
projectList: Project[]
siteContent: SiteContent
}) {
const [activeFilter, setActiveFilter] = useState<'all' | 'built' | 'hosted'>(
'all',
)
const filteredProjects = useMemo(() => {
if (activeFilter === 'all') {
return projectList
}
return projectList.filter((project) => project.ownership === activeFilter)
}, [activeFilter, projectList])
return (
<main className="shell">
<section className="hero">
<p className="eyebrow">{siteContent.homeEyebrow}</p>
<h1>{siteContent.homeTitle}</h1>
<p className="intro">{siteContent.homeIntro}</p>
<div className="filters" aria-label="Project filters">
<button
type="button"
className={activeFilter === 'all' ? 'active' : ''}
onClick={() => setActiveFilter('all')}
>
All
</button>
<button
type="button"
className={activeFilter === 'built' ? 'active' : ''}
onClick={() => setActiveFilter('built')}
>
Built by me
</button>
<button
type="button"
className={activeFilter === 'hosted' ? 'active' : ''}
onClick={() => setActiveFilter('hosted')}
>
Hosted by me
</button>
<Link to="/admin">Admin</Link>
</div>
</section>
<section className="stats" aria-label="Project summary">
<article>
<p>Total</p>
<strong>{projectList.length}</strong>
</article>
<article>
<p>Built</p>
<strong>{projectList.filter((project) => project.ownership === 'built').length}</strong>
</article>
<article>
<p>Hosted</p>
<strong>{projectList.filter((project) => project.ownership === 'hosted').length}</strong>
</article>
</section>
<section className="grid" aria-label="Project cards">
{filteredProjects.map((project) => (
<article className="card" key={project.title}>
<div className="card-top">
<span className="pill">{project.domain}</span>
<span className="status live">{project.status}</span>
</div>
<h2 className="title-row">
{project.iconUrl ? (
<img className="project-icon" src={project.iconUrl} alt="" />
) : (
<span className="project-icon fallback" aria-hidden="true">
{project.title[0]}
</span>
)}
{project.title}
</h2>
<p>{project.summary}</p>
<ul className="stack" aria-label={`${project.title} tags`}>
<li>{project.ownership === 'built' ? 'Built' : 'Hosted'}</li>
<li>{project.category}</li>
<li>{project.access}</li>
<li>NeCloud</li>
{project.scanSource === 'manual' ? <li>Manual metadata</li> : null}
</ul>
<footer>
<span>{project.ownership === 'built' ? 'Built by me' : 'Hosted by me'}</span>
<div className="actions">
<Link to={`/projects/${project.slug}`}>About</Link>
<a href={project.url} target="_blank" rel="noreferrer">
Open Site
</a>
</div>
</footer>
</article>
))}
</section>
<section className="notes">
<h3>{siteContent.quickTipsTitle}</h3>
<p>{siteContent.quickTipsBody}</p>
</section>
</main>
)
}
function ProjectPage({
projectList,
siteContent,
}: {
projectList: Project[]
siteContent: SiteContent
}) {
const { slug } = useParams()
const project = slug ? projectList.find((item) => item.slug === slug) : undefined
if (!project) {
return (
<main className="shell">
<section className="hero">
<p className="eyebrow">Project</p>
<h1>Project not found</h1>
<p className="intro">The requested project page is not available.</p>
<div className="filters">
<Link to="/">Back to portfolio</Link>
</div>
</section>
</main>
)
}
return (
<main className="shell">
<section className="hero">
<p className="eyebrow">{siteContent.projectEyebrow}</p>
<h1>{project.title}</h1>
<p className="intro">{project.featured.headline}</p>
<div className="filters">
<Link to="/">Back to portfolio</Link>
<a href={project.url} target="_blank" rel="noreferrer">
Visit live site
</a>
</div>
</section>
<section className="stats" aria-label="Project facts">
<article>
<p>Domain</p>
<strong>{project.domain}</strong>
</article>
<article>
<p>Ownership</p>
<strong>{project.ownership === 'built' ? 'Built by me' : 'Hosted by me'}</strong>
</article>
<article>
<p>Category</p>
<strong>{project.category}</strong>
</article>
<article>
<p>Access</p>
<strong>{project.access}</strong>
</article>
<article>
<p>Source</p>
<strong>
{project.scanSource === 'scanned' ? 'Scanned metadata' : 'Manual fallback'}
</strong>
</article>
</section>
<section className="detail-grid" aria-label="Project overview">
<article className="detail-card">
<h3>Problem</h3>
<p>{project.featured.problem}</p>
</article>
<article className="detail-card">
<h3>Solution</h3>
<p>{project.featured.solution}</p>
</article>
</section>
<section className="notes">
<h3>Feature highlights</h3>
<ul className="stack" aria-label={`${project.title} feature highlights`}>
{project.featured.highlights.map((feature) => (
<li key={`${project.slug}-${feature}`}>{feature}</li>
))}
</ul>
</section>
<section className="notes">
<h3>Stack</h3>
<ul className="stack" aria-label={`${project.title} stack`}>
{project.featured.stack.map((item) => (
<li key={`${project.slug}-stack-${item}`}>{item}</li>
))}
</ul>
</section>
<section className="notes">
<h3>Next steps</h3>
<ul className="stack" aria-label={`${project.title} next steps`}>
{project.featured.nextSteps.map((item) => (
<li key={`${project.slug}-next-${item}`}>{item}</li>
))}
</ul>
<p className="update-tip">{project.featured.updateNote}</p>
</section>
</main>
)
}
function AdminPage({
projectList,
onSave,
onResetProject,
onResetAll,
onDeleteProject,
onCreateProject,
theme,
onThemeChange,
siteContent,
onSaveSiteContent,
onResetSiteContent,
}: {
projectList: Project[]
onSave: (sourceSlug: string, project: Project) => void
onResetProject: (slug: string) => void
onResetAll: () => void
onDeleteProject: (slug: string) => void
onCreateProject: () => Project
theme: ThemeName
onThemeChange: (theme: ThemeName) => void
siteContent: SiteContent
onSaveSiteContent: (siteContent: SiteContent) => void
onResetSiteContent: () => void
}) {
const [selectedSlug, setSelectedSlug] = useState(projectList[0]?.slug ?? '')
const [draft, setDraft] = useState<Project | null>(projectList[0] ?? null)
const [scanState, setScanState] = useState<'idle' | 'loading' | 'error' | 'success'>(
'idle',
)
const [scanMessage, setScanMessage] = useState('')
const [siteDraft, setSiteDraft] = useState<SiteContent>(siteContent)
const selectedProject = useMemo(
() => projectList.find((project) => project.slug === selectedSlug),
[projectList, selectedSlug],
)
useEffect(() => {
if (!selectedProject && projectList[0]) {
setSelectedSlug(projectList[0].slug)
setDraft(projectList[0])
return
}
if (selectedProject) {
setDraft(structuredClone(selectedProject))
setScanState('idle')
setScanMessage('')
}
}, [selectedProject, projectList])
useEffect(() => {
setSiteDraft(siteContent)
}, [siteContent])
const handleScanFromUrl = async () => {
if (!draft) {
return
}
setScanState('loading')
setScanMessage('Scanning URL metadata...')
try {
const result = await scanMetadataFromUrl(draft.url || draft.domain)
const suggestedSlug = createUniqueSlug(
slugify(result.domain.split('.')[0] || result.title),
projectList.filter((item) => item.slug !== selectedSlug),
)
setDraft({
...draft,
slug: suggestedSlug,
title: result.title,
domain: result.domain,
url: result.url,
category: result.category,
access: result.access,
iconUrl: result.iconUrl,
scanSource: 'scanned',
summary: result.summary,
details: result.summary,
features: result.highlights,
featured: {
...draft.featured,
headline: result.summary,
highlights: result.highlights,
stack: result.stack,
},
})
setScanState('success')
setScanMessage(`Scan complete using ${result.source}. Review fields, then save.`)
} catch {
setScanState('error')
setScanMessage('Could not scan this URL automatically. Fill fields manually and save.')
}
}
if (!draft) {
return (
<main className="shell">
<section className="hero">
<p className="eyebrow">Admin</p>
<h1>No projects available</h1>
<div className="filters">
<Link to="/">Back to portfolio</Link>
</div>
</section>
</main>
)
}
return (
<main className="shell">
<section className="hero">
<p className="eyebrow">Admin</p>
<h1>Edit Cards and About Pages</h1>
<p className="intro">
Select a project, edit content, then click Save project. Edits persist in your
browser and immediately update both cards and featured pages.
</p>
<div className="filters">
<Link to="/">Back to portfolio</Link>
<button
type="button"
onClick={() => {
const created = onCreateProject()
setSelectedSlug(created.slug)
}}
>
Create new entry
</button>
<button type="button" onClick={onResetAll}>
Reset all
</button>
</div>
</section>
<section className="notes admin-panel">
<h3>Main Site Header</h3>
<div className="admin-grid">
<label className="admin-label" htmlFor="homeEyebrow">
Home eyebrow
</label>
<input
id="homeEyebrow"
className="admin-input"
value={siteDraft.homeEyebrow}
onChange={(event) =>
setSiteDraft({ ...siteDraft, homeEyebrow: event.target.value })
}
/>
<label className="admin-label" htmlFor="homeTitle">
Home title
</label>
<input
id="homeTitle"
className="admin-input"
value={siteDraft.homeTitle}
onChange={(event) => setSiteDraft({ ...siteDraft, homeTitle: event.target.value })}
/>
<label className="admin-label" htmlFor="homeIntro">
Home intro
</label>
<textarea
id="homeIntro"
className="admin-input admin-textarea"
value={siteDraft.homeIntro}
onChange={(event) => setSiteDraft({ ...siteDraft, homeIntro: event.target.value })}
/>
<label className="admin-label" htmlFor="projectEyebrow">
Project page eyebrow
</label>
<input
id="projectEyebrow"
className="admin-input"
value={siteDraft.projectEyebrow}
onChange={(event) =>
setSiteDraft({ ...siteDraft, projectEyebrow: event.target.value })
}
/>
<label className="admin-label" htmlFor="quickTipsTitle">
Quick tips title
</label>
<input
id="quickTipsTitle"
className="admin-input"
value={siteDraft.quickTipsTitle}
onChange={(event) =>
setSiteDraft({ ...siteDraft, quickTipsTitle: event.target.value })
}
/>
<label className="admin-label" htmlFor="quickTipsBody">
Quick tips body
</label>
<textarea
id="quickTipsBody"
className="admin-input admin-textarea"
value={siteDraft.quickTipsBody}
onChange={(event) =>
setSiteDraft({ ...siteDraft, quickTipsBody: event.target.value })
}
/>
</div>
<div className="actions admin-actions">
<button type="button" onClick={() => onSaveSiteContent(siteDraft)}>
Save headers and tips
</button>
<button type="button" onClick={onResetSiteContent}>
Reset headers
</button>
</div>
</section>
<section className="notes admin-panel">
<h3>Project Header and Content</h3>
<label className="admin-label" htmlFor="project-select">
Project
</label>
<select
id="project-select"
className="admin-input"
value={selectedSlug}
onChange={(event) => setSelectedSlug(event.target.value)}
>
{projectList.map((project) => (
<option key={project.slug} value={project.slug}>
{project.title}
</option>
))}
</select>
<label className="admin-label" htmlFor="theme-select">
Theme
</label>
<select
id="theme-select"
className="admin-input"
value={theme}
onChange={(event) => onThemeChange(event.target.value as ThemeName)}
>
<option value="sand">Sandstone</option>
<option value="ocean">Ocean</option>
<option value="midnight">Midnight</option>
</select>
<div className="admin-grid">
<label className="admin-label" htmlFor="slug">
Slug
</label>
<input
id="slug"
className="admin-input"
value={draft.slug}
onChange={(event) =>
setDraft({ ...draft, slug: slugify(event.target.value) || draft.slug })
}
/>
<label className="admin-label" htmlFor="title">
Project page title
</label>
<input
id="title"
className="admin-input"
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
/>
<label className="admin-label" htmlFor="summary">
Card summary
</label>
<textarea
id="summary"
className="admin-input admin-textarea"
value={draft.summary}
onChange={(event) => setDraft({ ...draft, summary: event.target.value })}
/>
<label className="admin-label" htmlFor="category">
Category
</label>
<input
id="category"
className="admin-input"
value={draft.category}
onChange={(event) => setDraft({ ...draft, category: event.target.value })}
/>
<label className="admin-label" htmlFor="access">
Access
</label>
<select
id="access"
className="admin-input"
value={draft.access}
onChange={(event) =>
setDraft({ ...draft, access: event.target.value as Project['access'] })
}
>
<option>Public</option>
<option>Login required</option>
<option>Mixed</option>
</select>
<label className="admin-label" htmlFor="ownership">
Ownership
</label>
<select
id="ownership"
className="admin-input"
value={draft.ownership}
onChange={(event) =>
setDraft({ ...draft, ownership: event.target.value as Project['ownership'] })
}
>
<option value="built">Built</option>
<option value="hosted">Hosted</option>
</select>
<label className="admin-label" htmlFor="domain">
Domain
</label>
<input
id="domain"
className="admin-input"
value={draft.domain}
onChange={(event) => setDraft({ ...draft, domain: event.target.value })}
/>
<label className="admin-label" htmlFor="url">
URL
</label>
<input
id="url"
className="admin-input"
value={draft.url}
onChange={(event) => setDraft({ ...draft, url: event.target.value })}
/>
<div className="scan-row">
<button
type="button"
className="scan-button"
onClick={handleScanFromUrl}
disabled={scanState === 'loading'}
>
{scanState === 'loading' ? 'Scanning...' : 'Scan URL metadata'}
</button>
{scanMessage ? (
<p className={`scan-status ${scanState}`}>{scanMessage}</p>
) : null}
</div>
<label className="admin-label" htmlFor="iconUrl">
Icon URL
</label>
<input
id="iconUrl"
className="admin-input"
value={draft.iconUrl ?? ''}
onChange={(event) => setDraft({ ...draft, iconUrl: event.target.value || undefined })}
/>
<label className="admin-label" htmlFor="headline">
Project page subtitle
</label>
<textarea
id="headline"
className="admin-input admin-textarea"
value={draft.featured.headline}
onChange={(event) =>
setDraft({
...draft,
featured: { ...draft.featured, headline: event.target.value },
})
}
/>
<label className="admin-label" htmlFor="problem">
Problem
</label>
<textarea
id="problem"
className="admin-input admin-textarea"
value={draft.featured.problem}
onChange={(event) =>
setDraft({
...draft,
featured: { ...draft.featured, problem: event.target.value },
})
}
/>
<label className="admin-label" htmlFor="solution">
Solution
</label>
<textarea
id="solution"
className="admin-input admin-textarea"
value={draft.featured.solution}
onChange={(event) =>
setDraft({
...draft,
featured: { ...draft.featured, solution: event.target.value },
})
}
/>
<label className="admin-label" htmlFor="highlights">
Highlights (one per line)
</label>
<textarea
id="highlights"
className="admin-input admin-textarea"
value={joinLines(draft.featured.highlights)}
onChange={(event) =>
setDraft({
...draft,
featured: { ...draft.featured, highlights: splitLines(event.target.value) },
})
}
/>
<label className="admin-label" htmlFor="stack">
Stack (one per line)
</label>
<textarea
id="stack"
className="admin-input admin-textarea"
value={joinLines(draft.featured.stack)}
onChange={(event) =>
setDraft({
...draft,
featured: { ...draft.featured, stack: splitLines(event.target.value) },
})
}
/>
<label className="admin-label" htmlFor="nextSteps">
Next steps (one per line)
</label>
<textarea
id="nextSteps"
className="admin-input admin-textarea"
value={joinLines(draft.featured.nextSteps)}
onChange={(event) =>
setDraft({
...draft,
featured: { ...draft.featured, nextSteps: splitLines(event.target.value) },
})
}
/>
</div>
<div className="actions admin-actions">
<button
type="button"
onClick={() => {
onSave(selectedSlug, draft)
setSelectedSlug(draft.slug)
}}
>
Save project
</button>
<button type="button" onClick={() => onResetProject(selectedSlug)}>
Reset project
</button>
<button
type="button"
onClick={() => {
onDeleteProject(selectedSlug)
}}
>
Remove project
</button>
</div>
</section>
</main>
)
}
function App() {
const [managedProjects, setManagedProjects] = useState<Project[]>(baseProjects)
const [theme, setTheme] = useState<ThemeName>(readStoredTheme)
const [siteContent, setSiteContent] = useState<SiteContent>(defaultSiteContent)
useEffect(() => {
void (async () => {
const loaded = await fetchAdminContent()
if (!loaded) {
return
}
setManagedProjects(loaded.projects)
setSiteContent(loaded.siteContent)
})()
}, [])
useEffect(() => {
localStorage.setItem(THEME_STORAGE_KEY, theme)
document.documentElement.setAttribute('data-theme', theme)
}, [theme])
const handleSaveProject = (sourceSlug: string, project: Project) => {
const safeSlug = createUniqueSlug(
project.slug,
managedProjects.filter((item) => item.slug !== sourceSlug),
)
const normalizedProject = {
...project,
slug: safeSlug,
url: project.url.trim(),
domain: project.domain.trim(),
title: project.title.trim() || 'Untitled Project',
}
const updated = managedProjects.map((item) =>
item.slug === sourceSlug ? normalizedProject : item,
)
setManagedProjects(updated)
void persistAdminContent(updated, siteContent)
}
const handleResetProject = (slug: string) => {
const original = baseProjects.find((project) => project.slug === slug)
if (!original) {
return
}
const updated = managedProjects.map((item) =>
item.slug === slug ? structuredClone(original) : item,
)
setManagedProjects(updated)
void persistAdminContent(updated, siteContent)
}
const handleResetAll = () => {
const updated = structuredClone(baseProjects)
setManagedProjects(updated)
void persistAdminContent(updated, siteContent)
}
const handleCreateProject = () => {
const created = createEmptyProject(managedProjects)
const updated = [...managedProjects, created]
setManagedProjects(updated)
void persistAdminContent(updated, siteContent)
return created
}
const handleDeleteProject = (slug: string) => {
const updated = managedProjects.filter((item) => item.slug !== slug)
setManagedProjects(updated)
void persistAdminContent(updated, siteContent)
}
const handleResetSiteContent = () => {
const updatedSiteContent = structuredClone(defaultSiteContent)
setSiteContent(updatedSiteContent)
void persistAdminContent(managedProjects, updatedSiteContent)
}
const handleSaveSiteContent = (updatedSiteContent: SiteContent) => {
setSiteContent(updatedSiteContent)
void persistAdminContent(managedProjects, updatedSiteContent)
}
return (
<Routes>
<Route
path="/"
element={<HomePage projectList={managedProjects} siteContent={siteContent} />}
/>
<Route
path="/projects/:slug"
element={<ProjectPage projectList={managedProjects} siteContent={siteContent} />}
/>
<Route
path="/admin"
element={
<AdminPage
projectList={managedProjects}
onSave={handleSaveProject}
onResetProject={handleResetProject}
onResetAll={handleResetAll}
onDeleteProject={handleDeleteProject}
onCreateProject={handleCreateProject}
theme={theme}
onThemeChange={setTheme}
siteContent={siteContent}
onSaveSiteContent={handleSaveSiteContent}
onResetSiteContent={handleResetSiteContent}
/>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default App