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 - Built vs Hosted filters
- A dedicated About page route for each project - A dedicated About page route for each project
- An admin editor route to update cards and About pages - An admin editor route to update cards and About pages
- A per-project README summary block on each project page
## Run locally ## Run locally
@@ -72,8 +73,8 @@ Persistent admin saves:
## Admin editing ## Admin editing
- Open `/admin` to edit project card and featured-page content. - 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. - Use **Global Settings** to update theme, home headers, and quick tips.
- Edit **Quick tips title/body** in the same section. - Use **Project Settings** to update project-specific fields and README URL.
- Use **Create new entry** to add a new project directly from Admin. - 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. - 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. - 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", "express": "^5.2.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^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": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
+58
View File
@@ -257,6 +257,64 @@ h1 {
color: var(--ink-muted); 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 { .detail-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
+128 -18
View File
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom' import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import './App.css' import './App.css'
import { projects as baseProjects } from './data/projects' import { projects as baseProjects } from './data/projects'
import type { Project } from './data/projects' import type { Project } from './data/projects'
@@ -87,6 +89,7 @@ function createEmptyProject(existing: Project[]): Project {
title: 'New Project', title: 'New Project',
domain: 'new-project.necloud.us', domain: 'new-project.necloud.us',
url: 'https://new-project.necloud.us', url: 'https://new-project.necloud.us',
readmeUrl: '',
category: 'Custom', category: 'Custom',
access: 'Public', access: 'Public',
ownership: 'built', ownership: 'built',
@@ -248,6 +251,45 @@ function uniqueItems(items: string[]): string[] {
return Array.from(new Set(items.filter(Boolean))) 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 { function extractScanResult(text: string, url: string, source: string): ScanResult {
const parsed = new URL(url) const parsed = new URL(url)
const domain = parsed.hostname const domain = parsed.hostname
@@ -442,6 +484,50 @@ function ProjectPage({
}) { }) {
const { slug } = useParams() const { slug } = useParams()
const project = slug ? projectList.find((item) => item.slug === slug) : undefined 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) { if (!project) {
return ( return (
@@ -535,6 +621,19 @@ function ProjectPage({
</ul> </ul>
<p className="update-tip">{project.featured.updateNote}</p> <p className="update-tip">{project.featured.updateNote}</p>
</section> </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> </main>
) )
} }
@@ -679,8 +778,24 @@ function AdminPage({
</section> </section>
<section className="notes admin-panel"> <section className="notes admin-panel">
<h3>Main Site Header</h3> <h3>Global Settings</h3>
<div className="admin-grid"> <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"> <label className="admin-label" htmlFor="homeEyebrow">
Home eyebrow Home eyebrow
</label> </label>
@@ -748,6 +863,7 @@ function AdminPage({
setSiteDraft({ ...siteDraft, quickTipsBody: event.target.value }) setSiteDraft({ ...siteDraft, quickTipsBody: event.target.value })
} }
/> />
</div> </div>
<div className="actions admin-actions"> <div className="actions admin-actions">
@@ -761,7 +877,7 @@ function AdminPage({
</section> </section>
<section className="notes admin-panel"> <section className="notes admin-panel">
<h3>Project Header and Content</h3> <h3>Project Settings</h3>
<label className="admin-label" htmlFor="project-select"> <label className="admin-label" htmlFor="project-select">
Project Project
</label> </label>
@@ -778,22 +894,6 @@ function AdminPage({
))} ))}
</select> </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"> <div className="admin-grid">
<label className="admin-label" htmlFor="slug"> <label className="admin-label" htmlFor="slug">
Slug Slug
@@ -888,6 +988,16 @@ function AdminPage({
onChange={(event) => setDraft({ ...draft, url: event.target.value })} 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"> <div className="scan-row">
<button <button
type="button" type="button"
+6
View File
@@ -5,6 +5,7 @@ export type Project = {
title: string title: string
domain: string domain: string
url: string url: string
readmeUrl?: string
category: string category: string
access: 'Public' | 'Login required' | 'Mixed' access: 'Public' | 'Login required' | 'Mixed'
iconUrl?: string iconUrl?: string
@@ -31,6 +32,7 @@ export const projects: Project[] = [
title: 'NeCloud Weather', title: 'NeCloud Weather',
domain: 'weather.necloud.us', domain: 'weather.necloud.us',
url: 'https://weather.necloud.us', url: 'https://weather.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/weather/main/README.md',
category: 'Weather', category: 'Weather',
access: 'Public', access: 'Public',
ownership: 'built', ownership: 'built',
@@ -57,6 +59,7 @@ export const projects: Project[] = [
title: 'Capsule', title: 'Capsule',
domain: 'capsule.necloud.us', domain: 'capsule.necloud.us',
url: 'https://capsule.necloud.us', url: 'https://capsule.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/capsule/main/README.md',
category: 'Finance', category: 'Finance',
access: 'Mixed', access: 'Mixed',
iconUrl: 'https://capsule.necloud.us/images/capsule-logo.svg', iconUrl: 'https://capsule.necloud.us/images/capsule-logo.svg',
@@ -84,6 +87,7 @@ export const projects: Project[] = [
title: 'Nexus Bible', title: 'Nexus Bible',
domain: 'nexusbible.necloud.us', domain: 'nexusbible.necloud.us',
url: 'https://nexusbible.necloud.us', url: 'https://nexusbible.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/nexusbible/main/README.md',
category: 'Study', category: 'Study',
access: 'Mixed', access: 'Mixed',
ownership: 'built', ownership: 'built',
@@ -110,6 +114,7 @@ export const projects: Project[] = [
title: 'Haver', title: 'Haver',
domain: 'prayer.necloud.us', domain: 'prayer.necloud.us',
url: 'https://prayer.necloud.us', url: 'https://prayer.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/haver/main/README.md',
category: 'Faith', category: 'Faith',
access: 'Mixed', access: 'Mixed',
ownership: 'built', ownership: 'built',
@@ -161,6 +166,7 @@ export const projects: Project[] = [
title: 'Spelling Hub', title: 'Spelling Hub',
domain: 'spellinghub.necloud.us', domain: 'spellinghub.necloud.us',
url: 'https://spellinghub.necloud.us', url: 'https://spellinghub.necloud.us',
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/spellinghub/main/README.md',
category: 'Education', category: 'Education',
access: 'Mixed', access: 'Mixed',
ownership: 'built', ownership: 'built',