docs(examples): add the CMS proof-of-concept dogfooding artifact
A WordPress-inspired CMS whose entire backend is PiCloud Rhai scripts deployed with pic apply, plus a SvelteKit frontend. Built to dogfood the project tool, CLI, and SDK; exercises docs/kv/files/users, docs+queue+cron +pubsub triggers, the transactional-outbox notification chain, a docs before-interceptor, set_if CAS, SSE, per-app CORS, env overlays, and a durable Workflow (validate -> enrich || seo -> publish -> finalize) started with workflow::start and polled with workflow::run_status, visualized live with Svelte Flow. FINDINGS.md / SECURITY.md capture every gap, surprise, and security issue found (each tagged [PiCloud] vs [CMS]); the platform fixes for the actionable ones ship in the preceding commit. Also folds in the read-only `pic` allowlist entries added to .claude/settings.json during the session (fewer-permission-prompts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
examples/cms-poc/frontend/.gitignore
vendored
Normal file
7
examples/cms-poc/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
2
examples/cms-poc/frontend/.npmrc
Normal file
2
examples/cms-poc/frontend/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
fund=false
|
||||
audit=false
|
||||
1607
examples/cms-poc/frontend/package-lock.json
generated
Normal file
1607
examples/cms-poc/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
examples/cms-poc/frontend/package.json
Normal file
21
examples/cms-poc/frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "cms-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5173 --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"svelte": "^5.1.0",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/svelte": "^1.6.2"
|
||||
}
|
||||
}
|
||||
12
examples/cms-poc/frontend/src/app.html
Normal file
12
examples/cms-poc/frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PiCloud CMS</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div style="max-width:820px;margin:0 auto;padding:0 16px">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
39
examples/cms-poc/frontend/src/lib/api.ts
Normal file
39
examples/cms-poc/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Thin fetch wrapper around the PiCloud CMS backend. The browser calls
|
||||
// same-origin /cms/* (Vite proxies to :8081), so there's no CORS/preflight.
|
||||
const TOKEN_KEY = 'cms_token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
export function setToken(t: string | null) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
if (t) localStorage.setItem(TOKEN_KEY, t);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
async function req(method: string, path: string, body?: unknown) {
|
||||
const headers: Record<string, string> = {};
|
||||
const t = getToken();
|
||||
if (t) headers['Authorization'] = 'Bearer ' + t;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch('/cms' + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
});
|
||||
const text = await res.text();
|
||||
let data: any = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
||||
if (!res.ok) {
|
||||
throw Object.assign(new Error((data && data.error) || res.statusText), { status: res.status, data });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (p: string) => req('GET', p),
|
||||
post: (p: string, b?: unknown) => req('POST', p, b),
|
||||
put: (p: string, b?: unknown) => req('PUT', p, b),
|
||||
del: (p: string) => req('DELETE', p)
|
||||
};
|
||||
27
examples/cms-poc/frontend/src/lib/auth.ts
Normal file
27
examples/cms-poc/frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api, getToken, setToken } from './api';
|
||||
|
||||
export const user = writable<any | null>(null);
|
||||
|
||||
export async function refreshUser() {
|
||||
if (!getToken()) { user.set(null); return; }
|
||||
try { user.set(await api.get('/auth/me')); }
|
||||
catch { setToken(null); user.set(null); }
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const r = await api.post('/auth/login', { email, password });
|
||||
setToken(r.token);
|
||||
user.set(r.user);
|
||||
return r;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try { await api.post('/auth/logout'); } catch { /* ignore */ }
|
||||
setToken(null);
|
||||
user.set(null);
|
||||
}
|
||||
|
||||
export function hasRole(u: any, role: string): boolean {
|
||||
return !!u && Array.isArray(u.roles) && u.roles.includes(role);
|
||||
}
|
||||
25
examples/cms-poc/frontend/src/lib/markdown.ts
Normal file
25
examples/cms-poc/frontend/src/lib/markdown.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Tiny, safe-ish markdown renderer for post bodies. It ESCAPES all HTML first
|
||||
// (so even a trusted author can't inject script), then applies a small set of
|
||||
// inline/block transforms. Not a full CommonMark impl — enough for a PoC.
|
||||
export function renderMarkdown(src: string): string {
|
||||
if (!src) return '';
|
||||
let s = src
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
// headings
|
||||
s = s.replace(/^### (.*)$/gm, '<h3>$1</h3>');
|
||||
s = s.replace(/^## (.*)$/gm, '<h2>$1</h2>');
|
||||
s = s.replace(/^# (.*)$/gm, '<h1>$1</h1>');
|
||||
// bold / italic / code
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
s = s.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||
// links [text](http...) — only http(s) URLs
|
||||
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noopener noreferrer">$1</a>');
|
||||
// paragraphs from blank-line separated blocks (skip block-level tags)
|
||||
return s
|
||||
.split(/\n{2,}/)
|
||||
.map((b) => (/^\s*<h[1-3]>/.test(b) ? b : '<p>' + b.replace(/\n/g, '<br>') + '</p>'))
|
||||
.join('\n');
|
||||
}
|
||||
55
examples/cms-poc/frontend/src/routes/+layout.svelte
Normal file
55
examples/cms-poc/frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { user, refreshUser, logout, hasRole } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { children } = $props();
|
||||
onMount(refreshUser);
|
||||
|
||||
async function doLogout() {
|
||||
await logout();
|
||||
goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/" class="brand">📝 PiCloud Blog</a>
|
||||
<a href="/">Home</a>
|
||||
<a href="/pages/about">About</a>
|
||||
{#if $user && (hasRole($user, 'admin') || hasRole($user, 'author'))}
|
||||
<a href="/admin">Dashboard</a>
|
||||
{/if}
|
||||
<span class="spacer"></span>
|
||||
{#if $user}
|
||||
<span class="who">{$user.display_name || $user.email} ({$user.roles.join(', ')})</span>
|
||||
<button onclick={doLogout}>Log out</button>
|
||||
{:else}
|
||||
<a href="/login">Log in</a>
|
||||
<a href="/register">Register</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer>PiCloud CMS PoC — backend is 100% PiCloud Rhai scripts.</footer>
|
||||
|
||||
<style>
|
||||
:global(body) { font-family: system-ui, sans-serif; color: #222; line-height: 1.5; }
|
||||
header { border-bottom: 1px solid #eee; margin-bottom: 1.5rem; }
|
||||
nav { display: flex; gap: 1rem; align-items: center; padding: 0.8rem 0; flex-wrap: wrap; }
|
||||
nav a { text-decoration: none; color: #2563eb; }
|
||||
nav .brand { font-weight: 700; color: #111; }
|
||||
.spacer { flex: 1; }
|
||||
.who { color: #666; font-size: 0.85rem; }
|
||||
button { cursor: pointer; border: 1px solid #ccc; background: #f8f8f8; border-radius: 6px; padding: 0.3rem 0.7rem; }
|
||||
footer { margin: 3rem 0 1rem; color: #999; font-size: 0.8rem; border-top: 1px solid #eee; padding-top: 1rem; }
|
||||
:global(input), :global(textarea), :global(select) { font: inherit; padding: 0.4rem; border: 1px solid #ccc; border-radius: 6px; width: 100%; box-sizing: border-box; }
|
||||
:global(.btn) { background: #2563eb; color: #fff; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; }
|
||||
:global(.err) { color: #b91c1c; }
|
||||
:global(.card) { border: 1px solid #eee; border-radius: 8px; padding: 1rem; margin: 0.8rem 0; }
|
||||
:global(label) { display: block; margin: 0.6rem 0 0.2rem; font-size: 0.85rem; color: #555; }
|
||||
</style>
|
||||
3
examples/cms-poc/frontend/src/routes/+layout.ts
Normal file
3
examples/cms-poc/frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// SPA: no SSR (everything is client-side fetch against the proxied backend).
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
55
examples/cms-poc/frontend/src/routes/+page.svelte
Normal file
55
examples/cms-poc/frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let posts = $state<any[]>([]);
|
||||
let tags = $state<any[]>([]);
|
||||
let activeTag = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
async function load(tag: string | null) {
|
||||
loading = true;
|
||||
activeTag = tag;
|
||||
const q = tag ? '?tag=' + encodeURIComponent(tag) : '';
|
||||
const [p, t] = await Promise.all([api.get('/posts' + q), api.get('/tags')]);
|
||||
posts = p.posts;
|
||||
tags = t.tags;
|
||||
loading = false;
|
||||
}
|
||||
onMount(() => load(null));
|
||||
</script>
|
||||
|
||||
<h1>Latest posts</h1>
|
||||
|
||||
{#if tags.length}
|
||||
<div class="tagcloud">
|
||||
<button class:active={!activeTag} onclick={() => load(null)}>all</button>
|
||||
{#each tags as t}
|
||||
<button class:active={activeTag === t.tag} onclick={() => load(t.tag)}>#{t.tag} ({t.count})</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p>Loading…</p>
|
||||
{:else if posts.length === 0}
|
||||
<p>No posts yet.</p>
|
||||
{:else}
|
||||
{#each posts as post}
|
||||
<article class="card">
|
||||
<h2><a href={'/posts/' + post.slug}>{post.title}</a></h2>
|
||||
<p class="meta">by {post.author_name} · {new Date(post.publish_at).toLocaleDateString()}</p>
|
||||
{#if post.excerpt}<p>{post.excerpt}</p>{/if}
|
||||
<p class="tags">{#each post.tags as tg}<span>#{tg}</span> {/each}</p>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tagcloud { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
.tagcloud button { border: 1px solid #ddd; background: #fafafa; border-radius: 999px; padding: 0.2rem 0.7rem; cursor: pointer; font-size: 0.8rem; }
|
||||
.tagcloud button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.meta { color: #888; font-size: 0.85rem; margin: 0.2rem 0; }
|
||||
.tags span { color: #2563eb; font-size: 0.8rem; margin-right: 0.3rem; }
|
||||
</style>
|
||||
37
examples/cms-poc/frontend/src/routes/admin/+layout.svelte
Normal file
37
examples/cms-poc/frontend/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { user, refreshUser, hasRole } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
let { children } = $props();
|
||||
let ready = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await refreshUser();
|
||||
ready = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (ready && (!$user || (!hasRole($user, 'admin') && !hasRole($user, 'author')))) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if ready && $user && (hasRole($user, 'admin') || hasRole($user, 'author'))}
|
||||
<div class="adminnav">
|
||||
<a href="/admin">Overview</a>
|
||||
<a href="/admin/posts">Posts</a>
|
||||
<a href="/admin/pages">Pages</a>
|
||||
<a href="/admin/comments">Comments</a>
|
||||
<a href="/admin/workflow">Pipeline</a>
|
||||
{#if hasRole($user, 'admin')}<a href="/admin/users">Users</a>{/if}
|
||||
</div>
|
||||
{@render children()}
|
||||
{:else}
|
||||
<p>Checking access…</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.adminnav { display: flex; gap: 1rem; padding: 0.6rem 0.8rem; background: #f3f4f6; border-radius: 8px; margin-bottom: 1rem; }
|
||||
.adminnav a { text-decoration: none; color: #374151; font-weight: 500; }
|
||||
</style>
|
||||
32
examples/cms-poc/frontend/src/routes/admin/+page.svelte
Normal file
32
examples/cms-poc/frontend/src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { user, hasRole } from '$lib/auth';
|
||||
let stats = $state({ posts: 0, pending: 0, users: 0 });
|
||||
onMount(async () => {
|
||||
const posts = (await api.get('/admin/posts')).posts;
|
||||
stats.posts = posts.length;
|
||||
try {
|
||||
const pending = (await api.get('/admin/comments?status=pending')).comments;
|
||||
stats.pending = pending.length;
|
||||
} catch { /* authors can't list comments */ }
|
||||
if (hasRole($user, 'admin')) {
|
||||
try { stats.users = (await api.get('/admin/users')).users.length; } catch { /* */ }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>Dashboard</h1>
|
||||
<div class="grid">
|
||||
<div class="card"><h2>{stats.posts}</h2><p>your posts</p><a href="/admin/posts">Manage →</a></div>
|
||||
<div class="card"><h2>{stats.pending}</h2><p>pending comments</p><a href="/admin/comments">Moderate →</a></div>
|
||||
{#if hasRole($user, 'admin')}
|
||||
<div class="card"><h2>{stats.users}</h2><p>users</p><a href="/admin/users">Manage →</a></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; }
|
||||
.card h2 { margin: 0; font-size: 2rem; }
|
||||
.card p { margin: 0.2rem 0; color: #666; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
let comments = $state<any[]>([]);
|
||||
let filter = $state('pending');
|
||||
let msg = $state('');
|
||||
|
||||
async function load() {
|
||||
const q = filter ? '?status=' + filter : '';
|
||||
comments = (await api.get('/admin/comments' + q)).comments;
|
||||
}
|
||||
onMount(load);
|
||||
|
||||
async function setStatus(c: any, status: string) {
|
||||
try { await api.put('/admin/comments/' + c.id, { status }); await load(); }
|
||||
catch (e: any) { msg = e.message; }
|
||||
}
|
||||
async function del(c: any) {
|
||||
if (!confirm('Delete comment?')) return;
|
||||
try { await api.del('/admin/comments/' + c.id); await load(); }
|
||||
catch (e: any) { msg = e.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Comment moderation</h1>
|
||||
<p>
|
||||
Filter:
|
||||
<select bind:value={filter} onchange={load} style="width:auto;display:inline-block">
|
||||
<option value="pending">pending</option>
|
||||
<option value="approved">approved</option>
|
||||
<option value="spam">spam</option>
|
||||
<option value="">all</option>
|
||||
</select>
|
||||
<span class="err">{msg}</span>
|
||||
</p>
|
||||
|
||||
{#each comments as c}
|
||||
<div class="card">
|
||||
<p><strong>{@html c.author_name}</strong> on <a href={'/posts/' + c.post_slug}>{c.post_title}</a>
|
||||
· <span class="meta">{new Date(c.created_at).toLocaleString()}</span>
|
||||
· <span class="badge">{c.status}</span></p>
|
||||
<!-- pre-escaped by the interceptor -->
|
||||
<p>{@html c.body}</p>
|
||||
<p>
|
||||
{#if c.status !== 'approved'}<button class="btn" onclick={() => setStatus(c, 'approved')}>approve</button>{/if}
|
||||
{#if c.status !== 'spam'}<button onclick={() => setStatus(c, 'spam')}>spam</button>{/if}
|
||||
<button onclick={() => del(c)}>delete</button>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p>No comments.</p>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.meta { color: #888; font-size: 0.85rem; }
|
||||
.badge { background: #eef2ff; color: #3730a3; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
let pages = $state<any[]>([]);
|
||||
let editing = $state<any | null>(null);
|
||||
let msg = $state('');
|
||||
const blank = () => ({ id: null, title: '', slug: '', body_md: '', status: 'draft' });
|
||||
async function load() { pages = (await api.get('/admin/pages')).pages; }
|
||||
onMount(load);
|
||||
async function save(e: Event) {
|
||||
e.preventDefault(); msg = '';
|
||||
const b = { title: editing.title, slug: editing.slug, body_md: editing.body_md, status: editing.status };
|
||||
try {
|
||||
if (editing.id) await api.put('/admin/pages/' + editing.id, b);
|
||||
else await api.post('/admin/pages', b);
|
||||
editing = null; await load(); msg = 'Saved.';
|
||||
} catch (err: any) { msg = 'Error: ' + err.message; }
|
||||
}
|
||||
async function del(p: any) {
|
||||
if (!confirm('Delete page?')) return;
|
||||
await api.del('/admin/pages/' + p.id); await load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Pages</h1>
|
||||
<p><button class="btn" onclick={() => (editing = blank())}>+ New page</button> {msg}</p>
|
||||
|
||||
{#if editing}
|
||||
<form class="card" onsubmit={save}>
|
||||
<label>Title</label><input bind:value={editing.title} required />
|
||||
<label>Slug</label><input bind:value={editing.slug} />
|
||||
<label>Body (markdown)</label><textarea bind:value={editing.body_md} rows="8"></textarea>
|
||||
<label>Status</label>
|
||||
<select bind:value={editing.status}><option value="draft">draft</option><option value="published">published</option></select>
|
||||
<p><button class="btn" type="submit">Save</button> <button type="button" onclick={() => (editing = null)}>Cancel</button></p>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<ul>
|
||||
{#each pages as p}
|
||||
<li>
|
||||
<strong>{p.title}</strong> <span class="badge">{p.status}</span>
|
||||
— <a href={'/pages/' + p.slug}>view</a>
|
||||
<button onclick={() => (editing = { ...p })}>edit</button>
|
||||
<button onclick={() => del(p)}>del</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
li { margin: 0.4rem 0; }
|
||||
.badge { background: #f3f4f6; color: #555; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let posts = $state<any[]>([]);
|
||||
let editing = $state<any | null>(null);
|
||||
let msg = $state('');
|
||||
|
||||
const blank = () => ({ id: null, title: '', slug: '', body_md: '', excerpt: '', tags: '', status: 'draft', publish_at: '' });
|
||||
|
||||
async function load() { posts = (await api.get('/admin/posts')).posts; }
|
||||
onMount(load);
|
||||
|
||||
function newPost() { editing = blank(); }
|
||||
function edit(p: any) {
|
||||
editing = { ...p, tags: (p.tags || []).join(', '), publish_at: p.publish_at || '' };
|
||||
}
|
||||
|
||||
async function save(e: Event) {
|
||||
e.preventDefault();
|
||||
msg = '';
|
||||
const body: any = {
|
||||
title: editing.title,
|
||||
slug: editing.slug,
|
||||
body_md: editing.body_md,
|
||||
excerpt: editing.excerpt,
|
||||
status: editing.status,
|
||||
tags: editing.tags.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
};
|
||||
if (editing.status === 'scheduled' && editing.publish_at) body.publish_at = editing.publish_at;
|
||||
try {
|
||||
if (editing.id) await api.put('/admin/posts/' + editing.id, body);
|
||||
else await api.post('/admin/posts', body);
|
||||
msg = 'Saved.';
|
||||
editing = null;
|
||||
await load();
|
||||
} catch (err: any) { msg = 'Error: ' + err.message; }
|
||||
}
|
||||
|
||||
async function del(p: any) {
|
||||
if (!confirm('Delete "' + p.title + '"?')) return;
|
||||
try { await api.del('/admin/posts/' + p.id); await load(); }
|
||||
catch (e: any) { msg = 'Error: ' + e.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Posts</h1>
|
||||
<p><button class="btn" onclick={newPost}>+ New post</button> {msg}</p>
|
||||
|
||||
{#if editing}
|
||||
<form class="card" onsubmit={save}>
|
||||
<h3>{editing.id ? 'Edit' : 'New'} post</h3>
|
||||
<label>Title</label><input bind:value={editing.title} required />
|
||||
<label>Slug (optional)</label><input bind:value={editing.slug} />
|
||||
<label>Excerpt</label><input bind:value={editing.excerpt} />
|
||||
<label>Tags (comma separated)</label><input bind:value={editing.tags} />
|
||||
<label>Body (markdown)</label><textarea bind:value={editing.body_md} rows="8"></textarea>
|
||||
<label>Status</label>
|
||||
<select bind:value={editing.status}>
|
||||
<option value="draft">draft</option>
|
||||
<option value="published">published</option>
|
||||
<option value="scheduled">scheduled</option>
|
||||
</select>
|
||||
{#if editing.status === 'scheduled'}
|
||||
<label>Publish at (ISO, e.g. 2026-07-18T09:00:00Z)</label>
|
||||
<input bind:value={editing.publish_at} />
|
||||
{/if}
|
||||
<p><button class="btn" type="submit">Save</button> <button type="button" onclick={() => (editing = null)}>Cancel</button></p>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Tags</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each posts as p}
|
||||
<tr>
|
||||
<td>{p.title}</td>
|
||||
<td><span class={'badge ' + p.status}>{p.status}</span></td>
|
||||
<td>{(p.tags || []).join(', ')}</td>
|
||||
<td>
|
||||
<button onclick={() => edit(p)}>edit</button>
|
||||
<button onclick={() => del(p)}>del</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; font-size: 0.9rem; }
|
||||
.badge { padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
|
||||
.badge.published { background: #dcfce7; color: #166534; }
|
||||
.badge.draft { background: #f3f4f6; color: #555; }
|
||||
.badge.scheduled { background: #fef9c3; color: #854d0e; }
|
||||
td button { margin-right: 0.3rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
let users = $state<any[]>([]);
|
||||
let msg = $state('');
|
||||
const ROLES = ['admin', 'author', 'reader'];
|
||||
|
||||
async function load() { users = (await api.get('/admin/users')).users; }
|
||||
onMount(load);
|
||||
|
||||
async function toggle(u: any, role: string) {
|
||||
const has = u.roles.includes(role);
|
||||
try {
|
||||
await api.put('/admin/users/' + u.id + '/roles', has ? { remove: [role] } : { add: [role] });
|
||||
await load();
|
||||
} catch (e: any) { msg = e.message; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Users</h1>
|
||||
<p class="err">{msg}</p>
|
||||
<table>
|
||||
<thead><tr><th>Email</th><th>Name</th><th>Roles</th></tr></thead>
|
||||
<tbody>
|
||||
{#each users as u}
|
||||
<tr>
|
||||
<td>{u.email}</td>
|
||||
<td>{u.display_name || '—'}</td>
|
||||
<td>
|
||||
{#each ROLES as r}
|
||||
<label class="chip">
|
||||
<input type="checkbox" checked={u.roles.includes(r)} onchange={() => toggle(u, r)} />
|
||||
{r}
|
||||
</label>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; font-size: 0.9rem; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.2rem; margin-right: 0.6rem; width: auto; font-size: 0.85rem; }
|
||||
.chip input { width: auto; }
|
||||
</style>
|
||||
148
examples/cms-poc/frontend/src/routes/admin/workflow/+page.svelte
Normal file
148
examples/cms-poc/frontend/src/routes/admin/workflow/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { SvelteFlow, Background, Controls } from '@xyflow/svelte';
|
||||
import '@xyflow/svelte/dist/style.css';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let nodes = $state<any[]>([]);
|
||||
let edges = $state<any[]>([]);
|
||||
let dag = $state<any>(null);
|
||||
let drafts = $state<any[]>([]);
|
||||
let selected = $state<string>('');
|
||||
let runId = $state<string | null>(null);
|
||||
let overall = $state<string>('');
|
||||
let msg = $state('');
|
||||
let poll: any = null;
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
pending: 'background:#f3f4f6;border:1px solid #d1d5db;color:#6b7280',
|
||||
running: 'background:#dbeafe;border:2px solid #2563eb;color:#1e40af',
|
||||
succeeded: 'background:#dcfce7;border:2px solid #16a34a;color:#166534',
|
||||
failed: 'background:#fee2e2;border:2px solid #dc2626;color:#991b1b',
|
||||
skipped: 'background:#fafafa;border:1px dashed #cbd5e1;color:#94a3b8'
|
||||
};
|
||||
|
||||
// layer each node by longest dependency path, then spread within the layer
|
||||
function layout(steps: any[]) {
|
||||
const byName: Record<string, any> = {};
|
||||
steps.forEach((s) => (byName[s.name] = s));
|
||||
const layer: Record<string, number> = {};
|
||||
const depth = (n: string): number => {
|
||||
if (layer[n] != null) return layer[n];
|
||||
const deps = byName[n].depends_on || [];
|
||||
const d = deps.length ? 1 + Math.max(...deps.map(depth)) : 0;
|
||||
return (layer[n] = d);
|
||||
};
|
||||
steps.forEach((s) => depth(s.name));
|
||||
const layers: Record<number, string[]> = {};
|
||||
steps.forEach((s) => (layers[layer[s.name]] ||= []).push(s.name));
|
||||
const pos: Record<string, { x: number; y: number }> = {};
|
||||
Object.entries(layers).forEach(([l, names]) => {
|
||||
names.forEach((n, i) => {
|
||||
pos[n] = { x: 120 + i * 240 - ((names.length - 1) * 240) / 2 + 200, y: +l * 130 };
|
||||
});
|
||||
});
|
||||
return pos;
|
||||
}
|
||||
|
||||
function buildGraph(steps: any[], statuses: Record<string, any> = {}) {
|
||||
const pos = layout(steps);
|
||||
nodes = steps.map((s) => {
|
||||
const st = statuses[s.name]?.status || 'pending';
|
||||
const sub = s.when ? `\nwhen: ${s.when}` : '';
|
||||
return {
|
||||
id: s.name,
|
||||
data: { label: `${s.label}\n[${st}]${sub}` },
|
||||
position: pos[s.name],
|
||||
style: `${STATUS_STYLE[st] || STATUS_STYLE.pending};border-radius:8px;padding:8px 10px;width:190px;white-space:pre-line;font-size:12px;text-align:center`
|
||||
};
|
||||
});
|
||||
edges = steps.flatMap((s) =>
|
||||
(s.depends_on || []).map((d: string) => ({
|
||||
id: `${d}-${s.name}`,
|
||||
source: d,
|
||||
target: s.name,
|
||||
animated: statuses[s.name]?.status === 'running'
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDag() {
|
||||
dag = await api.get('/admin/workflow');
|
||||
buildGraph(dag.steps);
|
||||
const posts = (await api.get('/admin/posts')).posts;
|
||||
drafts = posts.filter((p: any) => p.status === 'draft');
|
||||
if (drafts.length) selected = drafts[0].id;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!selected) return;
|
||||
msg = 'starting…';
|
||||
stopPoll();
|
||||
const r = await api.post(`/admin/posts/${selected}/pipeline`, {});
|
||||
runId = r.run_id;
|
||||
overall = 'running';
|
||||
msg = `run ${runId.slice(0, 8)}… started`;
|
||||
poll = setInterval(tick, 700);
|
||||
tick();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (!runId) return;
|
||||
try {
|
||||
const r = await api.get(`/admin/workflow/runs/${runId}`);
|
||||
overall = r.overall.status;
|
||||
buildGraph(dag.steps, r.steps);
|
||||
if (overall !== 'running') {
|
||||
stopPoll();
|
||||
msg = `run ${overall}`;
|
||||
loadDraftsSoon();
|
||||
}
|
||||
} catch (e: any) {
|
||||
msg = e.message;
|
||||
stopPoll();
|
||||
}
|
||||
}
|
||||
async function loadDraftsSoon() {
|
||||
const posts = (await api.get('/admin/posts')).posts;
|
||||
drafts = posts.filter((p: any) => p.status === 'draft');
|
||||
}
|
||||
function stopPoll() { if (poll) { clearInterval(poll); poll = null; } }
|
||||
|
||||
onMount(loadDag);
|
||||
onDestroy(stopPoll);
|
||||
</script>
|
||||
|
||||
<h1>Editorial pipeline <span class="muted">(PiCloud Workflow)</span></h1>
|
||||
<p class="muted">
|
||||
A durable DAG: <code>validate → (enrich ∥ seo) → publish → finalize</code>. Pick a draft and run it —
|
||||
the nodes light up as each step completes. Publishing chains into the reader-notification pipeline.
|
||||
</p>
|
||||
|
||||
<div class="bar">
|
||||
<select bind:value={selected} style="width:auto;max-width:320px">
|
||||
{#if drafts.length === 0}<option value="">— no drafts (create one in Posts) —</option>{/if}
|
||||
{#each drafts as d}<option value={d.id}>{d.title}</option>{/each}
|
||||
</select>
|
||||
<button class="btn" onclick={run} disabled={!selected || overall === 'running'}>▶ Run pipeline</button>
|
||||
{#if overall}<span class="pill {overall}">{overall}</span>{/if}
|
||||
<span class="muted">{msg}</span>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<SvelteFlow bind:nodes bind:edges fitView>
|
||||
<Background />
|
||||
<Controls />
|
||||
</SvelteFlow>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.muted { color: #888; font-weight: 400; font-size: 0.9rem; }
|
||||
.bar { display: flex; gap: 0.75rem; align-items: center; margin: 1rem 0; flex-wrap: wrap; }
|
||||
.flow { height: 460px; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; }
|
||||
.pill { padding: 0.15rem 0.6rem; border-radius: 999px; font-size: 0.8rem; text-transform: uppercase; }
|
||||
.pill.running { background: #dbeafe; color: #1e40af; }
|
||||
.pill.completed { background: #dcfce7; color: #166534; }
|
||||
.pill.blocked { background: #fee2e2; color: #991b1b; }
|
||||
code { background: #f3f4f6; padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
</style>
|
||||
24
examples/cms-poc/frontend/src/routes/login/+page.svelte
Normal file
24
examples/cms-poc/frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { login } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
try { await login(email, password); goto('/'); }
|
||||
catch (err: any) { error = err.message || 'login failed'; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Log in</h1>
|
||||
<form onsubmit={submit} style="max-width:360px">
|
||||
<label>Email</label>
|
||||
<input bind:value={email} type="email" required />
|
||||
<label>Password</label>
|
||||
<input bind:value={password} type="password" required />
|
||||
<p><button class="btn" type="submit">Log in</button></p>
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
</form>
|
||||
<p>No account? <a href="/register">Register as a reader</a>.</p>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { renderMarkdown } from '$lib/markdown';
|
||||
let pg = $state<any>(null);
|
||||
let error = $state('');
|
||||
const slug = $derived($page.params.slug);
|
||||
async function load() {
|
||||
try { pg = await api.get('/pages/' + slug); }
|
||||
catch (e: any) { error = e.message; }
|
||||
}
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<p class="err">Page not found.</p>
|
||||
{:else if pg}
|
||||
<h1>{pg.title}</h1>
|
||||
<div>{@html renderMarkdown(pg.body_md)}</div>
|
||||
{:else}
|
||||
<p>Loading…</p>
|
||||
{/if}
|
||||
110
examples/cms-poc/frontend/src/routes/posts/[slug]/+page.svelte
Normal file
110
examples/cms-poc/frontend/src/routes/posts/[slug]/+page.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { renderMarkdown } from '$lib/markdown';
|
||||
|
||||
let post = $state<any>(null);
|
||||
let comments = $state<any[]>([]);
|
||||
let error = $state('');
|
||||
let form = $state({ author_name: '', author_email: '', body: '' });
|
||||
let submitMsg = $state('');
|
||||
|
||||
const slug = $derived($page.params.slug);
|
||||
|
||||
let live = $state(false);
|
||||
let es: EventSource | null = null;
|
||||
|
||||
async function load() {
|
||||
error = '';
|
||||
try {
|
||||
post = await api.get('/posts/' + slug);
|
||||
comments = (await api.get('/posts/' + post.id + '/comments')).comments;
|
||||
subscribeLive();
|
||||
} catch (e: any) {
|
||||
error = e.message || 'not found';
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeLive() {
|
||||
if (es) es.close();
|
||||
// Public SSE feed of approved comments; filter to this post, dedupe by id-ish.
|
||||
es = new EventSource('/realtime/topics/comments-feed');
|
||||
es.onopen = () => (live = true);
|
||||
es.onerror = () => (live = false);
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
const m = JSON.parse(ev.data).message;
|
||||
if (m && m.post_id === post.id) {
|
||||
const exists = comments.some(
|
||||
(c) => c.body === m.body && c.author_name === m.author_name && c.created_at === m.created_at
|
||||
);
|
||||
if (!exists) comments = [...comments, m];
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
return () => es?.close();
|
||||
});
|
||||
|
||||
async function submitComment(e: Event) {
|
||||
e.preventDefault();
|
||||
submitMsg = '';
|
||||
try {
|
||||
await api.post('/posts/' + post.id + '/comments', form);
|
||||
submitMsg = 'Thanks! Your comment is awaiting moderation.';
|
||||
form = { author_name: '', author_email: '', body: '' };
|
||||
} catch (e: any) {
|
||||
submitMsg = 'Error: ' + (e.message || 'failed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<p class="err">Post not found.</p>
|
||||
<a href="/">← back</a>
|
||||
{:else if post}
|
||||
<a href="/">← all posts</a>
|
||||
<h1>{post.title}</h1>
|
||||
<p class="meta">by {post.author_name} · {new Date(post.publish_at).toLocaleString()} · {post.views} views</p>
|
||||
<p class="tags">{#each post.tags as t}<a href={'/?tag=' + t}>#{t}</a> {/each}</p>
|
||||
<!-- body_md is author-authored; renderMarkdown escapes HTML before formatting -->
|
||||
<div class="body">{@html renderMarkdown(post.body_md)}</div>
|
||||
|
||||
<hr />
|
||||
<h3>Comments ({comments.length}) {#if live}<span class="live">● live</span>{/if}</h3>
|
||||
{#each comments as c}
|
||||
<div class="card comment">
|
||||
<strong>{@html c.author_name}</strong>
|
||||
<span class="meta">{new Date(c.created_at).toLocaleDateString()}</span>
|
||||
<!-- c.body is stored HTML-escaped by the interceptor; {@html} renders the
|
||||
escaped entities as harmless literal text (no tag is formed). -->
|
||||
<p>{@html c.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<h3>Leave a comment</h3>
|
||||
<form onsubmit={submitComment}>
|
||||
<label>Name</label>
|
||||
<input bind:value={form.author_name} required />
|
||||
<label>Email (optional, never shown publicly)</label>
|
||||
<input bind:value={form.author_email} type="email" />
|
||||
<label>Comment</label>
|
||||
<textarea bind:value={form.body} rows="4" required></textarea>
|
||||
<p><button class="btn" type="submit">Submit</button></p>
|
||||
{#if submitMsg}<p>{submitMsg}</p>{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<p>Loading…</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.meta { color: #888; font-size: 0.85rem; }
|
||||
.tags a { color: #2563eb; font-size: 0.8rem; margin-right: 0.3rem; text-decoration: none; }
|
||||
.body :global(p) { margin: 0.8rem 0; }
|
||||
.comment strong { margin-right: 0.5rem; }
|
||||
.live { color: #16a34a; font-size: 0.7rem; vertical-align: middle; }
|
||||
</style>
|
||||
29
examples/cms-poc/frontend/src/routes/register/+page.svelte
Normal file
29
examples/cms-poc/frontend/src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let display_name = $state('');
|
||||
let msg = $state('');
|
||||
let error = $state('');
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
msg = ''; error = '';
|
||||
try {
|
||||
await api.post('/auth/register', { email, password, display_name });
|
||||
msg = 'Registered! You can now log in. (Readers get emailed when new posts publish.)';
|
||||
} catch (err: any) { error = err.message || 'registration failed'; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Register as a reader</h1>
|
||||
<form onsubmit={submit} style="max-width:360px">
|
||||
<label>Display name</label>
|
||||
<input bind:value={display_name} />
|
||||
<label>Email</label>
|
||||
<input bind:value={email} type="email" required />
|
||||
<label>Password (min 8 chars)</label>
|
||||
<input bind:value={password} type="password" required />
|
||||
<p><button class="btn" type="submit">Register</button></p>
|
||||
{#if msg}<p>{msg}</p>{/if}
|
||||
{#if error}<p class="err">{error}</p>{/if}
|
||||
</form>
|
||||
12
examples/cms-poc/frontend/svelte.config.js
Normal file
12
examples/cms-poc/frontend/svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
// SPA: prerender the shell, client-side route + fetch everything.
|
||||
adapter: adapter({ fallback: 'index.html' }),
|
||||
prerender: { entries: [] }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
23
examples/cms-poc/frontend/vite.config.ts
Normal file
23
examples/cms-poc/frontend/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// The browser calls same-origin /cms/* on :5173; Vite proxies to the PiCloud
|
||||
// backend on :8081. This sidesteps PiCloud's lack of CORS (FINDINGS F-030) and
|
||||
// avoids CORS preflight entirely (the request is same-origin to the browser).
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/cms': {
|
||||
target: 'http://localhost:8081',
|
||||
changeOrigin: true
|
||||
},
|
||||
// SSE realtime stream (live comments)
|
||||
'/realtime': {
|
||||
target: 'http://localhost:8081',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user