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:
@@ -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>
|
||||
Reference in New Issue
Block a user