Integrate per-project README content inline and admin structure updates
This commit is contained in:
+58
@@ -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
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user