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
+3 -2
View File
@@ -13,6 +13,7 @@ The app now supports:
- Built vs Hosted filters
- A dedicated About page route for each project
- An admin editor route to update cards and About pages
- A per-project README summary block on each project page
## Run locally
@@ -72,8 +73,8 @@ Persistent admin saves:
## Admin editing
- Open `/admin` to edit project card and featured-page content.
- Edit the **Main Site Header** section to update the home page header and project page eyebrow.
- Edit **Quick tips title/body** in the same section.
- Use **Global Settings** to update theme, home headers, and quick tips.
- Use **Project Settings** to update project-specific fields and README URL.
- Use **Create new entry** to add a new project directly from Admin.
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
- Click **Save project** to apply updates instantly.
+1472 -6
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -16,7 +16,9 @@
"express": "^5.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+58
View File
@@ -257,6 +257,64 @@ h1 {
color: var(--ink-muted);
}
.readme-panel a {
margin-top: 0.7rem;
display: inline-block;
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.readme-panel a:hover {
text-decoration: underline;
}
.readme-markdown {
margin-top: 0.85rem;
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.9rem;
background: var(--surface);
}
.readme-markdown h1,
.readme-markdown h2,
.readme-markdown h3,
.readme-markdown h4 {
margin: 0.8rem 0 0.45rem;
line-height: 1.2;
}
.readme-markdown p,
.readme-markdown ul,
.readme-markdown ol {
margin: 0.45rem 0;
}
.readme-markdown code {
font-size: 0.85rem;
}
.readme-markdown pre {
overflow-x: auto;
padding: 0.55rem;
border-radius: 8px;
background: var(--chip-bg);
}
.readme-markdown table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
}
.readme-markdown th,
.readme-markdown td {
border: 1px solid var(--line);
padding: 0.4rem;
text-align: left;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+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"
+6
View File
@@ -5,6 +5,7 @@ export type Project = {
title: string
domain: string
url: string
readmeUrl?: string
category: string
access: 'Public' | 'Login required' | 'Mixed'
iconUrl?: string
@@ -31,6 +32,7 @@ export const projects: Project[] = [
title: 'NeCloud Weather',
domain: 'weather.necloud.us',
url: 'https://weather.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/weather/main/README.md',
category: 'Weather',
access: 'Public',
ownership: 'built',
@@ -57,6 +59,7 @@ export const projects: Project[] = [
title: 'Capsule',
domain: 'capsule.necloud.us',
url: 'https://capsule.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/capsule/main/README.md',
category: 'Finance',
access: 'Mixed',
iconUrl: 'https://capsule.necloud.us/images/capsule-logo.svg',
@@ -84,6 +87,7 @@ export const projects: Project[] = [
title: 'Nexus Bible',
domain: 'nexusbible.necloud.us',
url: 'https://nexusbible.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/nexusbible/main/README.md',
category: 'Study',
access: 'Mixed',
ownership: 'built',
@@ -110,6 +114,7 @@ export const projects: Project[] = [
title: 'Haver',
domain: 'prayer.necloud.us',
url: 'https://prayer.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/haver/main/README.md',
category: 'Faith',
access: 'Mixed',
ownership: 'built',
@@ -161,6 +166,7 @@ export const projects: Project[] = [
title: 'Spelling Hub',
domain: 'spellinghub.necloud.us',
url: 'https://spellinghub.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/spellinghub/main/README.md',
category: 'Education',
access: 'Mixed',
ownership: 'built',