Integrate per-project README content inline and admin structure updates

This commit is contained in:
Nate Emmert
2026-03-17 16:15:25 -04:00
parent 4dd7a5cb9b
commit 38e5ed7906
6 changed files with 1670 additions and 27 deletions
+128 -18
View File
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import './App.css'
import { projects as baseProjects } from './data/projects'
import type { Project } from './data/projects'
@@ -87,6 +89,7 @@ function createEmptyProject(existing: Project[]): Project {
title: 'New Project',
domain: 'new-project.necloud.us',
url: 'https://new-project.necloud.us',
readmeUrl: '',
category: 'Custom',
access: 'Public',
ownership: 'built',
@@ -248,6 +251,45 @@ function uniqueItems(items: string[]): string[] {
return Array.from(new Set(items.filter(Boolean)))
}
function extractReadmeSummary(markdown: string): string {
const lines = markdown.split('\n')
let collecting = false
const paragraph: string[] = []
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) {
if (collecting && paragraph.length > 0) {
break
}
continue
}
if (line.startsWith('#') || line.startsWith('```')) {
continue
}
if (!collecting) {
collecting = true
}
if (line.startsWith('- ') || /^\d+\.\s/.test(line)) {
continue
}
paragraph.push(line)
}
const summary = paragraph.join(' ').replace(/\s+/g, ' ').trim()
if (summary) {
return summary
}
const compact = markdown.replace(/\s+/g, ' ').trim()
return compact.slice(0, 220)
}
function extractScanResult(text: string, url: string, source: string): ScanResult {
const parsed = new URL(url)
const domain = parsed.hostname
@@ -442,6 +484,50 @@ function ProjectPage({
}) {
const { slug } = useParams()
const project = slug ? projectList.find((item) => item.slug === slug) : undefined
const [readmeStatus, setReadmeStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>(
'idle',
)
const [readmeSummary, setReadmeSummary] = useState('')
const [readmeMarkdown, setReadmeMarkdown] = useState('')
useEffect(() => {
let canceled = false
const loadReadme = async () => {
if (!project?.readmeUrl) {
setReadmeStatus('idle')
setReadmeSummary('')
setReadmeMarkdown('')
return
}
setReadmeStatus('loading')
try {
const response = await fetch(project.readmeUrl)
if (!response.ok) {
throw new Error('README fetch failed')
}
const markdown = await response.text()
const summary = extractReadmeSummary(markdown)
if (!canceled) {
setReadmeSummary(summary)
setReadmeMarkdown(markdown)
setReadmeStatus('ready')
}
} catch {
if (!canceled) {
setReadmeStatus('error')
}
}
}
void loadReadme()
return () => {
canceled = true
}
}, [project?.readmeUrl])
if (!project) {
return (
@@ -535,6 +621,19 @@ function ProjectPage({
</ul>
<p className="update-tip">{project.featured.updateNote}</p>
</section>
<section className="notes readme-panel">
<h3>From Project README</h3>
{!project.readmeUrl ? <p>No README URL configured for this project yet.</p> : null}
{readmeStatus === 'loading' ? <p>Loading README summary...</p> : null}
{readmeStatus === 'error' ? <p>Unable to load README content right now.</p> : null}
{readmeStatus === 'ready' ? <p>{readmeSummary}</p> : null}
{readmeStatus === 'ready' && readmeMarkdown ? (
<div className="readme-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeMarkdown}</ReactMarkdown>
</div>
) : null}
</section>
</main>
)
}
@@ -679,8 +778,24 @@ function AdminPage({
</section>
<section className="notes admin-panel">
<h3>Main Site Header</h3>
<h3>Global Settings</h3>
<div className="admin-grid">
<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>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
</select>
<label className="admin-label" htmlFor="homeEyebrow">
Home eyebrow
</label>
@@ -748,6 +863,7 @@ function AdminPage({
setSiteDraft({ ...siteDraft, quickTipsBody: event.target.value })
}
/>
</div>
<div className="actions admin-actions">
@@ -761,7 +877,7 @@ function AdminPage({
</section>
<section className="notes admin-panel">
<h3>Project Header and Content</h3>
<h3>Project Settings</h3>
<label className="admin-label" htmlFor="project-select">
Project
</label>
@@ -778,22 +894,6 @@ function AdminPage({
))}
</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>
<option value="forest">Forest</option>
<option value="sunset">Sunset</option>
</select>
<div className="admin-grid">
<label className="admin-label" htmlFor="slug">
Slug
@@ -888,6 +988,16 @@ function AdminPage({
onChange={(event) => setDraft({ ...draft, url: event.target.value })}
/>
<label className="admin-label" htmlFor="projectReadmeUrl">
Project README URL
</label>
<input
id="projectReadmeUrl"
className="admin-input"
value={draft.readmeUrl ?? ''}
onChange={(event) => setDraft({ ...draft, readmeUrl: event.target.value })}
/>
<div className="scan-row">
<button
type="button"