@startsimpli/ui 0.4.47 → 0.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.47",
3
+ "version": "0.4.48",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -17,6 +17,7 @@
17
17
  "./theme/contract": "./theme/contract.css",
18
18
  "./blog": "./src/components/blog/index.ts",
19
19
  "./blog/resolve": "./src/components/blog/resolveArticle.ts",
20
+ "./collection": "./src/components/collection/index.ts",
20
21
  "./email-editor": "./src/components/email-editor/index.ts",
21
22
  "./workflows": "./src/components/workflows/index.ts",
22
23
  "./workflows/styles": "./src/components/workflows/workflows.css",
@@ -39,8 +40,8 @@
39
40
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
40
41
  "react": "^18.0.0 || ^19.0.0",
41
42
  "react-dom": "^18.0.0 || ^19.0.0",
42
- "@startsimpli/auth": "0.4.29",
43
- "@startsimpli/api": "0.5.44"
43
+ "@startsimpli/api": "0.5.44",
44
+ "@startsimpli/auth": "0.4.29"
44
45
  },
45
46
  "peerDependenciesMeta": {
46
47
  "@startsimpli/api": {
@@ -0,0 +1,533 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Draft review workspace — "compare the candidates, pick one, review it, mark it
5
+ * ready to post". A writer emits N candidate drafts per approved topic; the team
6
+ * compares a story's candidates, picks the one to publish, reads the full draft
7
+ * (blog + LinkedIn + SEO) with the isolated Content-Judge verdict + auto-checks,
8
+ * and marks the chosen final ready to post (posting stays a manual step).
9
+ * Client-injected + generic — no OGMC specifics.
10
+ */
11
+ import { useMemo, useState } from 'react';
12
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
13
+ import { ShieldCheck, AlertTriangle, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
14
+
15
+ import { Button } from '../ui/button';
16
+ import { Badge } from '../ui/badge';
17
+ import { notify } from '../toast/use-notify';
18
+ import { MarkdownRenderer } from '../blog/MarkdownRenderer';
19
+ import type { CollectionClient, EntityRecord } from './types';
20
+ import { readData, toCamelKey } from './data';
21
+ import {
22
+ groupDrafts,
23
+ parseSources,
24
+ candidateTitle,
25
+ candidateIndex,
26
+ isChosen,
27
+ gradeWords,
28
+ wordTargets,
29
+ type DraftGroup,
30
+ type DraftSource,
31
+ type CheckStatus,
32
+ } from './drafts';
33
+
34
+ function field(r: EntityRecord, name: string): string {
35
+ const v = readData(r.data, name);
36
+ return v == null ? '' : String(v);
37
+ }
38
+ function obj(v: unknown): Record<string, unknown> {
39
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
40
+ }
41
+ function get(o: Record<string, unknown>, camel: string, snake: string): unknown {
42
+ return o[camel] ?? o[snake];
43
+ }
44
+ function hostOf(url: string): string {
45
+ try {
46
+ return new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).hostname.replace(/^www\./, '');
47
+ } catch {
48
+ return '';
49
+ }
50
+ }
51
+
52
+ export interface DraftReviewWorkspaceProps {
53
+ client: CollectionClient;
54
+ }
55
+
56
+ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
57
+ const qc = useQueryClient();
58
+ const typesQ = useQuery({ queryKey: ['schema-types'], queryFn: () => client.listTypes() });
59
+ const draftType = typesQ.data?.results.find((t) => t.key === 'draft');
60
+ const recordsQ = useQuery({
61
+ queryKey: ['entities', 'draft', 'all'],
62
+ queryFn: () => client.listAllEntities('draft'),
63
+ enabled: !!draftType,
64
+ });
65
+ const records = recordsQ.data ?? [];
66
+
67
+ const sourcesQ = useQuery({
68
+ queryKey: ['entities', 'source', 'all'],
69
+ queryFn: () => client.listAllEntities('source'),
70
+ enabled: !!draftType,
71
+ });
72
+ const tierByDomain = useMemo(() => {
73
+ const m = new Map<string, number>();
74
+ for (const s of sourcesQ.data ?? []) {
75
+ const d = String(readData(s.data, 'domain') ?? '').replace(/^www\./, '').toLowerCase();
76
+ const t = Number(readData(s.data, 'tier'));
77
+ if (d) m.set(d, Number.isFinite(t) ? t : 0);
78
+ }
79
+ return m;
80
+ }, [sourcesQ.data]);
81
+
82
+ const groups = useMemo(() => groupDrafts(records), [records]);
83
+ const [openId, setOpenId] = useState<string | null>(null);
84
+ const [busyKey, setBusyKey] = useState<string | null>(null);
85
+
86
+ async function writeEntity(record: EntityRecord, changes: Record<string, unknown>) {
87
+ const data: Record<string, unknown> = { ...record.data };
88
+ for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v;
89
+ return client.updateEntity(record.id, { data });
90
+ }
91
+
92
+ async function pick(group: DraftGroup, cand: EntityRecord) {
93
+ setBusyKey(group.key);
94
+ try {
95
+ await Promise.all(
96
+ group.candidates.map((c) =>
97
+ writeEntity(c, {
98
+ chosen: String(c.id) === String(cand.id),
99
+ status: String(c.id) === String(cand.id) ? 'selected' : field(c, 'status') === 'ready' ? 'ready' : '',
100
+ }),
101
+ ),
102
+ );
103
+ await qc.invalidateQueries({ queryKey: ['entities', 'draft'] });
104
+ } catch (err) {
105
+ notify.error(err instanceof Error ? err.message : 'Could not save the pick.');
106
+ } finally {
107
+ setBusyKey(null);
108
+ }
109
+ }
110
+
111
+ async function markReady(record: EntityRecord) {
112
+ setBusyKey(String(record.id));
113
+ try {
114
+ await writeEntity(record, { status: 'ready', chosen: true });
115
+ await qc.invalidateQueries({ queryKey: ['entities', 'draft'] });
116
+ } catch (err) {
117
+ notify.error(err instanceof Error ? err.message : 'Could not mark ready.');
118
+ } finally {
119
+ setBusyKey(null);
120
+ }
121
+ }
122
+
123
+ if (typesQ.isLoading || recordsQ.isLoading) {
124
+ return <p className="text-sm text-gray-500">Loading drafts…</p>;
125
+ }
126
+ if (!draftType) {
127
+ return <p className="text-sm text-gray-500">No “draft” type is defined for this app.</p>;
128
+ }
129
+
130
+ const pickedCount = groups.filter((g) => g.chosen).length;
131
+ const readyCount = groups.filter((g) => g.ready).length;
132
+
133
+ return (
134
+ <div className="space-y-6">
135
+ <div className="flex items-end justify-between">
136
+ <div>
137
+ <h1 className="text-2xl font-bold text-gray-900">Draft review</h1>
138
+ <p className="text-sm text-gray-500">
139
+ Each story has a few AI-written candidates. Compare them, pick the one to publish, and check it against the
140
+ judge before marking it ready.
141
+ </p>
142
+ </div>
143
+ <span className="text-sm text-gray-500">
144
+ {groups.length} stories · {pickedCount} picked · {readyCount} ready to post
145
+ </span>
146
+ </div>
147
+
148
+ {groups.length === 0 ? (
149
+ <p className="rounded-lg border border-dashed border-gray-300 p-8 text-center text-sm text-gray-500">
150
+ No drafts yet. Move a topic to “ready” and the writer will generate candidates here.
151
+ </p>
152
+ ) : (
153
+ <ol className="space-y-4">
154
+ {groups.map((g) => (
155
+ <StoryGroup
156
+ key={g.key}
157
+ group={g}
158
+ tierByDomain={tierByDomain}
159
+ openId={openId}
160
+ onToggleOpen={(id) => setOpenId((cur) => (cur === id ? null : id))}
161
+ onPick={pick}
162
+ onMarkReady={markReady}
163
+ busy={busyKey === g.key}
164
+ busyId={busyKey}
165
+ />
166
+ ))}
167
+ </ol>
168
+ )}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ function StoryGroup({
174
+ group,
175
+ tierByDomain,
176
+ openId,
177
+ onToggleOpen,
178
+ onPick,
179
+ onMarkReady,
180
+ busy,
181
+ busyId,
182
+ }: {
183
+ group: DraftGroup;
184
+ tierByDomain: Map<string, number>;
185
+ openId: string | null;
186
+ onToggleOpen: (id: string) => void;
187
+ onPick: (g: DraftGroup, c: EntityRecord) => Promise<void>;
188
+ onMarkReady: (c: EntityRecord) => Promise<void>;
189
+ busy: boolean;
190
+ busyId: string | null;
191
+ }) {
192
+ const headSources = parseSources(readData(group.candidates[0].data, 'sources'));
193
+ return (
194
+ <li className="rounded-lg border border-border bg-card p-4">
195
+ <div className="flex flex-wrap items-center gap-2">
196
+ <h2 className="text-base font-semibold text-gray-900">{group.label}</h2>
197
+ {group.contentType && (
198
+ <Badge variant="secondary" className="text-xs">
199
+ {group.contentType.replace(/_/g, ' ')}
200
+ </Badge>
201
+ )}
202
+ <span className="text-xs text-gray-400">
203
+ {group.candidates.length} candidate{group.candidates.length === 1 ? '' : 's'}
204
+ </span>
205
+ {group.ready && <Badge className="bg-emerald-600 text-xs text-white">ready to post</Badge>}
206
+ </div>
207
+
208
+ <SourceChips sources={headSources} tierByDomain={tierByDomain} />
209
+
210
+ <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
211
+ {group.candidates.map((c) => (
212
+ <CandidateCard
213
+ key={String(c.id)}
214
+ record={c}
215
+ contentType={group.contentType}
216
+ open={openId === String(c.id)}
217
+ onToggle={() => onToggleOpen(String(c.id))}
218
+ onPick={() => onPick(group, c)}
219
+ picking={busy}
220
+ />
221
+ ))}
222
+ </div>
223
+
224
+ {group.candidates
225
+ .filter((c) => openId === String(c.id))
226
+ .map((c) => (
227
+ <ContentReview
228
+ key={`rev-${c.id}`}
229
+ record={c}
230
+ contentType={group.contentType}
231
+ onMarkReady={() => onMarkReady(c)}
232
+ marking={busyId === String(c.id)}
233
+ />
234
+ ))}
235
+ </li>
236
+ );
237
+ }
238
+
239
+ function CandidateCard({
240
+ record,
241
+ contentType,
242
+ open,
243
+ onToggle,
244
+ onPick,
245
+ picking,
246
+ }: {
247
+ record: EntityRecord;
248
+ contentType: string;
249
+ open: boolean;
250
+ onToggle: () => void;
251
+ onPick: () => void;
252
+ picking: boolean;
253
+ }) {
254
+ const chosen = isChosen(record);
255
+ const status = field(record, 'status');
256
+ const preview = field(record, 'blog').slice(0, 150);
257
+ const checks = obj(readData(record.data, 'auto_checks'));
258
+ const blogWords = Number(get(checks, 'blogWords', 'blog_words')) || 0;
259
+ const target = wordTargets(contentType).blog;
260
+ return (
261
+ <div className={`flex flex-col rounded-md border p-3 ${chosen ? 'border-emerald-400 bg-emerald-50/40' : 'border-border bg-white'}`}>
262
+ <div className="mb-1 flex items-center justify-between">
263
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-gray-400">Candidate {candidateIndex(record) || '—'}</span>
264
+ {status === 'ready' ? (
265
+ <Badge className="bg-emerald-600 text-[10px] text-white">ready</Badge>
266
+ ) : chosen ? (
267
+ <Badge className="bg-emerald-100 text-[10px] text-emerald-700">picked</Badge>
268
+ ) : null}
269
+ </div>
270
+ <p className="line-clamp-2 text-sm font-medium text-gray-900">{candidateTitle(record)}</p>
271
+ <p className="mt-1 line-clamp-3 text-xs text-gray-500">{preview}…</p>
272
+
273
+ <div className="mt-2 flex flex-wrap gap-1">
274
+ <WordChip label="blog" count={blogWords} status={gradeWords(blogWords, target)} />
275
+ <JudgeChip record={record} />
276
+ </div>
277
+
278
+ <div className="mt-3 flex gap-2">
279
+ <Button size="sm" variant={chosen ? 'secondary' : 'default'} disabled={picking} onClick={onPick} className="text-xs">
280
+ {chosen ? (
281
+ <>
282
+ <CheckCircle2 className="mr-1 h-3.5 w-3.5" /> Picked
283
+ </>
284
+ ) : (
285
+ <>
286
+ <Circle className="mr-1 h-3.5 w-3.5" /> Pick
287
+ </>
288
+ )}
289
+ </Button>
290
+ <Button size="sm" variant="outline" onClick={onToggle} className="text-xs">
291
+ {open ? 'Hide' : 'Review'}
292
+ </Button>
293
+ </div>
294
+ </div>
295
+ );
296
+ }
297
+
298
+ function ContentReview({
299
+ record,
300
+ contentType,
301
+ onMarkReady,
302
+ marking,
303
+ }: {
304
+ record: EntityRecord;
305
+ contentType: string;
306
+ onMarkReady: () => void;
307
+ marking: boolean;
308
+ }) {
309
+ const blog = field(record, 'blog');
310
+ const linkedin = field(record, 'linkedin');
311
+ const seo = obj(readData(record.data, 'seo'));
312
+ const judge = obj(readData(record.data, 'judge_verdict'));
313
+ const checks = obj(readData(record.data, 'auto_checks'));
314
+ const ready = field(record, 'status') === 'ready';
315
+
316
+ const tags = ((): string[] => {
317
+ const t = seo.tags;
318
+ if (Array.isArray(t)) return t.map(String);
319
+ if (typeof t === 'string') return t.split(',').map((s) => s.trim()).filter(Boolean);
320
+ return [];
321
+ })();
322
+
323
+ const targets = wordTargets(contentType);
324
+ const blogWords = Number(get(checks, 'blogWords', 'blog_words')) || 0;
325
+ const liWords = Number(get(checks, 'linkedinWords', 'linkedin_words')) || 0;
326
+ const hype = get(checks, 'hypeFlags', 'hype_flags');
327
+ const approvedSources = get(checks, 'approvedSources', 'approved_sources');
328
+
329
+ const issues = Array.isArray(judge.issues) ? (judge.issues as Record<string, unknown>[]) : [];
330
+ const verdict = String(judge.verdict ?? '');
331
+ const summary = String(judge.summary ?? '');
332
+ const scores = obj(judge.scores);
333
+
334
+ return (
335
+ <div className="mt-4 rounded-lg border border-border bg-gray-50/60 p-4">
336
+ <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]">
337
+ <div className="min-w-0 space-y-4">
338
+ <section>
339
+ <SectionLabel>Blog post</SectionLabel>
340
+ {blog ? (
341
+ <div className="rounded-md border border-border bg-white p-4">
342
+ <MarkdownRenderer content={blog} />
343
+ </div>
344
+ ) : (
345
+ <Empty>No blog body.</Empty>
346
+ )}
347
+ </section>
348
+
349
+ {linkedin && (
350
+ <section>
351
+ <SectionLabel>LinkedIn post</SectionLabel>
352
+ <p className="whitespace-pre-wrap rounded-md border border-border bg-white p-4 text-sm text-gray-800">{linkedin}</p>
353
+ </section>
354
+ )}
355
+
356
+ {(seo.metaDescription || seo.meta_description || seo.primaryKeyword || seo.primary_keyword || tags.length > 0) && (
357
+ <section>
358
+ <SectionLabel>SEO</SectionLabel>
359
+ <div className="space-y-1.5 rounded-md border border-border bg-white p-4 text-sm">
360
+ <p>
361
+ <span className="text-gray-400">Meta:</span> {String(get(seo, 'metaDescription', 'meta_description') ?? '—')}
362
+ </p>
363
+ <p>
364
+ <span className="text-gray-400">Keyword:</span> {String(get(seo, 'primaryKeyword', 'primary_keyword') ?? '—')}
365
+ </p>
366
+ {tags.length > 0 && (
367
+ <div className="flex flex-wrap gap-1 pt-1">
368
+ {tags.map((t) => (
369
+ <Badge key={t} variant="outline" className="text-[10px]">
370
+ {t}
371
+ </Badge>
372
+ ))}
373
+ </div>
374
+ )}
375
+ </div>
376
+ </section>
377
+ )}
378
+ </div>
379
+
380
+ <div className="space-y-4">
381
+ <section>
382
+ <SectionLabel>Content judge</SectionLabel>
383
+ <div className="space-y-2 rounded-md border border-border bg-white p-3">
384
+ <div className="flex items-center gap-2">
385
+ <VerdictBadge verdict={verdict} issueCount={issues.length} />
386
+ {summary && <span className="text-xs text-gray-600">{summary}</span>}
387
+ </div>
388
+ {Object.keys(scores).length > 0 && (
389
+ <div className="flex flex-wrap gap-1">
390
+ {Object.entries(scores).map(([k, v]) => (
391
+ <Badge key={k} variant="outline" className="text-[10px]">
392
+ {k.replace(/_/g, ' ')} {String(v)}
393
+ </Badge>
394
+ ))}
395
+ </div>
396
+ )}
397
+ {issues.length === 0 ? (
398
+ <p className="flex items-center gap-1 text-xs text-emerald-700">
399
+ <ShieldCheck className="h-3.5 w-3.5" /> No issues flagged.
400
+ </p>
401
+ ) : (
402
+ <ul className="space-y-2">
403
+ {issues.map((it, i) => (
404
+ <li key={i} className="rounded border border-amber-200 bg-amber-50 p-2 text-xs">
405
+ <div className="mb-0.5 flex items-center gap-1.5">
406
+ <AlertTriangle className="h-3 w-3 text-amber-600" />
407
+ {it.severity ? <SeverityBadge severity={String(it.severity)} /> : null}
408
+ {it.guardrail ? <span className="font-medium text-amber-800">{String(it.guardrail)}</span> : null}
409
+ </div>
410
+ {it.problem ? <p className="text-gray-700">{String(it.problem)}</p> : null}
411
+ {it.fix ? <p className="mt-1 text-gray-500">Fix: {String(it.fix)}</p> : null}
412
+ </li>
413
+ ))}
414
+ </ul>
415
+ )}
416
+ </div>
417
+ </section>
418
+
419
+ <section>
420
+ <SectionLabel>Auto-checks</SectionLabel>
421
+ <div className="flex flex-wrap gap-1.5 rounded-md border border-border bg-white p-3">
422
+ <WordChip label="blog" count={blogWords} status={gradeWords(blogWords, targets.blog)} />
423
+ <WordChip label="linkedin" count={liWords} status={gradeWords(liWords, targets.linkedin)} />
424
+ {Array.isArray(hype) && (
425
+ <Badge variant="outline" className={`text-[10px] ${hype.length === 0 ? 'text-emerald-700' : 'text-amber-700'}`}>
426
+ {hype.length === 0 ? 'no hype' : `${hype.length} hype flag${hype.length === 1 ? '' : 's'}`}
427
+ </Badge>
428
+ )}
429
+ {approvedSources != null && (
430
+ <Badge variant="outline" className={`text-[10px] ${approvedSources ? 'text-emerald-700' : 'text-rose-700'}`}>
431
+ sources {approvedSources ? 'approved' : 'unverified'}
432
+ </Badge>
433
+ )}
434
+ </div>
435
+ </section>
436
+
437
+ <Button onClick={onMarkReady} disabled={marking || ready} className="w-full">
438
+ {ready ? (
439
+ <>
440
+ <CheckCircle2 className="mr-1.5 h-4 w-4" /> Ready to post
441
+ </>
442
+ ) : marking ? (
443
+ 'Saving…'
444
+ ) : (
445
+ 'Mark ready to post'
446
+ )}
447
+ </Button>
448
+ </div>
449
+ </div>
450
+ </div>
451
+ );
452
+ }
453
+
454
+ function SourceChips({ sources, tierByDomain }: { sources: DraftSource[]; tierByDomain: Map<string, number> }) {
455
+ if (sources.length === 0) {
456
+ return (
457
+ <p className="mt-2 flex items-center gap-1 text-xs font-medium text-amber-600">
458
+ <AlertTriangle className="h-3.5 w-3.5" /> No sources — needs verification
459
+ </p>
460
+ );
461
+ }
462
+ return (
463
+ <div className="mt-2 flex flex-wrap items-center gap-1.5">
464
+ <span className="text-xs text-gray-400">Sources:</span>
465
+ {sources.map((s, i) => {
466
+ const host = hostOf(s.url);
467
+ const tier = host ? tierByDomain.get(host.toLowerCase()) : undefined;
468
+ const href = /^https?:\/\//.test(s.url) ? s.url : host ? `https://${s.url}` : undefined;
469
+ const chip = (
470
+ <span className="inline-flex items-center gap-1 rounded-full border border-border bg-white px-2 py-0.5 text-xs text-gray-700">
471
+ {tier === 1 && <ShieldCheck className="h-3 w-3 text-emerald-600" />}
472
+ <span className="max-w-[12rem] truncate">{s.outlet || host || s.url}</span>
473
+ {tier != null ? (
474
+ <span className={`rounded px-1 text-[10px] font-semibold ${tier === 1 ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>T{tier}</span>
475
+ ) : (
476
+ <span className="rounded bg-amber-100 px-1 text-[10px] font-semibold text-amber-700">unlisted</span>
477
+ )}
478
+ {href && <ExternalLink className="h-3 w-3 text-gray-400" />}
479
+ </span>
480
+ );
481
+ return href ? (
482
+ <a key={i} href={href} target="_blank" rel="noreferrer" className="hover:opacity-80">
483
+ {chip}
484
+ </a>
485
+ ) : (
486
+ <span key={i}>{chip}</span>
487
+ );
488
+ })}
489
+ </div>
490
+ );
491
+ }
492
+
493
+ function JudgeChip({ record }: { record: EntityRecord }) {
494
+ const judge = obj(readData(record.data, 'judge_verdict'));
495
+ const verdict = String(judge.verdict ?? '');
496
+ const issues = Array.isArray(judge.issues) ? (judge.issues as unknown[]).length : 0;
497
+ if (verdict === 'accept') return <Badge variant="outline" className="text-[10px] text-emerald-700">judge: accept</Badge>;
498
+ if (verdict && verdict !== 'accept') return <Badge variant="outline" className="text-[10px] text-rose-700">judge: {verdict}</Badge>;
499
+ if (issues > 0) return <Badge variant="outline" className="text-[10px] text-amber-700">{issues} issue{issues === 1 ? '' : 's'}</Badge>;
500
+ return <Badge variant="outline" className="text-[10px] text-gray-500">unjudged</Badge>;
501
+ }
502
+
503
+ function WordChip({ label, count, status }: { label: string; count: number; status: CheckStatus }) {
504
+ if (status === 'none' && count === 0) return null;
505
+ const tone = status === 'ok' ? 'text-emerald-700' : status === 'none' ? 'text-gray-500' : 'text-amber-700';
506
+ return (
507
+ <Badge variant="outline" className={`text-[10px] ${tone}`}>
508
+ {label} {count}w{status === 'low' ? ' ↓' : status === 'high' ? ' ↑' : ''}
509
+ </Badge>
510
+ );
511
+ }
512
+
513
+ function VerdictBadge({ verdict, issueCount }: { verdict: string; issueCount: number }) {
514
+ if (verdict === 'accept') return <Badge className="bg-emerald-600 text-xs text-white">accept</Badge>;
515
+ if (verdict) return <Badge className="bg-rose-600 text-xs text-white">{verdict}</Badge>;
516
+ return <Badge className={`text-xs ${issueCount ? 'bg-amber-500 text-white' : 'bg-gray-400 text-white'}`}>{issueCount ? `${issueCount} issue${issueCount === 1 ? '' : 's'}` : 'reviewed'}</Badge>;
517
+ }
518
+
519
+ function SeverityBadge({ severity }: { severity: string }) {
520
+ const map: Record<string, string> = {
521
+ high: 'bg-rose-100 text-rose-700',
522
+ medium: 'bg-amber-100 text-amber-700',
523
+ low: 'bg-gray-100 text-gray-600',
524
+ };
525
+ return <span className={`rounded px-1 text-[10px] font-semibold ${map[severity] ?? map.low}`}>{severity}</span>;
526
+ }
527
+
528
+ function SectionLabel({ children }: { children: React.ReactNode }) {
529
+ return <p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400">{children}</p>;
530
+ }
531
+ function Empty({ children }: { children: React.ReactNode }) {
532
+ return <p className="rounded-md border border-dashed border-gray-300 p-3 text-xs text-gray-400">{children}</p>;
533
+ }
@@ -0,0 +1,343 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Topic review workspace — the "live-ranked list": topics ranked by ai_rank,
5
+ * triaged fast without a drawer (good/bad/edit verdict, status move, team note).
6
+ * Rejected sink to the bottom (kept, not deleted — a persistent avoid-list);
7
+ * written drop into a Done section. Click a title for the app-provided full
8
+ * editor (renderDetail). Generic — the controlled-vocab markets + the detail
9
+ * drawer are injected by the consumer.
10
+ */
11
+ import { useMemo, useState, type ReactNode } from 'react';
12
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
13
+ import { ChevronDown, ChevronRight, ThumbsUp, ThumbsDown, PencilLine, ExternalLink, AlertTriangle, ShieldCheck } from 'lucide-react';
14
+
15
+ import { Button } from '../ui/button';
16
+ import { Textarea } from '../ui/textarea';
17
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
18
+ import { notify } from '../toast/use-notify';
19
+ import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
20
+ import { readData, toCamelKey } from './data';
21
+
22
+ const STATUSES = ['suggested', 'ready', 'rejected', 'written'] as const;
23
+ const VERDICTS = [
24
+ { value: 'good', label: 'Good', icon: ThumbsUp, on: 'bg-emerald-600 text-white border-emerald-600' },
25
+ { value: 'bad', label: 'Bad', icon: ThumbsDown, on: 'bg-rose-600 text-white border-rose-600' },
26
+ { value: 'edit', label: 'Edit', icon: PencilLine, on: 'bg-amber-500 text-white border-amber-500' },
27
+ ] as const;
28
+
29
+ function field(r: EntityRecord, name: string): string {
30
+ const v = readData(r.data, name);
31
+ return v == null ? '' : String(v);
32
+ }
33
+ function rankOf(r: EntityRecord): number {
34
+ const n = Number(readData(r.data, 'ai_rank'));
35
+ return Number.isFinite(n) ? n : 0;
36
+ }
37
+ function hostOf(url: string): string {
38
+ try {
39
+ return new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).hostname.replace(/^www\./, '');
40
+ } catch {
41
+ return '';
42
+ }
43
+ }
44
+
45
+ export interface TopicReviewWorkspaceProps {
46
+ client: CollectionClient;
47
+ /** Controlled-vocab markets locking the market dropdown (kills value drift). */
48
+ markets?: string[];
49
+ /** Render the app's full-record editor when a title is clicked (optional). */
50
+ renderDetail?: (record: EntityRecord, type: EntityTypeDef, onClose: () => void, onSaved: () => void) => ReactNode;
51
+ }
52
+
53
+ export function TopicReviewWorkspace({ client, markets = [], renderDetail }: TopicReviewWorkspaceProps) {
54
+ const qc = useQueryClient();
55
+ const typesQ = useQuery({ queryKey: ['schema-types'], queryFn: () => client.listTypes() });
56
+ const topicType = typesQ.data?.results.find((t) => t.key === 'topic');
57
+ const recordsQ = useQuery({
58
+ queryKey: ['entities', 'topic', 'all'],
59
+ queryFn: () => client.listAllEntities('topic'),
60
+ enabled: !!topicType,
61
+ });
62
+ const records = recordsQ.data ?? [];
63
+
64
+ const sourcesQ = useQuery({
65
+ queryKey: ['entities', 'source', 'all'],
66
+ queryFn: () => client.listAllEntities('source'),
67
+ enabled: !!topicType,
68
+ });
69
+ const tierByDomain = useMemo(() => {
70
+ const m = new Map<string, number>();
71
+ for (const s of sourcesQ.data ?? []) {
72
+ const d = String(readData(s.data, 'domain') ?? '').replace(/^www\./, '').toLowerCase();
73
+ const t = Number(readData(s.data, 'tier'));
74
+ if (d) m.set(d, Number.isFinite(t) ? t : 0);
75
+ }
76
+ return m;
77
+ }, [sourcesQ.data]);
78
+
79
+ const [selected, setSelected] = useState<EntityRecord | null>(null);
80
+ const [showRejected, setShowRejected] = useState(false);
81
+ const [showWritten, setShowWritten] = useState(false);
82
+
83
+ const { active, written, rejected } = useMemo(() => {
84
+ const a: EntityRecord[] = [];
85
+ const w: EntityRecord[] = [];
86
+ const rej: EntityRecord[] = [];
87
+ for (const r of records) {
88
+ const s = field(r, 'status');
89
+ if (s === 'rejected') rej.push(r);
90
+ else if (s === 'written') w.push(r);
91
+ else a.push(r);
92
+ }
93
+ const byRank = (x: EntityRecord, y: EntityRecord) => rankOf(y) - rankOf(x);
94
+ return { active: a.sort(byRank), written: w.sort(byRank), rejected: rej.sort(byRank) };
95
+ }, [records]);
96
+
97
+ async function patch(record: EntityRecord, changes: Record<string, unknown>) {
98
+ const data: Record<string, unknown> = { ...record.data };
99
+ for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v;
100
+ try {
101
+ await client.updateEntity(record.id, { data });
102
+ await qc.invalidateQueries({ queryKey: ['entities', 'topic'] });
103
+ } catch (err) {
104
+ notify.error(err instanceof Error ? err.message : 'Could not save.');
105
+ }
106
+ }
107
+
108
+ if (typesQ.isLoading || recordsQ.isLoading) {
109
+ return <p className="text-sm text-gray-500">Loading topics…</p>;
110
+ }
111
+ if (!topicType) {
112
+ return <p className="text-sm text-gray-500">No “topic” type is defined for this app.</p>;
113
+ }
114
+
115
+ return (
116
+ <div className="space-y-6">
117
+ <div className="flex items-end justify-between">
118
+ <div>
119
+ <h1 className="text-2xl font-bold text-gray-900">Topic review</h1>
120
+ <p className="text-sm text-gray-500">
121
+ Ranked by AI. Rate each topic, jot a note, and move it along — rejected sink to the bottom.
122
+ </p>
123
+ </div>
124
+ <span className="text-sm text-gray-500">
125
+ {active.length} to review · {written.length} written · {rejected.length} rejected
126
+ </span>
127
+ </div>
128
+
129
+ {active.length === 0 ? (
130
+ <p className="rounded-lg border border-dashed border-gray-300 p-8 text-center text-sm text-gray-500">
131
+ Nothing to review right now.
132
+ </p>
133
+ ) : (
134
+ <ol className="space-y-3">
135
+ {active.map((r, i) => (
136
+ <TopicRow key={String(r.id)} rank={i + 1} record={r} markets={markets} onOpen={() => setSelected(r)} onPatch={patch} tierByDomain={tierByDomain} />
137
+ ))}
138
+ </ol>
139
+ )}
140
+
141
+ {written.length > 0 && (
142
+ <Section label={`Written (${written.length})`} open={showWritten} onToggle={() => setShowWritten((v) => !v)}>
143
+ <ol className="space-y-3">
144
+ {written.map((r) => (
145
+ <TopicRow key={String(r.id)} record={r} markets={markets} onOpen={() => setSelected(r)} onPatch={patch} tierByDomain={tierByDomain} muted />
146
+ ))}
147
+ </ol>
148
+ </Section>
149
+ )}
150
+
151
+ {rejected.length > 0 && (
152
+ <Section label={`Rejected (${rejected.length})`} open={showRejected} onToggle={() => setShowRejected((v) => !v)}>
153
+ <ol className="space-y-3">
154
+ {rejected.map((r) => (
155
+ <TopicRow key={String(r.id)} record={r} markets={markets} onOpen={() => setSelected(r)} onPatch={patch} tierByDomain={tierByDomain} muted />
156
+ ))}
157
+ </ol>
158
+ </Section>
159
+ )}
160
+
161
+ {selected && renderDetail?.(selected, topicType, () => setSelected(null), () => recordsQ.refetch())}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ function Section({ label, open, onToggle, children }: { label: string; open: boolean; onToggle: () => void; children: ReactNode }) {
167
+ return (
168
+ <div>
169
+ <button type="button" onClick={onToggle} className="flex items-center gap-1.5 text-sm font-semibold text-gray-500 hover:text-gray-900">
170
+ {open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
171
+ {label}
172
+ </button>
173
+ {open && <div className="mt-3">{children}</div>}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ function TopicRow({
179
+ record,
180
+ rank,
181
+ markets,
182
+ onOpen,
183
+ onPatch,
184
+ tierByDomain,
185
+ muted,
186
+ }: {
187
+ record: EntityRecord;
188
+ rank?: number;
189
+ markets: string[];
190
+ onOpen: () => void;
191
+ onPatch: (r: EntityRecord, changes: Record<string, unknown>) => Promise<void>;
192
+ tierByDomain: Map<string, number>;
193
+ muted?: boolean;
194
+ }) {
195
+ const title = field(record, 'title') || record.name || `#${record.id}`;
196
+ const angle = field(record, 'angle');
197
+ const market = field(record, 'market');
198
+ const contentType = field(record, 'content_type');
199
+ const status = field(record, 'status') || 'suggested';
200
+ const verdict = field(record, 'team_verdict');
201
+ const [noteOpen, setNoteOpen] = useState(false);
202
+ const [note, setNote] = useState(field(record, 'team_notes'));
203
+ const [savingNote, setSavingNote] = useState(false);
204
+
205
+ async function saveNote() {
206
+ setSavingNote(true);
207
+ await onPatch(record, { team_notes: note });
208
+ setSavingNote(false);
209
+ setNoteOpen(false);
210
+ }
211
+
212
+ return (
213
+ <li className={`rounded-lg border border-border bg-card p-4 ${muted ? 'opacity-70' : ''}`}>
214
+ <div className="flex items-start gap-3">
215
+ {rank != null && (
216
+ <span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary-50 text-xs font-semibold text-primary-700">
217
+ {rank}
218
+ </span>
219
+ )}
220
+ <div className="min-w-0 flex-1">
221
+ <div className="flex items-start justify-between gap-3">
222
+ <button type="button" onClick={onOpen} className="min-w-0 text-left">
223
+ <span className="font-semibold text-gray-900 hover:underline">{title}</span>
224
+ </button>
225
+ <button type="button" onClick={onOpen} className="shrink-0 text-gray-400 hover:text-gray-700" aria-label="Open topic">
226
+ <ExternalLink className="h-4 w-4" />
227
+ </button>
228
+ </div>
229
+ {angle && <p className="mt-1 line-clamp-2 text-sm text-gray-600">{angle}</p>}
230
+
231
+ <div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
232
+ <Select value={market || undefined} onValueChange={(val) => onPatch(record, { market: val })}>
233
+ <SelectTrigger className="h-7 w-auto min-w-[8rem] gap-1 text-xs">
234
+ <SelectValue placeholder="Set market" />
235
+ </SelectTrigger>
236
+ <SelectContent>
237
+ {[...new Set([...(market ? [market] : []), ...markets])].map((m) => (
238
+ <SelectItem key={m} value={m}>
239
+ {m}
240
+ </SelectItem>
241
+ ))}
242
+ </SelectContent>
243
+ </Select>
244
+ {contentType && <span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600">{contentType.replace(/_/g, ' ')}</span>}
245
+ </div>
246
+
247
+ <TopicSources record={record} tierByDomain={tierByDomain} />
248
+
249
+ <div className="mt-3 flex flex-wrap items-center gap-2">
250
+ <div className="flex overflow-hidden rounded-md border border-border">
251
+ {VERDICTS.map((v) => {
252
+ const active = verdict === v.value;
253
+ const Icon = v.icon;
254
+ return (
255
+ <button
256
+ key={v.value}
257
+ type="button"
258
+ onClick={() => onPatch(record, { team_verdict: active ? '' : v.value })}
259
+ className={`flex items-center gap-1 px-2.5 py-1 text-xs font-medium transition ${active ? v.on : 'bg-white text-gray-500 hover:bg-gray-50'}`}
260
+ >
261
+ <Icon className="h-3.5 w-3.5" /> {v.label}
262
+ </button>
263
+ );
264
+ })}
265
+ </div>
266
+
267
+ <Select value={status} onValueChange={(val) => onPatch(record, { status: val })}>
268
+ <SelectTrigger className="h-8 w-36 text-xs">
269
+ <SelectValue />
270
+ </SelectTrigger>
271
+ <SelectContent>
272
+ {STATUSES.map((s) => (
273
+ <SelectItem key={s} value={s}>
274
+ {s}
275
+ </SelectItem>
276
+ ))}
277
+ </SelectContent>
278
+ </Select>
279
+
280
+ <Button variant="outline" size="sm" onClick={() => setNoteOpen((v) => !v)} className="text-xs">
281
+ {note ? 'Note ✓' : 'Add note'}
282
+ </Button>
283
+ </div>
284
+
285
+ {noteOpen && (
286
+ <div className="mt-2 space-y-2">
287
+ <Textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} placeholder="Quick note for the AI — e.g. “sources don’t validate; topic is good, angle too broad.”" className="text-sm" />
288
+ <div className="flex gap-2">
289
+ <Button size="sm" onClick={saveNote} disabled={savingNote}>
290
+ {savingNote ? 'Saving…' : 'Save note'}
291
+ </Button>
292
+ <Button size="sm" variant="outline" onClick={() => { setNote(field(record, 'team_notes')); setNoteOpen(false); }}>
293
+ Cancel
294
+ </Button>
295
+ </div>
296
+ </div>
297
+ )}
298
+ {!noteOpen && note && <p className="mt-2 border-l-2 border-gray-200 pl-2 text-xs italic text-gray-500">{note}</p>}
299
+ </div>
300
+ </div>
301
+ </li>
302
+ );
303
+ }
304
+
305
+ function TopicSources({ record, tierByDomain }: { record: EntityRecord; tierByDomain: Map<string, number> }) {
306
+ const sources = [1, 2, 3].map((i) => field(record, `source_${i}`)).filter(Boolean);
307
+ if (sources.length === 0) {
308
+ return (
309
+ <p className="mt-2 flex items-center gap-1 text-xs font-medium text-amber-600">
310
+ <AlertTriangle className="h-3.5 w-3.5" /> No sources — needs verification
311
+ </p>
312
+ );
313
+ }
314
+ return (
315
+ <div className="mt-2 flex flex-wrap items-center gap-1.5">
316
+ <span className="text-xs text-gray-400">Sources:</span>
317
+ {sources.map((s, i) => {
318
+ const host = hostOf(s);
319
+ const tier = host ? tierByDomain.get(host.toLowerCase()) : undefined;
320
+ const href = /^https?:\/\//.test(s) ? s : host ? `https://${s}` : undefined;
321
+ const chip = (
322
+ <span className="inline-flex items-center gap-1 rounded-full border border-border bg-white px-2 py-0.5 text-xs text-gray-700">
323
+ {tier === 1 && <ShieldCheck className="h-3 w-3 text-emerald-600" />}
324
+ <span className="max-w-[12rem] truncate">{host || s}</span>
325
+ {tier != null ? (
326
+ <span className={`rounded px-1 text-[10px] font-semibold ${tier === 1 ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>T{tier}</span>
327
+ ) : (
328
+ <span className="rounded bg-amber-100 px-1 text-[10px] font-semibold text-amber-700">unlisted</span>
329
+ )}
330
+ {href && <ExternalLink className="h-3 w-3 text-gray-400" />}
331
+ </span>
332
+ );
333
+ return href ? (
334
+ <a key={i} href={href} target="_blank" rel="noreferrer" className="hover:opacity-80">
335
+ {chip}
336
+ </a>
337
+ ) : (
338
+ <span key={i}>{chip}</span>
339
+ );
340
+ })}
341
+ </div>
342
+ );
343
+ }
@@ -0,0 +1,91 @@
1
+ import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets } from '../drafts';
2
+ import type { EntityRecord } from '../types';
3
+
4
+ function rec(id: string, name: string, data: Record<string, unknown>): EntityRecord {
5
+ return { id: id as unknown as number, entityType: 'draft', externalId: null, name, data, createdAt: '' };
6
+ }
7
+
8
+ describe('parseSources', () => {
9
+ it('parses the pipe-delimited string shape', () => {
10
+ const out = parseSources('Arab News (2024-08-04) https://www.arabnews.com/node/misa-q2 | note without url');
11
+ expect(out).toHaveLength(1);
12
+ expect(out[0].url).toBe('https://www.arabnews.com/node/misa-q2');
13
+ expect(out[0].outlet).toBe('Arab News');
14
+ expect(out[0].date).toBe('2024-08-04');
15
+ });
16
+ it('parses the JSON array shape', () => {
17
+ const out = parseSources([{ url: 'https://www.thenationalnews.com/x', outlet: 'The National', date: '2026-06-27' }]);
18
+ expect(out).toHaveLength(1);
19
+ expect(out[0].outlet).toBe('The National');
20
+ });
21
+ it('returns [] for null/garbage', () => {
22
+ expect(parseSources(null)).toEqual([]);
23
+ expect(parseSources(42)).toEqual([]);
24
+ });
25
+ });
26
+
27
+ describe('normUrl', () => {
28
+ it('strips scheme/www/trailing slash and lowercases', () => {
29
+ expect(normUrl('https://www.Invest.qa/')).toBe('invest.qa');
30
+ expect(normUrl('https://www.arabnews.com/node/misa-q2')).toBe('arabnews.com/node/misa-q2');
31
+ });
32
+ });
33
+
34
+ describe('groupDrafts', () => {
35
+ const saudi = [1, 2, 3].map((i) =>
36
+ rec(`s${i}`, `Saudi ${i}`, { candidate_index: i, content_type: 'weekly_brief', chosen: false, sources: 'Arab News (2024-08-04) https://www.arabnews.com/node/misa-q2 | note' }),
37
+ );
38
+ const qatar = [3, 1, 2].map((i) =>
39
+ rec(`q${i}`, `Qatar ${i}`, { candidate_index: i, content_type: 'weekly_brief', chosen: i === 1, status: i === 1 ? 'selected' : '', sources: [{ url: 'https://www.invest.qa/', outlet: 'Invest Qatar' }] }),
40
+ );
41
+
42
+ it('clusters candidates by primary source, sorted by candidate_index', () => {
43
+ const groups = groupDrafts([...saudi, ...qatar]);
44
+ expect(groups).toHaveLength(2);
45
+ const s = groups.find((g) => g.key.includes('arabnews'))!;
46
+ expect(s.candidates.map((c) => c.data.candidate_index)).toEqual([1, 2, 3]);
47
+ });
48
+
49
+ it('unresolved groups float above picked ones', () => {
50
+ const groups = groupDrafts([...saudi, ...qatar]);
51
+ expect(groups[0].key).toContain('arabnews');
52
+ expect(groups[0].chosen).toBeNull();
53
+ expect(groups[1].chosen).not.toBeNull();
54
+ });
55
+
56
+ it('prefers an explicit topic_ref over the source heuristic', () => {
57
+ const a = rec('a', 'A', { candidate_index: 1, topic_ref: 't-42', topic_title: 'Parent topic', sources: 'x (2026-01-01) https://a.com/one' });
58
+ const b = rec('b', 'B', { candidate_index: 2, topic_ref: 't-42', topic_title: 'Parent topic', sources: 'y (2026-01-01) https://b.com/two' });
59
+ expect(groupKeyOf(a)).toBe('ref:t-42');
60
+ expect(groupKeyOf(a)).toBe(groupKeyOf(b));
61
+ const g = groupDrafts([a, b]);
62
+ expect(g).toHaveLength(1);
63
+ expect(g[0].label).toBe('Parent topic');
64
+ });
65
+
66
+ it('a ready candidate sinks the group and flags ready', () => {
67
+ const done = [1, 2].map((i) => rec(`d${i}`, `Done ${i}`, { candidate_index: i, topic_ref: 'done', status: i === 1 ? 'ready' : '', chosen: i === 1 }));
68
+ const open = [1, 2].map((i) => rec(`o${i}`, `Open ${i}`, { candidate_index: i, topic_ref: 'open' }));
69
+ const groups = groupDrafts([...done, ...open]);
70
+ expect(groups[groups.length - 1].ready).toBe(true);
71
+ expect(groups[0].ready).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe('candidateTitle', () => {
76
+ it('strips the "Candidate N —" scaffolding', () => {
77
+ expect(candidateTitle(rec('x', 'Candidate 1 — UAE–China trade brief', {}))).toBe('UAE–China trade brief');
78
+ });
79
+ });
80
+
81
+ describe('gradeWords', () => {
82
+ it('grades against the weekly-brief blog target', () => {
83
+ const t = wordTargets('weekly_brief').blog;
84
+ expect(gradeWords(462, t)).toBe('ok');
85
+ expect(gradeWords(200, t)).toBe('low');
86
+ expect(gradeWords(900, t)).toBe('high');
87
+ });
88
+ it('returns none when no target', () => {
89
+ expect(gradeWords(100, wordTargets('lead_magnet').linkedin)).toBe('none');
90
+ });
91
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * camelCase-aware read/write helpers for an Entity.data blob. The tenant API
3
+ * client camelCases response keys (incl. the data blob), so a snake_case attr
4
+ * `team_verdict` lands as `teamVerdict`; try the camel form first, then the raw
5
+ * key. Pure + framework-free.
6
+ */
7
+ import type { EntityRecord } from './types';
8
+
9
+ export function toCamelKey(name: string): string {
10
+ return name.replace(/_+([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
11
+ }
12
+
13
+ export function readData(data: EntityRecord['data'] | undefined, name: string): unknown {
14
+ if (!data) return undefined;
15
+ const bag = data as Record<string, unknown>;
16
+ return bag[toCamelKey(name)] ?? bag[name];
17
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Pure, schema-driven grouping logic for the draft-review workspace.
3
+ *
4
+ * A content writer typically emits N candidate drafts per approved topic ("3
5
+ * candidates, pick 1"), but draft records may carry no stored parent-topic id.
6
+ * Cluster by an explicit `topic_ref` when present (exact), else by the shared
7
+ * primary source URL, else the shared "Candidate N — <story>" name suffix. Pure
8
+ * + framework-free so it's unit-tested in isolation.
9
+ */
10
+ import type { EntityRecord } from './types';
11
+ import { readData } from './data';
12
+
13
+ export interface DraftSource {
14
+ url: string;
15
+ outlet?: string;
16
+ date?: string;
17
+ }
18
+
19
+ function str(v: unknown): string {
20
+ return v == null ? '' : String(v);
21
+ }
22
+
23
+ /**
24
+ * Sources arrive in two shapes: a pipe-delimited string ("Outlet (2024-08-04)
25
+ * https://url | note without a url") or a JSON array of { url, outlet, date }.
26
+ * Normalize both to a list of sources that actually carry a URL.
27
+ */
28
+ export function parseSources(raw: unknown): DraftSource[] {
29
+ if (Array.isArray(raw)) {
30
+ return raw
31
+ .map((s): DraftSource | null => {
32
+ if (typeof s === 'string') return s ? { url: s } : null;
33
+ if (s && typeof s === 'object') {
34
+ const o = s as Record<string, unknown>;
35
+ const url = str(o.url);
36
+ return url ? { url, outlet: str(o.outlet) || undefined, date: str(o.date) || undefined } : null;
37
+ }
38
+ return null;
39
+ })
40
+ .filter((s): s is DraftSource => s !== null);
41
+ }
42
+ if (typeof raw === 'string') {
43
+ return raw
44
+ .split('|')
45
+ .map((part): DraftSource | null => {
46
+ const m = part.match(/https?:\/\/\S+/);
47
+ if (!m) return null;
48
+ const outlet = part.slice(0, part.indexOf('(') === -1 ? part.indexOf('http') : part.indexOf('(')).trim();
49
+ const dm = part.match(/\((\d{4}-\d{2}-\d{2})\)/);
50
+ return { url: m[0].replace(/[.,)]+$/, ''), outlet: outlet || undefined, date: dm ? dm[1] : undefined };
51
+ })
52
+ .filter((s): s is DraftSource => s !== null);
53
+ }
54
+ return [];
55
+ }
56
+
57
+ /** host + path (no scheme/www/trailing slash), lowercased — a stable dedupe key. */
58
+ export function normUrl(u: string): string {
59
+ try {
60
+ const x = new URL(/^https?:\/\//.test(u) ? u : `https://${u}`);
61
+ return (x.hostname.replace(/^www\./, '') + x.pathname.replace(/\/+$/, '')).toLowerCase();
62
+ } catch {
63
+ return u.trim().toLowerCase();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * The clustering key for a draft. Prefer an explicit topic_ref (stamped by the
69
+ * writer) — exact. Older drafts have none, so fall back to the normalized primary
70
+ * source URL, then the shared "Candidate N — <story>" suffix, then id.
71
+ */
72
+ export function groupKeyOf(r: EntityRecord): string {
73
+ const topicRef = str(readData(r.data, 'topic_ref'));
74
+ if (topicRef) return `ref:${topicRef}`;
75
+ const sources = parseSources(readData(r.data, 'sources'));
76
+ if (sources.length > 0) return `src:${normUrl(sources[0].url)}`;
77
+ const name = str(r.name) || str(readData(r.data, 'story_title'));
78
+ const suffix = name.replace(/^\s*candidate\s*\d+\s*[—\-:]\s*/i, '').trim();
79
+ if (suffix) return `name:${suffix.toLowerCase()}`;
80
+ return `id:${r.id}`;
81
+ }
82
+
83
+ /** Candidate title without the "Candidate N — " scaffolding a writer adds. */
84
+ export function candidateTitle(r: EntityRecord): string {
85
+ const raw = str(readData(r.data, 'story_title')) || str(r.name);
86
+ return raw.replace(/^\s*candidate\s*\d+\s*[—\-:]\s*/i, '').trim() || raw || `#${r.id}`;
87
+ }
88
+
89
+ export function candidateIndex(r: EntityRecord): number {
90
+ const n = Number(readData(r.data, 'candidate_index'));
91
+ return Number.isFinite(n) ? n : 0;
92
+ }
93
+
94
+ export function isChosen(r: EntityRecord): boolean {
95
+ const v = readData(r.data, 'chosen');
96
+ return v === true || v === 'true' || v === 1;
97
+ }
98
+
99
+ export interface DraftGroup {
100
+ key: string;
101
+ label: string;
102
+ contentType: string;
103
+ candidates: EntityRecord[];
104
+ chosen: EntityRecord | null;
105
+ /** A candidate has been approved (status='ready') — the final is ready to post. */
106
+ ready: boolean;
107
+ }
108
+
109
+ /**
110
+ * Cluster draft records into candidate sets (one per story), each sorted by
111
+ * candidate_index. Unresolved stories (nothing picked) float to the top — that's
112
+ * the work to do — and ready-to-post ones sink.
113
+ */
114
+ export function groupDrafts(records: EntityRecord[]): DraftGroup[] {
115
+ const byKey = new Map<string, EntityRecord[]>();
116
+ for (const r of records) {
117
+ const k = groupKeyOf(r);
118
+ (byKey.get(k) ?? byKey.set(k, []).get(k)!).push(r);
119
+ }
120
+ const groups: DraftGroup[] = [];
121
+ for (const [key, cands] of byKey) {
122
+ cands.sort((a, b) => candidateIndex(a) - candidateIndex(b));
123
+ const chosen = cands.find(isChosen) ?? null;
124
+ const head = chosen ?? cands[0];
125
+ const ready = cands.some((c) => str(readData(c.data, 'status')) === 'ready');
126
+ const topicTitle = str(readData(head.data, 'topic_title'));
127
+ groups.push({
128
+ key,
129
+ label: topicTitle || candidateTitle(head),
130
+ contentType: str(readData(head.data, 'content_type')),
131
+ candidates: cands,
132
+ chosen,
133
+ ready,
134
+ });
135
+ }
136
+ const rank = (g: DraftGroup) => (g.ready ? 2 : g.chosen ? 1 : 0);
137
+ return groups.sort((a, b) => rank(a) - rank(b) || a.label.localeCompare(b.label));
138
+ }
139
+
140
+ /** Word-count targets by content type, from the OGMC skill specs (generic defaults). */
141
+ export function wordTargets(contentType: string): { blog: [number, number]; linkedin: [number, number] } {
142
+ switch (contentType) {
143
+ case 'lead_magnet':
144
+ return { blog: [3000, 4500], linkedin: [0, 0] };
145
+ case 'weekly_brief':
146
+ return { blog: [400, 500], linkedin: [150, 250] };
147
+ default:
148
+ return { blog: [350, 600], linkedin: [120, 280] };
149
+ }
150
+ }
151
+
152
+ export type CheckStatus = 'ok' | 'low' | 'high' | 'none';
153
+
154
+ /** Grade a word count against a [lo, hi] target. hi===0 means "no target". */
155
+ export function gradeWords(count: number, [lo, hi]: [number, number]): CheckStatus {
156
+ if (hi === 0) return 'none';
157
+ if (count < lo) return 'low';
158
+ if (count > hi) return 'high';
159
+ return 'ok';
160
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @startsimpli/ui/collection — shared entity-collection review workspaces
3
+ * (topic triage + draft compare/approve) for Foundry-templated tenant apps.
4
+ *
5
+ * Client-injected: pass a CollectionClient (the app's authed tenant API) and the
6
+ * workspace owns all the react-query/data logic, so the app wrapper is ~10 LOC.
7
+ */
8
+ export { TopicReviewWorkspace, type TopicReviewWorkspaceProps } from './TopicReviewWorkspace';
9
+ export { DraftReviewWorkspace, type DraftReviewWorkspaceProps } from './DraftReviewWorkspace';
10
+ export type { CollectionClient, EntityRecord, EntityTypeDef, AttributeDef, Paginated, DataType } from './types';
11
+ export { readData, toCamelKey } from './data';
12
+ export {
13
+ parseSources,
14
+ normUrl,
15
+ groupKeyOf,
16
+ candidateTitle,
17
+ candidateIndex,
18
+ isChosen,
19
+ groupDrafts,
20
+ wordTargets,
21
+ gradeWords,
22
+ type DraftSource,
23
+ type DraftGroup,
24
+ type CheckStatus,
25
+ } from './drafts';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @startsimpli/ui/collection — shared entity-collection review workspaces.
3
+ *
4
+ * These are the "system-of-record" review surfaces for a Foundry-templated
5
+ * tenant: a topic-review triage list and a draft-review compare/approve board.
6
+ * They are client-INJECTED — the consuming app passes a CollectionClient (its
7
+ * own authed tenant API) so the workspace owns all the react-query logic and the
8
+ * app wrapper stays ~10 LOC. Nothing here is OGMC-specific; the controlled-vocab
9
+ * markets + the optional record-detail drawer are props.
10
+ */
11
+
12
+ export type DataType = 'text' | 'longtext' | 'number' | 'integer' | 'boolean' | 'date' | 'enum' | 'json';
13
+
14
+ export interface AttributeDef {
15
+ id: string | number;
16
+ name: string;
17
+ dataType: DataType;
18
+ required: boolean;
19
+ config: Record<string, unknown>;
20
+ }
21
+
22
+ export interface EntityTypeDef {
23
+ id: string | number;
24
+ key: string;
25
+ label: string;
26
+ attributes: AttributeDef[];
27
+ }
28
+
29
+ export interface EntityRecord {
30
+ id: number;
31
+ entityType: string;
32
+ externalId: string | null;
33
+ name: string;
34
+ data: Record<string, unknown>;
35
+ createdAt: string;
36
+ }
37
+
38
+ export interface Paginated<T> {
39
+ count: number;
40
+ next: string | null;
41
+ previous: string | null;
42
+ results: T[];
43
+ }
44
+
45
+ /**
46
+ * The tenant-API surface a review workspace needs. The app implements this over
47
+ * its authed same-origin client (keys are camel/snake-normalized by that client;
48
+ * PATCH replaces the whole data blob, so callers send the full merged data).
49
+ */
50
+ export interface CollectionClient {
51
+ listTypes(): Promise<Paginated<EntityTypeDef>>;
52
+ listAllEntities(type: string): Promise<EntityRecord[]>;
53
+ updateEntity(id: number | string, input: { name?: string; data?: Record<string, unknown> }): Promise<EntityRecord>;
54
+ }