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>
98 lines
3.4 KiB
Svelte
98 lines
3.4 KiB
Svelte
<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>
|