Integrate per-project README content inline and admin structure updates
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
Generated
+1472
-6
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user