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 } 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 { 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 (

{siteContent.homeEyebrow}

{siteContent.homeTitle}

{siteContent.homeIntro}

Admin

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'}
))}

{siteContent.quickTipsTitle}

{siteContent.quickTipsBody}

) } function ProjectPage({ projectList, siteContent, }: { projectList: Project[] siteContent: SiteContent }) { const { slug } = useParams() const project = slug ? projectList.find((item) => item.slug === slug) : undefined if (!project) { return (

Project

Project not found

The requested project page is not available.

Back to portfolio
) } return (

{siteContent.projectEyebrow}

{project.title}

{project.featured.headline}

Back to portfolio Visit live site

Domain

{project.domain}

Ownership

{project.ownership === 'built' ? 'Built by me' : 'Hosted by me'}

Category

{project.category}

Access

{project.access}

Source

{project.scanSource === 'scanned' ? 'Scanned metadata' : 'Manual fallback'}

Problem

{project.featured.problem}

Solution

{project.featured.solution}

Feature highlights

    {project.featured.highlights.map((feature) => (
  • {feature}
  • ))}

Stack

    {project.featured.stack.map((item) => (
  • {item}
  • ))}

Next steps

    {project.featured.nextSteps.map((item) => (
  • {item}
  • ))}

{project.featured.updateNote}

) } 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(projectList[0] ?? null) const [scanState, setScanState] = useState<'idle' | 'loading' | 'error' | 'success'>( 'idle', ) const [scanMessage, setScanMessage] = useState('') const [siteDraft, setSiteDraft] = useState(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 (

Admin

No projects available

Back to portfolio
) } 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.

Back to portfolio

Main Site Header

setSiteDraft({ ...siteDraft, homeEyebrow: event.target.value }) } /> setSiteDraft({ ...siteDraft, homeTitle: event.target.value })} />