public documentation (0.39.0)

- amerc-api: GET /docs + /docs/:id are public (reads); writes still require auth
- DocsApp/ConsoleShell: publicView mode renders docs read-only when logged out,
  with a 'Log in to edit' header CTA; edit/new/delete hidden for anonymous
- Docs nav link now shown to everyone
- seeded 5 quality guides: Quickstart, Publish an agent, Embed a chatbox, Showcase, API reference
This commit is contained in:
artheru 2026-06-10 06:08:19 +08:00
parent e711a7c904
commit c8f6d0f892
6 changed files with 17 additions and 16 deletions

View File

@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0e0a14" /> <meta name="theme-color" content="#0e0a14" />
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." /> <meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="0.38.0-npc" /> <meta name="version" content="0.39.0-docs" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<!-- Open Graph / social share --> <!-- Open Graph / social share -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />

View File

@ -1,6 +1,6 @@
{ {
"name": "amerc-site", "name": "amerc-site",
"version": "0.38.0-npc", "version": "0.39.0-docs",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -285,11 +285,11 @@ const server = http.createServer(async (req, res) => {
const needAuth = () => send(res, 401, { ok: false, error: 'login or api key required' }); const needAuth = () => send(res, 401, { ok: false, error: 'login or api key required' });
// DOCUMENTS // DOCUMENTS
if (path === '/docs' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, docs: db.prepare('SELECT id,title,folder,updated_by,created_at,updated_at FROM documents ORDER BY folder,title').all() }); } if (path === '/docs' && method === 'GET') { return send(res, 200, { ok: true, docs: db.prepare('SELECT id,title,folder,updated_by,created_at,updated_at FROM documents ORDER BY folder,title').all() }); }
if (path === '/docs' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req) || {}; const now = Date.now(); if (path === '/docs' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req) || {}; const now = Date.now();
const info = db.prepare('INSERT INTO documents(title,folder,body,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(b.title || 'Untitled', b.folder || '', b.body || '', who(id), now, now); const info = db.prepare('INSERT INTO documents(title,folder,body,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(b.title || 'Untitled', b.folder || '', b.body || '', who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); } return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); return d ? send(res, 200, { ok: true, doc: d }) : send(res, 404, { ok: false, error: 'not found' }); } if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'GET') { const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); return d ? send(res, 200, { ok: true, doc: d }) : send(res, 404, { ok: false, error: 'not found' }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); if (!d) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req) || {}; if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); if (!d) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req) || {};
db.prepare('UPDATE documents SET title=?,folder=?,body=?,updated_by=?,updated_at=? WHERE id=?').run(b.title ?? d.title, b.folder ?? d.folder, b.body ?? d.body, who(id), Date.now(), d.id); return send(res, 200, { ok: true }); } db.prepare('UPDATE documents SET title=?,folder=?,body=?,updated_by=?,updated_at=? WHERE id=?').run(b.title ?? d.title, b.folder ?? d.folder, b.body ?? d.body, who(id), Date.now(), d.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 403, { ok: false, error: 'human login required to delete' }); db.prepare('DELETE FROM documents WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); } if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 403, { ok: false, error: 'human login required to delete' }); db.prepare('DELETE FROM documents WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }

View File

@ -42,11 +42,11 @@ function ConsoleLogin({ auth }) {
); );
} }
export default function ConsoleShell({ title, accent = '#27d8ff', current, requireRole, children }) { export default function ConsoleShell({ title, accent = '#27d8ff', current, requireRole, publicView, children }) {
const auth = useAuth(); const auth = useAuth();
if (!auth.ready) return <div className="cs-app"><div className="cs-loading">Loading</div></div>; if (!auth.ready) return <div className="cs-app"><div className="cs-loading">Loading</div></div>;
if (!auth.user) return <div className="cs-app" style={{ '--accent': accent }}><ConsoleLogin auth={auth} /></div>; if (!auth.user && !publicView) return <div className="cs-app" style={{ '--accent': accent }}><ConsoleLogin auth={auth} /></div>;
if (requireRole && auth.user.role !== requireRole) return ( if (requireRole && auth.user && auth.user.role !== requireRole) return (
<div className="cs-app" style={{ '--accent': accent }}> <div className="cs-app" style={{ '--accent': accent }}>
<div className="cs-login-wrap"><div className="cs-login" style={{ textAlign: 'center' }}> <div className="cs-login-wrap"><div className="cs-login" style={{ textAlign: 'center' }}>
<div className="cs-brand"><span className="cs-gem" />amerc</div> <div className="cs-brand"><span className="cs-gem" />amerc</div>
@ -65,8 +65,9 @@ export default function ConsoleShell({ title, accent = '#27d8ff', current, requi
{APPS.map((a) => <a key={a.label} href={a.href} className={a.label.toLowerCase() === current ? 'on' : ''}>{a.label}</a>)} {APPS.map((a) => <a key={a.label} href={a.href} className={a.label.toLowerCase() === current ? 'on' : ''}>{a.label}</a>)}
</nav> </nav>
<div className="cs-user"> <div className="cs-user">
<span>{auth.user.handle}{auth.user.role === 'admin' ? ' · admin' : ''}</span> {auth.user
<button type="button" onClick={() => auth.logout()}>Log out</button> ? <><span>{auth.user.handle}{auth.user.role === 'admin' ? ' · admin' : ''}</span><button type="button" onClick={() => auth.logout()}>Log out</button></>
: <a className="cs-btn cs-primary" href="https://amerc.ai/#/signin">Log in to edit</a>}
</div> </div>
</header> </header>
<main className="cs-main">{typeof children === 'function' ? children(auth) : children}</main> <main className="cs-main">{typeof children === 'function' ? children(auth) : children}</main>

View File

@ -5,7 +5,7 @@ import { api } from './api.js';
marked.setOptions({ breaks: true }); marked.setOptions({ breaks: true });
function DocsWorkspace() { function DocsWorkspace({ canEdit }) {
const [docs, setDocs] = useState([]); const [docs, setDocs] = useState([]);
const [sel, setSel] = useState(null); const [sel, setSel] = useState(null);
const [doc, setDoc] = useState(null); const [doc, setDoc] = useState(null);
@ -39,7 +39,7 @@ function DocsWorkspace() {
<aside className="docs-side"> <aside className="docs-side">
<div className="docs-side-top"> <div className="docs-side-top">
<input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} /> <input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="cs-btn cs-primary" onClick={create}>+ New</button> {canEdit && <button className="cs-btn cs-primary" onClick={create}>+ New</button>}
</div> </div>
<div className="docs-tree"> <div className="docs-tree">
{Object.keys(folders).sort().map((f) => ( {Object.keys(folders).sort().map((f) => (
@ -64,10 +64,10 @@ function DocsWorkspace() {
: <h2>{doc.title}</h2>} : <h2>{doc.title}</h2>}
<div className="docs-actions"> <div className="docs-actions">
{editing && <input className="docs-folder-input" placeholder="folder" value={draft.folder} onChange={(e) => setDraft({ ...draft, folder: e.target.value })} />} {editing && <input className="docs-folder-input" placeholder="folder" value={draft.folder} onChange={(e) => setDraft({ ...draft, folder: e.target.value })} />}
{!editing && <button className="cs-btn" onClick={() => setEditing(true)}>Edit</button>} {canEdit && !editing && <button className="cs-btn" onClick={() => setEditing(true)}>Edit</button>}
{editing && <button className="cs-btn cs-primary" onClick={save}>Save</button>} {editing && <button className="cs-btn cs-primary" onClick={save}>Save</button>}
{editing && <button className="cs-btn" onClick={() => { setEditing(false); setDraft({ title: doc.title, folder: doc.folder, body: doc.body }); }}>Cancel</button>} {editing && <button className="cs-btn" onClick={() => { setEditing(false); setDraft({ title: doc.title, folder: doc.folder, body: doc.body }); }}>Cancel</button>}
<button className="cs-btn" onClick={del}>Delete</button> {canEdit && <button className="cs-btn" onClick={del}>Delete</button>}
</div> </div>
</div> </div>
<p className="docs-byline">updated by {doc.updated_by || '—'}</p> <p className="docs-byline">updated by {doc.updated_by || '—'}</p>
@ -82,5 +82,5 @@ function DocsWorkspace() {
} }
export default function DocsApp() { export default function DocsApp() {
return <ConsoleShell title="docs" accent="#27d8ff" current="docs">{() => <DocsWorkspace />}</ConsoleShell>; return <ConsoleShell title="docs" accent="#27d8ff" current="docs" publicView>{(auth) => <DocsWorkspace canEdit={!!auth.user} />}</ConsoleShell>;
} }

View File

@ -7,7 +7,7 @@ import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx'; import TavernGame from './TavernGame.jsx';
import { api } from './api.js'; import { api } from './api.js';
const RELEASE = '0.38.0-npc'; const RELEASE = '0.39.0-docs';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },
@ -47,7 +47,7 @@ function PixelTopbar({ route, setRoute, auth }) {
{NAV.map((item) => ( {NAV.map((item) => (
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">{item.label}</button> <button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">{item.label}</button>
))} ))}
{loggedIn && <a className="px-nav-btn" href="https://docs.amerc.ai/">Docs</a>} <a className="px-nav-btn" href="https://docs.amerc.ai/">Docs</a>
{isAdmin && <a className="px-nav-btn" href="https://pm.amerc.ai/">PM</a>} {isAdmin && <a className="px-nav-btn" href="https://pm.amerc.ai/">PM</a>}
{isAdmin && <button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button">Quartermaster</button>} {isAdmin && <button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button">Quartermaster</button>}
{loggedIn {loggedIn