S4 (a1): the media engine — VOICE templates, /desk, Ghost drafts
4a VOICE v1.1 committed (board start); lint is EXECUTABLE — banned list + no-exclamation law enforced in the engine (throws in test, drops in prod) and locked by tests. Curly-apostrophe variants covered. 4b mediaEngine: deterministic templates (MORNING WIRE, SIGNAL, STREAK WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH) filled ONLY from snapshot/ledger/streaks JSON. Record percentages never render under n>=20 (counts + 'Record building' below). Stark layer = curated committed library (content/stark-lines.json), day-rotated selection — selected, never generated. 4c /desk (founder-only: requireAuth + DESK_OWNERS email allowlist, deny-by-default): all formats as text + <=280-char pre-segmented tweets with per-tweet copy buttons + char counts, wire/numbers-only variants, DATA BRIEF block (structured day numbers) with copy-for-claude.ai. ntfy ping after the day's first snapshot: 'Desk pack ready'. 4d ghostPublisher: DRAFTS ONLY (status:'draft' test-locked), env-gated no-op, HS256 JWT via node crypto (zero new deps). POST /api/internal/ghost/drafts saves slate preview + settle drafts. Nothing anywhere auto-posts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/** Desk pack proxy (Session 63 / A1-S4) — forwards the founder's auth. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = req.headers.get('authorization');
|
||||
if (!auth) return NextResponse.json({ error: 'auth required' }, { status: 401 });
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/desk/pack`, {
|
||||
headers: { Accept: 'application/json', Authorization: auth },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'desk unavailable' }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal.
|
||||
* Auth: gated route + backend email allowlist (DESK_OWNERS). Everything on
|
||||
* this page is assembled from the pipeline by the VOICE v1.1 template
|
||||
* engine; per-tweet copy buttons + character counts; the DATA BRIEF block
|
||||
* pastes into claude.ai for freeform writing. Nothing auto-posts.
|
||||
*/
|
||||
|
||||
interface Variant { label: string; text: string; tweets: string[] }
|
||||
interface DeskPack {
|
||||
generated_at: string | null;
|
||||
formats: {
|
||||
morning_wire?: Variant[];
|
||||
signals?: Variant[][];
|
||||
streak_watch?: Variant[];
|
||||
settle?: Variant[];
|
||||
receipts?: Variant[][];
|
||||
archetype_watch?: Variant[];
|
||||
line_dispatches?: Variant[][];
|
||||
};
|
||||
data_brief?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function CopyBtn({ text, label = 'COPY' }: { text: string; label?: string }) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1200);
|
||||
});
|
||||
}}
|
||||
className="mono"
|
||||
style={{ cursor: 'pointer', background: done ? 'var(--g-a)' : 'transparent', color: done ? '#06060B' : 'var(--text-1)', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '4px 10px', fontSize: 10, fontWeight: 700, letterSpacing: '0.06em' }}
|
||||
>
|
||||
{done ? 'COPIED' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function VariantBlock({ v }: { v: Variant }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<span className="lbl">{v.label.toUpperCase()}</span>
|
||||
<CopyBtn text={v.text} label="COPY ALL" />
|
||||
</div>
|
||||
{v.tweets.map((t, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '8px 10px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 8, marginBottom: 6 }}>
|
||||
<pre className="mono" style={{ margin: 0, flex: 1, whiteSpace: 'pre-wrap', fontSize: 12.5, lineHeight: 1.55, color: 'var(--text-0)' }}>{t}</pre>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end', flexShrink: 0 }}>
|
||||
<CopyBtn text={t} />
|
||||
<span className="mono" style={{ fontSize: 9.5, color: t.length > 280 ? 'var(--miss)' : 'var(--text-2)' }}>{t.length}/280</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatCard({ title, variants }: { title: string; variants?: Variant[] }) {
|
||||
if (!variants || variants.length === 0) return null;
|
||||
return (
|
||||
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
|
||||
<div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--g-a)', marginBottom: 12 }}>{title}</div>
|
||||
{variants.map((v, i) => <VariantBlock key={i} v={v} />)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeskPage() {
|
||||
const { session } = useAuth();
|
||||
const [pack, setPack] = useState<DeskPack | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'denied' | 'ready' | 'error'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
const token = session?.access_token;
|
||||
if (!token) return;
|
||||
let active = true;
|
||||
fetch('/api/desk/pack', { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(async (r) => {
|
||||
if (!active) return;
|
||||
if (r.status === 403 || r.status === 401) { setStatus('denied'); return; }
|
||||
const data = (await r.json()) as DeskPack;
|
||||
setPack(data);
|
||||
setStatus(data && data.formats ? 'ready' : 'error');
|
||||
})
|
||||
.catch(() => { if (active) setStatus('error'); });
|
||||
return () => { active = false; };
|
||||
}, [session]);
|
||||
|
||||
if (status === 'denied') {
|
||||
return (
|
||||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>The desk is the publisher’s surface. DESK_OWNERS grants access.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (status !== 'ready' || !pack) {
|
||||
return (
|
||||
<section style={{ maxWidth: 640, margin: '0 auto', padding: '48px 16px' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-2)', fontSize: 13 }}>{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const f = pack.formats;
|
||||
return (
|
||||
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 120px' }}>
|
||||
<header style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>THE DESK</div>
|
||||
<h1 className="mono" style={{ margin: 0, fontSize: 26, fontWeight: 800 }}>Today’s arsenal</h1>
|
||||
{pack.generated_at && (
|
||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-2)', marginTop: 6 }}>
|
||||
Assembled {new Date(pack.generated_at).toLocaleString('en-US', { timeZone: 'America/New_York' })} ET · nothing auto-posts
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<FormatCard title="THE MORNING WIRE" variants={f.morning_wire} />
|
||||
{(f.signals || []).map((v, i) => <FormatCard key={`sig-${i}`} title={`SIGNAL ${i + 1}`} variants={v} />)}
|
||||
<FormatCard title="STREAK WATCH" variants={f.streak_watch} />
|
||||
<FormatCard title="ARCHETYPE WATCH" variants={f.archetype_watch} />
|
||||
{(f.line_dispatches || []).map((v, i) => <FormatCard key={`ld-${i}`} title={`LINE DISPATCH ${i + 1}`} variants={v} />)}
|
||||
<FormatCard title="THE SETTLE" variants={f.settle} />
|
||||
{(f.receipts || []).map((v, i) => <FormatCard key={`rc-${i}`} title={`RECEIPT ${i + 1}`} variants={v} />)}
|
||||
|
||||
{/* DATA BRIEF — paste into claude.ai for freeform Read Room threads. */}
|
||||
{pack.data_brief != null && (
|
||||
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border-hi)', borderRadius: 10, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<span className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--amber)' }}>DATA BRIEF</span>
|
||||
<CopyBtn text={JSON.stringify(pack.data_brief, null, 2)} label="COPY BRIEF" />
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>paste into claude.ai for Read Room + freeform</span>
|
||||
</div>
|
||||
<pre className="mono" style={{ margin: 0, maxHeight: 320, overflow: 'auto', fontSize: 11, lineHeight: 1.5, color: 'var(--text-1)', whiteSpace: 'pre-wrap' }}>{JSON.stringify(pack.data_brief, null, 2)}</pre>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user