1138 lines
33 KiB
TypeScript
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
|