@deskwork/studio 0.10.1 → 0.11.0
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/dist/lib/editorial-skills-catalogue.d.ts +15 -1
- package/dist/lib/editorial-skills-catalogue.d.ts.map +1 -1
- package/dist/lib/editorial-skills-catalogue.js +66 -59
- package/dist/lib/editorial-skills-catalogue.js.map +1 -1
- package/dist/lib/entry-resolver.d.ts +22 -0
- package/dist/lib/entry-resolver.d.ts.map +1 -0
- package/dist/lib/entry-resolver.js +42 -0
- package/dist/lib/entry-resolver.js.map +1 -0
- package/dist/lib/stage-affordances.d.ts +31 -0
- package/dist/lib/stage-affordances.d.ts.map +1 -0
- package/dist/lib/stage-affordances.js +37 -0
- package/dist/lib/stage-affordances.js.map +1 -0
- package/dist/pages/dashboard/affordances.d.ts +41 -0
- package/dist/pages/dashboard/affordances.d.ts.map +1 -0
- package/dist/pages/dashboard/affordances.js +87 -0
- package/dist/pages/dashboard/affordances.js.map +1 -0
- package/dist/pages/dashboard/affordances.ts +95 -0
- package/dist/pages/dashboard/data.d.ts +24 -0
- package/dist/pages/dashboard/data.d.ts.map +1 -0
- package/dist/pages/dashboard/data.js +49 -0
- package/dist/pages/dashboard/data.js.map +1 -0
- package/dist/pages/dashboard/data.ts +56 -0
- package/dist/pages/dashboard/header.d.ts +13 -0
- package/dist/pages/dashboard/header.d.ts.map +1 -0
- package/dist/pages/dashboard/header.js +70 -0
- package/dist/pages/dashboard/header.js.map +1 -0
- package/dist/pages/dashboard/header.ts +80 -0
- package/dist/pages/dashboard/section.d.ts +37 -0
- package/dist/pages/dashboard/section.d.ts.map +1 -0
- package/dist/pages/dashboard/section.js +117 -0
- package/dist/pages/dashboard/section.js.map +1 -0
- package/dist/pages/dashboard/section.ts +132 -0
- package/dist/pages/dashboard.d.ts +30 -21
- package/dist/pages/dashboard.d.ts.map +1 -1
- package/dist/pages/dashboard.js +34 -799
- package/dist/pages/dashboard.js.map +1 -1
- package/dist/pages/dashboard.ts +44 -980
- package/dist/pages/entry-review.d.ts +25 -0
- package/dist/pages/entry-review.d.ts.map +1 -0
- package/dist/pages/entry-review.js +148 -0
- package/dist/pages/entry-review.js.map +1 -0
- package/dist/pages/entry-review.ts +185 -0
- package/dist/pages/help.d.ts +10 -5
- package/dist/pages/help.d.ts.map +1 -1
- package/dist/pages/help.js +113 -99
- package/dist/pages/help.js.map +1 -1
- package/dist/pages/help.ts +113 -99
- package/dist/pages/index.d.ts +13 -1
- package/dist/pages/index.d.ts.map +1 -1
- package/dist/pages/index.js +39 -24
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/index.ts +43 -27
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +23 -3
- package/dist/server.js.map +1 -1
- package/package.json +4 -4
package/dist/pages/dashboard.ts
CHANGED
|
@@ -1,1007 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Studio dashboard page — `/dev/editorial-studio`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Pipeline-redesign Task 34. The dashboard renders eight stage
|
|
5
|
+
* sections — Ideas → Planned → Outlining → Drafting → Final →
|
|
6
|
+
* Published, plus Blocked and Cancelled — backed by sidecar reads
|
|
7
|
+
* under `<projectRoot>/.deskwork/entries/*.json`. Each row carries
|
|
8
|
+
* the entry's iteration count for its current stage and a
|
|
9
|
+
* reviewState badge so an operator can see at a glance where each
|
|
10
|
+
* entry sits without opening it.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
* (
|
|
12
|
-
*
|
|
12
|
+
* Replaces the legacy calendar.md + workflow store rendering. The
|
|
13
|
+
* scaffold (folio, masthead, filter strip, layout) is preserved so
|
|
14
|
+
* existing CSS keeps working.
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* The renderer's data flow:
|
|
17
|
+
* 1. loadDashboardData reads every sidecar and groups by stage.
|
|
18
|
+
* 2. Each of the eight stages renders via `renderStageSection`.
|
|
19
|
+
* 3. The Distribution placeholder pins beneath the stage sections.
|
|
20
|
+
*
|
|
21
|
+
* The legacy export `renderDashboard` stays — server.ts wires it as
|
|
22
|
+
* the page handler. The `getIndex` parameter is preserved for
|
|
23
|
+
* signature compatibility with the override resolver in server.ts;
|
|
24
|
+
* the new dashboard does not currently consume it (sidecars are the
|
|
25
|
+
* data source, not the on-disk content tree).
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
|
-
import { readCalendar } from '@deskwork/core/calendar';
|
|
23
|
-
import {
|
|
24
|
-
buildReport,
|
|
25
|
-
type ReviewReport,
|
|
26
|
-
} from '@deskwork/core/review/report';
|
|
27
|
-
import { readWorkflows } from '@deskwork/core/review/pipeline';
|
|
28
|
-
import type { DraftWorkflowItem } from '@deskwork/core/review/types';
|
|
29
|
-
import { bodyState, type BodyState } from '@deskwork/core/body-state';
|
|
30
|
-
import { countScrapbook, countScrapbookForEntry } from '@deskwork/core/scrapbook';
|
|
31
|
-
import {
|
|
32
|
-
PLATFORMS,
|
|
33
|
-
STAGES,
|
|
34
|
-
effectiveContentType,
|
|
35
|
-
hasRepoContent,
|
|
36
|
-
type CalendarEntry,
|
|
37
|
-
type DistributionRecord,
|
|
38
|
-
type Platform,
|
|
39
|
-
type Stage,
|
|
40
|
-
} from '@deskwork/core/types';
|
|
41
|
-
import {
|
|
42
|
-
resolveCalendarPath,
|
|
43
|
-
resolveBlogFilePath,
|
|
44
|
-
findEntryFile,
|
|
45
|
-
} from '@deskwork/core/paths';
|
|
46
|
-
import type { ContentIndex } from '@deskwork/core/content-index';
|
|
47
28
|
import type { StudioContext } from '../routes/api.ts';
|
|
48
|
-
import { html, unsafe
|
|
29
|
+
import { html, unsafe } from './html.ts';
|
|
49
30
|
import { layout } from './layout.ts';
|
|
50
31
|
import { renderEditorialFolio } from './chrome.ts';
|
|
32
|
+
import { loadDashboardData, DASHBOARD_STAGE_ORDER } from './dashboard/data.ts';
|
|
33
|
+
import {
|
|
34
|
+
renderStageSection,
|
|
35
|
+
renderDistributionPlaceholder,
|
|
36
|
+
} from './dashboard/section.ts';
|
|
37
|
+
import { renderHeader, renderFilterStrip } from './dashboard/header.ts';
|
|
38
|
+
import type { ContentIndex } from '@deskwork/core/content-index';
|
|
51
39
|
|
|
52
40
|
/**
|
|
53
|
-
* Per-request content-index getter.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* dashboard falls back to the slug-template path.
|
|
41
|
+
* Per-request content-index getter. Preserved for compatibility with
|
|
42
|
+
* `runTemplateOverride` in server.ts — the override resolver calls
|
|
43
|
+
* the dashboard with `(ctx, getIndex)` and we keep the signature
|
|
44
|
+
* symmetric. Sidecar-driven rendering does not consume it directly.
|
|
58
45
|
*/
|
|
59
46
|
export type DashboardIndexGetter = (site: string) => ContentIndex;
|
|
60
47
|
|
|
61
|
-
interface SitedEntry {
|
|
62
|
-
site: string;
|
|
63
|
-
entry: CalendarEntry;
|
|
64
|
-
}
|
|
65
|
-
interface SitedDistribution {
|
|
66
|
-
site: string;
|
|
67
|
-
platform: string;
|
|
68
|
-
slug: string;
|
|
69
|
-
/**
|
|
70
|
-
* Stable id of the joined calendar entry. Phase 19d: keys
|
|
71
|
-
* (site, entryId) when present, falls back to (site, slug) for
|
|
72
|
-
* pre-id distribution records.
|
|
73
|
-
*/
|
|
74
|
-
entryId: string | null;
|
|
75
|
-
shortform: boolean;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const PLATFORMS_ORDER: readonly Platform[] = [
|
|
79
|
-
'reddit',
|
|
80
|
-
'linkedin',
|
|
81
|
-
'youtube',
|
|
82
|
-
'instagram',
|
|
83
|
-
];
|
|
84
|
-
|
|
85
|
-
const STAGE_ORNAMENTS: Record<Stage, string> = {
|
|
86
|
-
Ideas: '◇',
|
|
87
|
-
Planned: '§',
|
|
88
|
-
Outlining: '⊹',
|
|
89
|
-
Drafting: '✎',
|
|
90
|
-
Review: '※',
|
|
91
|
-
Paused: '⏸',
|
|
92
|
-
Published: '✓',
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const MONTH_NAMES = [
|
|
96
|
-
'January', 'February', 'March', 'April', 'May', 'June',
|
|
97
|
-
'July', 'August', 'September', 'October', 'November', 'December',
|
|
98
|
-
];
|
|
99
|
-
|
|
100
|
-
function isPlatform(value: string): value is Platform {
|
|
101
|
-
return (PLATFORMS as readonly string[]).includes(value);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function siteLabel(site: string): string {
|
|
105
|
-
return site.slice(0, 2).toUpperCase();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function stateLabel(state: string): string {
|
|
109
|
-
return state.replace('-', ' ');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Internal correlation key for the dashboard's `Map<key, …>` joins.
|
|
114
|
-
* Phase 19d: prefer the calendar entry's stable UUID when present,
|
|
115
|
-
* falling back to the slug for legacy data (workflows / entries
|
|
116
|
-
* created before frontmatter ids landed). The function is overloaded
|
|
117
|
-
* via two arities — `covKey(site, slug)` for slug-only callers and
|
|
118
|
-
* `covKey(site, slug, entryId)` for callers that have access to the
|
|
119
|
-
* id. The latter form picks `entryId` when it's a non-empty string,
|
|
120
|
-
* else falls through to `slug`. Display still uses slug as the human
|
|
121
|
-
* label; this key only correlates internally.
|
|
122
|
-
*
|
|
123
|
-
* The "fallback" here is the legacy migration path — not the kind of
|
|
124
|
-
* silent fallback the project rules forbid. Doctor reports the legacy
|
|
125
|
-
* cases so operators can backfill ids.
|
|
126
|
-
*/
|
|
127
|
-
function covKey(site: string, slug: string, entryId?: string | null): string {
|
|
128
|
-
const stable = entryId !== undefined && entryId !== null && entryId !== ''
|
|
129
|
-
? entryId
|
|
130
|
-
: slug;
|
|
131
|
-
return `${site}::${stable}`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function fmtRelTime(iso: string, now: Date): string {
|
|
135
|
-
const t = new Date(iso).getTime();
|
|
136
|
-
const s = Math.max(0, Math.floor((now.getTime() - t) / 1000));
|
|
137
|
-
if (s < 60) return `${s}s ago`;
|
|
138
|
-
const m = Math.floor(s / 60);
|
|
139
|
-
if (m < 60) return `${m}m ago`;
|
|
140
|
-
const h = Math.floor(m / 60);
|
|
141
|
-
if (h < 48) return `${h}h ago`;
|
|
142
|
-
return `${Math.floor(h / 24)}d ago`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function workflowLink(w: DraftWorkflowItem): string {
|
|
146
|
-
if (w.contentKind === 'shortform') {
|
|
147
|
-
// Phase 21c: shortform now renders inside the unified review
|
|
148
|
-
// surface. Workflow-id deep-links land on the right workflow
|
|
149
|
-
// without first resolving an entry id — the route handler
|
|
150
|
-
// recognises a workflow id and dispatches accordingly.
|
|
151
|
-
return `/dev/editorial-review/${w.id}`;
|
|
152
|
-
}
|
|
153
|
-
// Phase 19d: prefer the canonical id-based URL when the workflow
|
|
154
|
-
// carries entryId. The legacy slug URL still works (server.ts will
|
|
155
|
-
// 302-redirect it), but emitting the canonical form skips the
|
|
156
|
-
// redirect round trip and makes the UI's outbound links honest.
|
|
157
|
-
const key = w.entryId ?? w.slug;
|
|
158
|
-
const kindBit = w.contentKind === 'outline' ? '&kind=outline' : '';
|
|
159
|
-
return `/dev/editorial-review/${key}?site=${w.site}${kindBit}`;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function blogPreviewLink(site: string, slug: string, host: string, entry: CalendarEntry): string {
|
|
163
|
-
if (entry.stage === 'Published') return `https://${host}/blog/${slug}/`;
|
|
164
|
-
// Phase 19d: prefer the canonical id-based URL.
|
|
165
|
-
const key = entry.id ?? slug;
|
|
166
|
-
return `/dev/editorial-review/${key}?site=${site}`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
48
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* The content-detail page (`/dev/content/<site>/<root>?node=<slug>`)
|
|
173
|
-
* is the right surface — it shows frontmatter + body + scrapbook for
|
|
174
|
-
* the entry without requiring a review workflow. Workflow-linked
|
|
175
|
-
* entries keep their `/dev/editorial-review/<id>` target via
|
|
176
|
-
* `workflowLink` above.
|
|
177
|
-
*
|
|
178
|
-
* The `<root>` segment is the entry slug's first path segment (the
|
|
179
|
-
* project root). For flat slugs (`the-outbound`), the project root
|
|
180
|
-
* IS the slug itself; for hierarchical slugs (`projects/x/y`), it's
|
|
181
|
-
* the first segment.
|
|
49
|
+
* Render the studio dashboard. Async because sidecar reads hit disk;
|
|
50
|
+
* the route handler in server.ts awaits the result before sending it.
|
|
182
51
|
*/
|
|
183
|
-
function
|
|
184
|
-
const root = slug.split('/')[0];
|
|
185
|
-
return `/dev/content/${encodeURIComponent(site)}/${encodeURIComponent(root)}?node=${encodeURIComponent(slug)}`;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
interface DashboardData {
|
|
189
|
-
calendarEntries: SitedEntry[];
|
|
190
|
-
distributions: SitedDistribution[];
|
|
191
|
-
slugsBySite: Record<string, string[]>;
|
|
192
|
-
workflows: DraftWorkflowItem[];
|
|
193
|
-
approved: DraftWorkflowItem[];
|
|
194
|
-
terminal: DraftWorkflowItem[];
|
|
195
|
-
publishedBlogEntries: SitedEntry[];
|
|
196
|
-
shortformCoverage: Map<string, Set<Platform>>;
|
|
197
|
-
activeBySitedSlug: Map<string, DraftWorkflowItem[]>;
|
|
198
|
-
report: ReviewReport;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function loadDashboardData(
|
|
52
|
+
export async function renderDashboard(
|
|
202
53
|
ctx: StudioContext,
|
|
203
54
|
getIndex?: DashboardIndexGetter,
|
|
204
|
-
):
|
|
205
|
-
//
|
|
206
|
-
// entryBodyStateOf, not here. Threading it through keeps the call
|
|
207
|
-
// signature symmetric with renderDashboard and leaves room for
|
|
208
|
-
// future load-time uses (e.g., joining workflows by entry id).
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
// Touch the parameter so the unused-param check stays satisfied.
|
|
209
57
|
void getIndex;
|
|
210
|
-
const calendarEntries: SitedEntry[] = [];
|
|
211
|
-
const distributions: SitedDistribution[] = [];
|
|
212
|
-
const slugsBySite: Record<string, string[]> = {};
|
|
213
|
-
const sites = Object.keys(ctx.config.sites);
|
|
214
|
-
|
|
215
|
-
for (const site of sites) {
|
|
216
|
-
slugsBySite[site] = [];
|
|
217
|
-
const calendarPath = resolveCalendarPath(ctx.projectRoot, ctx.config, site);
|
|
218
|
-
const cal = readCalendar(calendarPath);
|
|
219
|
-
// Build a slug → id map up front so distributions can resolve
|
|
220
|
-
// their entry's stable id even when the DistributionRecord
|
|
221
|
-
// pre-dates the entryId field.
|
|
222
|
-
const idBySlug = new Map<string, string>();
|
|
223
|
-
for (const entry of cal.entries) {
|
|
224
|
-
calendarEntries.push({ site, entry });
|
|
225
|
-
slugsBySite[site].push(entry.slug);
|
|
226
|
-
if (entry.id) idBySlug.set(entry.slug, entry.id);
|
|
227
|
-
}
|
|
228
|
-
for (const d of cal.distributions) {
|
|
229
|
-
const dr: DistributionRecord = d;
|
|
230
|
-
const entryId = dr.entryId ?? idBySlug.get(dr.slug) ?? null;
|
|
231
|
-
distributions.push({
|
|
232
|
-
site,
|
|
233
|
-
platform: dr.platform,
|
|
234
|
-
slug: dr.slug,
|
|
235
|
-
entryId,
|
|
236
|
-
shortform: typeof dr.shortform === 'string' && dr.shortform.length > 0,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const workflows = readWorkflows(ctx.projectRoot, ctx.config);
|
|
242
|
-
const approved = workflows.filter((w) => w.state === 'approved');
|
|
243
|
-
const terminal = workflows
|
|
244
|
-
.filter((w) => w.state === 'applied' || w.state === 'cancelled')
|
|
245
|
-
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
246
|
-
.slice(0, 10);
|
|
247
|
-
|
|
248
|
-
const shortformCoverage = new Map<string, Set<Platform>>();
|
|
249
|
-
for (const d of distributions) {
|
|
250
|
-
if (!d.shortform) continue;
|
|
251
|
-
if (!isPlatform(d.platform)) continue;
|
|
252
|
-
const key = covKey(d.site, d.slug, d.entryId);
|
|
253
|
-
const set = shortformCoverage.get(key) ?? new Set<Platform>();
|
|
254
|
-
set.add(d.platform);
|
|
255
|
-
shortformCoverage.set(key, set);
|
|
256
|
-
}
|
|
257
|
-
// Phase 21c — shortform workflows count as coverage too. Distributions
|
|
258
|
-
// come from the calendar (an `editorial-publish` side-effect), so a
|
|
259
|
-
// freshly-started shortform draft (no published-yet distribution
|
|
260
|
-
// record) wouldn't show in the matrix without this branch.
|
|
261
|
-
for (const w of workflows) {
|
|
262
|
-
if (w.contentKind !== 'shortform') continue;
|
|
263
|
-
if (w.state === 'applied' || w.state === 'cancelled') continue;
|
|
264
|
-
if (!w.platform || !isPlatform(w.platform)) continue;
|
|
265
|
-
const key = covKey(w.site, w.slug, w.entryId);
|
|
266
|
-
const set = shortformCoverage.get(key) ?? new Set<Platform>();
|
|
267
|
-
set.add(w.platform);
|
|
268
|
-
shortformCoverage.set(key, set);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const publishedBlogEntries = calendarEntries
|
|
272
|
-
.filter(
|
|
273
|
-
({ entry }) =>
|
|
274
|
-
entry.stage === 'Published' && effectiveContentType(entry) === 'blog',
|
|
275
|
-
)
|
|
276
|
-
.sort((a, b) =>
|
|
277
|
-
(b.entry.datePublished ?? '').localeCompare(a.entry.datePublished ?? ''),
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
const activeBySitedSlug = new Map<string, DraftWorkflowItem[]>();
|
|
281
|
-
for (const w of workflows) {
|
|
282
|
-
if (w.state === 'applied' || w.state === 'cancelled') continue;
|
|
283
|
-
const key = covKey(w.site, w.slug, w.entryId);
|
|
284
|
-
const list = activeBySitedSlug.get(key) ?? [];
|
|
285
|
-
list.push(w);
|
|
286
|
-
activeBySitedSlug.set(key, list);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const report: ReviewReport = buildReport(ctx.projectRoot, ctx.config, {});
|
|
290
|
-
|
|
291
|
-
return {
|
|
292
|
-
calendarEntries,
|
|
293
|
-
distributions,
|
|
294
|
-
slugsBySite,
|
|
295
|
-
workflows,
|
|
296
|
-
approved,
|
|
297
|
-
terminal,
|
|
298
|
-
publishedBlogEntries,
|
|
299
|
-
shortformCoverage,
|
|
300
|
-
activeBySitedSlug,
|
|
301
|
-
report,
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function entryBodyStateOf(
|
|
306
|
-
ctx: StudioContext,
|
|
307
|
-
site: string,
|
|
308
|
-
entry: CalendarEntry,
|
|
309
|
-
getIndex?: DashboardIndexGetter,
|
|
310
|
-
): BodyState {
|
|
311
|
-
if (!hasRepoContent(effectiveContentType(entry))) return 'missing';
|
|
312
|
-
// When the entry has a stable id AND the route layer wired in a
|
|
313
|
-
// per-request index getter, use the id-driven content-index lookup
|
|
314
|
-
// — this matches files whose path doesn't follow the slug template
|
|
315
|
-
// (e.g., writingcontrol-shape projects where slug `the-outbound`
|
|
316
|
-
// resolves to `projects/the-outbound/index.md` while the calendar
|
|
317
|
-
// slug doesn't bake the path). Without an id or getter, fall back
|
|
318
|
-
// to the slug-template behavior so non-route callers still work.
|
|
319
|
-
if (entry.id !== undefined && entry.id !== '' && getIndex) {
|
|
320
|
-
const path = findEntryFile(
|
|
321
|
-
ctx.projectRoot,
|
|
322
|
-
ctx.config,
|
|
323
|
-
site,
|
|
324
|
-
entry.id,
|
|
325
|
-
getIndex(site),
|
|
326
|
-
{ slug: entry.slug },
|
|
327
|
-
);
|
|
328
|
-
if (path !== undefined) return bodyState(path);
|
|
329
|
-
}
|
|
330
|
-
const fallback = resolveBlogFilePath(ctx.projectRoot, ctx.config, site, entry.slug);
|
|
331
|
-
return bodyState(fallback);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function findStageWorkflow(
|
|
335
|
-
data: DashboardData,
|
|
336
|
-
site: string,
|
|
337
|
-
entry: CalendarEntry,
|
|
338
|
-
stage: Stage,
|
|
339
|
-
): DraftWorkflowItem | undefined {
|
|
340
|
-
const list =
|
|
341
|
-
data.activeBySitedSlug.get(covKey(site, entry.slug, entry.id)) ?? [];
|
|
342
|
-
if (stage === 'Outlining') return list.find((w) => w.contentKind === 'outline');
|
|
343
|
-
return list.find((w) => w.contentKind === 'longform');
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// ---------------------------------------------------------------------------
|
|
347
|
-
// Section renderers
|
|
348
|
-
// ---------------------------------------------------------------------------
|
|
349
58
|
|
|
350
|
-
|
|
351
|
-
data: DashboardData,
|
|
352
|
-
ctx: StudioContext,
|
|
353
|
-
now: Date,
|
|
354
|
-
): RawHtml {
|
|
355
|
-
const volume = '01';
|
|
356
|
-
const issueNum = String(data.workflows.length).padStart(2, '0');
|
|
357
|
-
const issueDate = `${now.getDate()} ${MONTH_NAMES[now.getMonth()]} ${now.getFullYear()}`;
|
|
358
|
-
return unsafe(html`
|
|
359
|
-
<header class="er-pagehead er-pagehead--centered">
|
|
360
|
-
<p class="er-pagehead__kicker">
|
|
361
|
-
Vol. ${volume} · № ${issueNum} · Press-check
|
|
362
|
-
</p>
|
|
363
|
-
<h1 class="er-pagehead__title">
|
|
364
|
-
Editorial <em>Studio</em>
|
|
365
|
-
</h1>
|
|
366
|
-
<p class="er-pagehead__deck">
|
|
367
|
-
Project: <code>${ctx.projectRoot}</code>
|
|
368
|
-
· <a class="er-link-marginalia" href="/dev/editorial-help">the manual</a>
|
|
369
|
-
</p>
|
|
370
|
-
<p class="er-pagehead__meta">
|
|
371
|
-
<span>${issueDate}</span>
|
|
372
|
-
<span class="sep">·</span>
|
|
373
|
-
<span>${data.calendarEntries.length} on the calendar</span>
|
|
374
|
-
<span class="sep">·</span>
|
|
375
|
-
<span>${data.activeBySitedSlug.size} in review</span>
|
|
376
|
-
<span class="sep">·</span>
|
|
377
|
-
<span>${data.approved.length} awaiting press</span>
|
|
378
|
-
</p>
|
|
379
|
-
</header>`);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function renderFilterStrip(sites: readonly string[]): RawHtml {
|
|
383
|
-
return unsafe(html`
|
|
384
|
-
<section class="er-filter" data-filter-strip>
|
|
385
|
-
<span class="er-filter-label">Find</span>
|
|
386
|
-
<input type="search" data-filter-input placeholder="slug, title…" autocomplete="off" />
|
|
387
|
-
<span class="er-filter-label er-filter-label--gap">Site</span>
|
|
388
|
-
<div class="er-chips" role="tablist">
|
|
389
|
-
<button class="er-chip" aria-pressed="true" data-site-chip="all">all</button>
|
|
390
|
-
${sites.map(
|
|
391
|
-
(s) =>
|
|
392
|
-
unsafe(html`<button class="er-chip" data-site-chip="${s}">${siteLabel(s).toLowerCase()}</button>`),
|
|
393
|
-
)}
|
|
394
|
-
</div>
|
|
395
|
-
<span class="er-filter-label er-filter-label--gap">Stage</span>
|
|
396
|
-
<div class="er-chips" role="tablist">
|
|
397
|
-
<button class="er-chip" aria-pressed="true" data-stage-chip="all">all</button>
|
|
398
|
-
${STAGES.map(
|
|
399
|
-
(s) =>
|
|
400
|
-
unsafe(html`<button class="er-chip" data-stage-chip="${s}">${s.toLowerCase()}</button>`),
|
|
401
|
-
)}
|
|
402
|
-
</div>
|
|
403
|
-
</section>`);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const STAGE_EMPTY_MESSAGES: Record<Stage, string> = {
|
|
407
|
-
Ideas: 'No open ideas. Run /deskwork:add to capture one.',
|
|
408
|
-
Planned: 'Nothing planned. /deskwork:plan <slug> to promote an idea.',
|
|
409
|
-
Outlining: 'Nothing in outlining. /deskwork:outline <slug> to start one.',
|
|
410
|
-
Drafting: 'No posts in drafting.',
|
|
411
|
-
Review: 'Nothing in review stage.',
|
|
412
|
-
Paused: 'Nothing paused. /deskwork:pause <slug> sets an entry aside without losing where it was.',
|
|
413
|
-
Published: 'No published posts yet.',
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
function renderRowMeta(
|
|
417
|
-
ctx: StudioContext,
|
|
418
|
-
site: string,
|
|
419
|
-
entry: CalendarEntry,
|
|
420
|
-
stage: Stage,
|
|
421
|
-
hasFile: boolean,
|
|
422
|
-
getIndex?: DashboardIndexGetter,
|
|
423
|
-
): RawHtml {
|
|
424
|
-
const kind = effectiveContentType(entry);
|
|
425
|
-
const parts: RawHtml[] = [];
|
|
426
|
-
if (entry.targetKeywords && entry.targetKeywords.length > 0 && stage === 'Planned') {
|
|
427
|
-
parts.push(
|
|
428
|
-
unsafe(html`<span class="er-calendar-meta"><em>kw:</em> ${entry.targetKeywords.join(', ')}</span>`),
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
if (entry.issueNumber && entry.issueNumber > 0) {
|
|
432
|
-
parts.push(unsafe(html`<span class="er-calendar-meta">issue #${entry.issueNumber}</span>`));
|
|
433
|
-
}
|
|
434
|
-
if (entry.datePublished && stage === 'Published') {
|
|
435
|
-
parts.push(unsafe(html`<span class="er-calendar-meta">${entry.datePublished}</span>`));
|
|
436
|
-
}
|
|
437
|
-
if (stage === 'Paused' && entry.pausedFrom) {
|
|
438
|
-
parts.push(
|
|
439
|
-
unsafe(html`<span class="er-calendar-meta"><em>was:</em> ${entry.pausedFrom}</span>`),
|
|
440
|
-
);
|
|
441
|
-
}
|
|
442
|
-
if (kind !== 'blog') {
|
|
443
|
-
parts.push(unsafe(html`<span class="er-calendar-meta er-calendar-meta-kind">${kind}</span>`));
|
|
444
|
-
}
|
|
445
|
-
if (kind === 'blog' && hasFile) {
|
|
446
|
-
// Phase 19c+: prefer the id-driven content-index lookup so entries
|
|
447
|
-
// whose on-disk path doesn't match the slug template (e.g.
|
|
448
|
-
// writingcontrol-shape projects) report the correct count. When the
|
|
449
|
-
// entry has no id binding OR no per-request index getter is wired,
|
|
450
|
-
// fall through to the slug-template path. The fallback is the
|
|
451
|
-
// legacy migration path, not a silent default — doctor reports the
|
|
452
|
-
// unbound cases so operators can backfill ids.
|
|
453
|
-
const n =
|
|
454
|
-
entry.id !== undefined && entry.id !== '' && getIndex
|
|
455
|
-
? countScrapbookForEntry(
|
|
456
|
-
ctx.projectRoot,
|
|
457
|
-
ctx.config,
|
|
458
|
-
site,
|
|
459
|
-
entry,
|
|
460
|
-
getIndex(site),
|
|
461
|
-
)
|
|
462
|
-
: countScrapbook(ctx.projectRoot, ctx.config, site, entry.slug);
|
|
463
|
-
if (n > 0) {
|
|
464
|
-
const label = n === 1 ? 'scrapbook item' : 'scrapbook items';
|
|
465
|
-
parts.push(
|
|
466
|
-
unsafe(html`<a class="er-calendar-meta er-calendar-meta-scrapbook er-calendar-meta-link"
|
|
467
|
-
href="/dev/scrapbook/${site}/${entry.slug}"
|
|
468
|
-
title="${n} ${label}">scrapbook · <span class="er-calendar-meta-scrapbook-count">${n}</span> →</a>`),
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
return unsafe(parts.map((p) => p.__raw).join(''));
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function renderRowActions(
|
|
476
|
-
site: string,
|
|
477
|
-
entry: CalendarEntry,
|
|
478
|
-
stage: Stage,
|
|
479
|
-
hasFile: boolean,
|
|
480
|
-
bodyWritten: boolean,
|
|
481
|
-
wf: DraftWorkflowItem | undefined,
|
|
482
|
-
): RawHtml {
|
|
483
|
-
const kind = effectiveContentType(entry);
|
|
484
|
-
const buttons: string[] = [];
|
|
485
|
-
if (stage === 'Ideas') {
|
|
486
|
-
buttons.push(html`<button class="er-btn er-btn-small er-copy-btn" type="button"
|
|
487
|
-
data-copy="/deskwork:plan --site ${site} ${entry.slug}" title="copy command">plan →</button>`);
|
|
488
|
-
}
|
|
489
|
-
if (stage === 'Planned' && !hasFile) {
|
|
490
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-primary" type="button"
|
|
491
|
-
data-action="scaffold-draft" data-site="${site}" data-slug="${entry.slug}">scaffold →</button>`);
|
|
492
|
-
}
|
|
493
|
-
if (stage === 'Planned' && hasFile) {
|
|
494
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-primary er-copy-btn" type="button"
|
|
495
|
-
data-copy="/deskwork:outline --site ${site} ${entry.slug}"
|
|
496
|
-
title="scaffold file exists — copy to sketch the outline in Claude Code">outline →</button>`);
|
|
497
|
-
}
|
|
498
|
-
if (stage === 'Outlining' && wf && wf.state === 'iterating') {
|
|
499
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-primary er-copy-btn" type="button"
|
|
500
|
-
data-copy="/deskwork:iterate --kind outline --site ${site} ${entry.slug}"
|
|
501
|
-
title="operator clicked Iterate">iterate outline →</button>`);
|
|
502
|
-
}
|
|
503
|
-
if (stage === 'Outlining' && wf && wf.state === 'approved') {
|
|
504
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-approve er-copy-btn" type="button"
|
|
505
|
-
data-copy="/deskwork:approve --site ${site} ${entry.slug}"
|
|
506
|
-
title="outline approved — advance to Drafting">approve outline →</button>`);
|
|
507
|
-
}
|
|
508
|
-
if (
|
|
509
|
-
stage === 'Outlining' &&
|
|
510
|
-
wf &&
|
|
511
|
-
(wf.state === 'open' || wf.state === 'in-review')
|
|
512
|
-
) {
|
|
513
|
-
buttons.push(html`<a class="er-btn er-btn-small" href="${workflowLink(wf)}"
|
|
514
|
-
title="open the review surface to annotate / edit the outline">review outline →</a>`);
|
|
515
|
-
}
|
|
516
|
-
if (stage === 'Outlining' && !wf) {
|
|
517
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-primary er-copy-btn" type="button"
|
|
518
|
-
data-copy="/deskwork:outline --site ${site} ${entry.slug}"
|
|
519
|
-
title="no outline workflow found — copy to (re)start one">outline →</button>`);
|
|
520
|
-
}
|
|
521
|
-
if ((stage === 'Drafting' || stage === 'Review') && !bodyWritten) {
|
|
522
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-primary er-copy-btn" type="button"
|
|
523
|
-
data-copy="/deskwork:draft --site ${site} ${entry.slug}"
|
|
524
|
-
title="body is still the placeholder">draft body →</button>`);
|
|
525
|
-
}
|
|
526
|
-
if ((stage === 'Drafting' || stage === 'Review') && bodyWritten && !wf) {
|
|
527
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-primary" type="button"
|
|
528
|
-
data-action="enqueue-review" data-site="${site}" data-slug="${entry.slug}"
|
|
529
|
-
title="body is drafted — create a longform review workflow">review →</button>`);
|
|
530
|
-
}
|
|
531
|
-
if (stage === 'Drafting' || stage === 'Review') {
|
|
532
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-approve" type="button"
|
|
533
|
-
data-action="mark-published" data-site="${site}" data-slug="${entry.slug}"
|
|
534
|
-
title="flip to Published + set date">publish →</button>`);
|
|
535
|
-
}
|
|
536
|
-
if (stage === 'Published' && !wf) {
|
|
537
|
-
buttons.push(html`<button class="er-btn er-btn-small er-copy-btn" type="button"
|
|
538
|
-
data-copy="/deskwork:review-start --site ${site} ${entry.slug}"
|
|
539
|
-
title="re-review a published post">re-review</button>`);
|
|
540
|
-
}
|
|
541
|
-
// #27 — Paused gets a "resume" copy; pausable stages get a "pause" copy.
|
|
542
|
-
if (stage === 'Paused') {
|
|
543
|
-
buttons.push(html`<button class="er-btn er-btn-small er-btn-primary er-copy-btn" type="button"
|
|
544
|
-
data-copy="/deskwork:resume --site ${site} ${entry.slug}"
|
|
545
|
-
title="restore to ${entry.pausedFrom ?? 'prior stage'}">resume →</button>`);
|
|
546
|
-
} else if (
|
|
547
|
-
stage === 'Ideas' ||
|
|
548
|
-
stage === 'Planned' ||
|
|
549
|
-
stage === 'Outlining' ||
|
|
550
|
-
stage === 'Drafting' ||
|
|
551
|
-
stage === 'Review'
|
|
552
|
-
) {
|
|
553
|
-
buttons.push(html`<button class="er-btn er-btn-small er-copy-btn" type="button"
|
|
554
|
-
data-copy="/deskwork:pause --site ${site} ${entry.slug}"
|
|
555
|
-
title="set aside without losing the prior stage">pause</button>`);
|
|
556
|
-
}
|
|
557
|
-
if (kind === 'blog') {
|
|
558
|
-
buttons.push(html`<button class="er-btn er-btn-small" type="button" data-action="rename-open"
|
|
559
|
-
title="rename the slug">rename →</button>`);
|
|
560
|
-
}
|
|
561
|
-
return unsafe(`<span class="er-calendar-action">${buttons.join('')}</span>`);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function renderRow(
|
|
565
|
-
ctx: StudioContext,
|
|
566
|
-
data: DashboardData,
|
|
567
|
-
sited: SitedEntry,
|
|
568
|
-
stage: Stage,
|
|
569
|
-
index: number,
|
|
570
|
-
getIndex?: DashboardIndexGetter,
|
|
571
|
-
): RawHtml {
|
|
572
|
-
const { site, entry } = sited;
|
|
573
|
-
const kind = effectiveContentType(entry);
|
|
574
|
-
const body = entryBodyStateOf(ctx, site, entry, getIndex);
|
|
575
|
-
const hasFile = body !== 'missing';
|
|
576
|
-
const bodyWritten = body === 'written';
|
|
577
|
-
const wf = findStageWorkflow(data, site, entry, stage);
|
|
578
|
-
const search = [
|
|
579
|
-
entry.slug,
|
|
580
|
-
entry.title,
|
|
581
|
-
(entry.targetKeywords ?? []).join(' '),
|
|
582
|
-
kind,
|
|
583
|
-
site,
|
|
584
|
-
].join(' ').toLowerCase();
|
|
585
|
-
const host = ctx.config.sites[site]?.host ?? site;
|
|
586
|
-
// Issue #110: every row gets a link target. Workflow-linked rows
|
|
587
|
-
// open the review surface; Published rows link to the public host
|
|
588
|
-
// URL; everything else links to the content-detail page so the
|
|
589
|
-
// operator can read the entry's frontmatter, body, and scrapbook
|
|
590
|
-
// without inventing a workflow.
|
|
591
|
-
const slugLink = wf
|
|
592
|
-
? workflowLink(wf)
|
|
593
|
-
: entry.stage === 'Published'
|
|
594
|
-
? blogPreviewLink(site, entry.slug, host, entry)
|
|
595
|
-
: contentDetailLink(site, entry.slug);
|
|
596
|
-
const slugTitle = wf
|
|
597
|
-
? 'open the review surface'
|
|
598
|
-
: entry.stage === 'Published'
|
|
599
|
-
? 'read the published article'
|
|
600
|
-
: 'view content details';
|
|
601
|
-
const slugCell = unsafe(
|
|
602
|
-
html`<a href="${slugLink}" title="${slugTitle}">${entry.slug}</a>`,
|
|
603
|
-
);
|
|
604
|
-
|
|
605
|
-
const fileDot = hasRepoContent(kind)
|
|
606
|
-
? unsafe(html`<span class="er-file-dot er-file-${body}"
|
|
607
|
-
title="${body === 'missing'
|
|
608
|
-
? 'no blog file'
|
|
609
|
-
: body === 'placeholder'
|
|
610
|
-
? 'scaffold present, body is the placeholder'
|
|
611
|
-
: 'body written'}">●</span>`)
|
|
612
|
-
: '';
|
|
613
|
-
const stamp = wf
|
|
614
|
-
? unsafe(html`<span class="er-stamp er-stamp-${wf.state}">${stateLabel(wf.state)} v${wf.currentVersion}</span>`)
|
|
615
|
-
: '';
|
|
616
|
-
|
|
617
|
-
const renameForm =
|
|
618
|
-
kind === 'blog'
|
|
619
|
-
? unsafe(html`<form class="er-rename-inline" data-rename-form
|
|
620
|
-
data-site="${site}" data-slug="${entry.slug}" hidden>
|
|
621
|
-
<span class="er-rename-kicker" aria-hidden="true">rename →</span>
|
|
622
|
-
<code class="er-rename-old" title="current slug; will 301 after rename">${entry.slug}</code>
|
|
623
|
-
<span class="er-rename-arrow" aria-hidden="true">→</span>
|
|
624
|
-
<input type="text" name="new-slug" data-rename-input autocomplete="off" spellcheck="false"
|
|
625
|
-
placeholder="new-slug-here" aria-label="new slug" required />
|
|
626
|
-
<small class="er-rename-hint" data-rename-hint>lowercase, digits, hyphens</small>
|
|
627
|
-
<button type="button" class="er-btn er-btn-small" data-action="rename-cancel">cancel</button>
|
|
628
|
-
<button type="submit" class="er-btn er-btn-small er-btn-primary"
|
|
629
|
-
data-action="rename-copy">copy /rename →</button>
|
|
630
|
-
</form>`)
|
|
631
|
-
: '';
|
|
632
|
-
|
|
633
|
-
// Hierarchical entries (slugs containing `/`) get a depth indicator the
|
|
634
|
-
// CSS layer indents off of. Storage stays flat; this is a display-only
|
|
635
|
-
// marker. Depth = number of `/` in the slug (so "the-outbound" → 0,
|
|
636
|
-
// "the-outbound/characters" → 1, etc.).
|
|
637
|
-
const depth = entry.slug.split('/').length - 1;
|
|
638
|
-
const depthAttrs =
|
|
639
|
-
depth > 0
|
|
640
|
-
? unsafe(html` data-depth="${depth}" style="--er-row-depth: ${depth}"`)
|
|
641
|
-
: '';
|
|
642
|
-
// For nested entries, show only the leaf segment as the prominent
|
|
643
|
-
// identifier — the ancestor segments are implied by the visual indent
|
|
644
|
-
// and the position in the sorted list. Issue #110: the hierarchical
|
|
645
|
-
// shape still wraps in an <a> so the row has a link target.
|
|
646
|
-
const slugDisplay =
|
|
647
|
-
depth > 0
|
|
648
|
-
? unsafe(
|
|
649
|
-
html`<a href="${slugLink}" title="${slugTitle}"><span class="er-row-slug-ancestors" aria-hidden="true">${entry.slug.slice(0, entry.slug.lastIndexOf('/') + 1)}</span><span class="er-row-slug-leaf">${entry.slug.slice(entry.slug.lastIndexOf('/') + 1)}</span></a>`,
|
|
650
|
-
)
|
|
651
|
-
: '';
|
|
652
|
-
const slugCellWithHierarchy = depth > 0 ? slugDisplay : slugCell;
|
|
653
|
-
|
|
654
|
-
return unsafe(html`
|
|
655
|
-
<div class="er-calendar-row-wrap" data-row-wrap data-search="${search}"${depthAttrs}>
|
|
656
|
-
<div class="er-calendar-row" data-stage="${stage}" data-site="${site}"
|
|
657
|
-
data-slug="${entry.slug}" data-search="${search}">
|
|
658
|
-
<span class="er-row-num">№ ${String(index + 1).padStart(2, '0')}</span>
|
|
659
|
-
<div class="er-calendar-body">
|
|
660
|
-
<span class="er-row-site er-row-site--${site}" title="${host}">${siteLabel(site)}</span>
|
|
661
|
-
<span class="er-row-slug">${depth > 0 ? slugCellWithHierarchy : slugCell}</span>
|
|
662
|
-
<span class="er-calendar-title">${entry.title}</span>
|
|
663
|
-
${renderRowMeta(ctx, site, entry, stage, hasFile, getIndex)}
|
|
664
|
-
</div>
|
|
665
|
-
<span class="er-calendar-status">${fileDot}${stamp}</span>
|
|
666
|
-
${renderRowActions(site, entry, stage, hasFile, bodyWritten, wf)}
|
|
667
|
-
</div>
|
|
668
|
-
${renameForm}
|
|
669
|
-
</div>`);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function renderStageSection(
|
|
673
|
-
ctx: StudioContext,
|
|
674
|
-
data: DashboardData,
|
|
675
|
-
stage: Stage,
|
|
676
|
-
entries: SitedEntry[],
|
|
677
|
-
sites: readonly string[],
|
|
678
|
-
getIndex?: DashboardIndexGetter,
|
|
679
|
-
): RawHtml {
|
|
680
|
-
const intakeBlock =
|
|
681
|
-
stage === 'Ideas'
|
|
682
|
-
? unsafe(html`
|
|
683
|
-
<div class="er-intake-form" data-intake-form hidden>
|
|
684
|
-
<p class="er-intake-hint">
|
|
685
|
-
Fill in what you know; the agent will use the values verbatim.
|
|
686
|
-
</p>
|
|
687
|
-
<div class="er-intake-grid">
|
|
688
|
-
<label>
|
|
689
|
-
<span>Site</span>
|
|
690
|
-
<select data-intake-field="site">
|
|
691
|
-
${sites.map((s) => unsafe(html`<option value="${s}">${s}</option>`))}
|
|
692
|
-
</select>
|
|
693
|
-
</label>
|
|
694
|
-
<label>
|
|
695
|
-
<span>Content type</span>
|
|
696
|
-
<select data-intake-field="contentType">
|
|
697
|
-
<option value="blog">blog (default)</option>
|
|
698
|
-
<option value="youtube">youtube</option>
|
|
699
|
-
<option value="tool">tool</option>
|
|
700
|
-
</select>
|
|
701
|
-
</label>
|
|
702
|
-
<label class="er-intake-wide">
|
|
703
|
-
<span>Title</span>
|
|
704
|
-
<input type="text" data-intake-field="title" placeholder="Working title" />
|
|
705
|
-
</label>
|
|
706
|
-
<label class="er-intake-wide">
|
|
707
|
-
<span>Description</span>
|
|
708
|
-
<textarea data-intake-field="description" rows="4"></textarea>
|
|
709
|
-
</label>
|
|
710
|
-
<label class="er-intake-wide" data-intake-content-url hidden>
|
|
711
|
-
<span>Content URL</span>
|
|
712
|
-
<input type="url" data-intake-field="contentUrl" placeholder="https://..." />
|
|
713
|
-
</label>
|
|
714
|
-
</div>
|
|
715
|
-
<div class="er-intake-actions">
|
|
716
|
-
<button class="er-btn er-btn-small" type="button" data-action="intake-cancel">cancel</button>
|
|
717
|
-
<button class="er-btn er-btn-small er-btn-primary" type="button"
|
|
718
|
-
data-action="intake-copy">copy intake →</button>
|
|
719
|
-
</div>
|
|
720
|
-
</div>`)
|
|
721
|
-
: '';
|
|
722
|
-
|
|
723
|
-
const intakeButton =
|
|
724
|
-
stage === 'Ideas'
|
|
725
|
-
? unsafe(html`<button class="er-btn er-btn-small er-section-action" type="button"
|
|
726
|
-
data-action="intake-toggle"
|
|
727
|
-
title="fill out an intake sheet">intake new idea →</button>`)
|
|
728
|
-
: '';
|
|
729
|
-
|
|
730
|
-
const body =
|
|
731
|
-
entries.length === 0
|
|
732
|
-
? unsafe(html`<div class="er-empty" style="padding: 1rem 0.25rem; font-size: 0.95rem;">
|
|
733
|
-
${STAGE_EMPTY_MESSAGES[stage]}
|
|
734
|
-
</div>`)
|
|
735
|
-
: unsafe(
|
|
736
|
-
entries
|
|
737
|
-
.map((e, i) => renderRow(ctx, data, e, stage, i, getIndex).__raw)
|
|
738
|
-
.join(''),
|
|
739
|
-
);
|
|
740
|
-
|
|
741
|
-
return unsafe(html`
|
|
742
|
-
<section class="er-section" id="stage-${stage.toLowerCase()}" data-stage-section="${stage}">
|
|
743
|
-
<h2 class="er-section-head">
|
|
744
|
-
<span>${stage}</span>
|
|
745
|
-
<span class="ornament">${STAGE_ORNAMENTS[stage]}</span>
|
|
746
|
-
<span class="count">№ ${entries.length}</span>
|
|
747
|
-
${intakeButton}
|
|
748
|
-
</h2>
|
|
749
|
-
${intakeBlock}
|
|
750
|
-
${body}
|
|
751
|
-
</section>`);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* Shortform workflow lookup keyed by (site, entryId|slug, platform).
|
|
756
|
-
* Built once per dashboard render so the coverage matrix can render
|
|
757
|
-
* each covered cell as a direct link to the workflow's review surface
|
|
758
|
-
* — phase 21c replaces the prior "✓" sigil + copy-CLI-command flow.
|
|
759
|
-
*/
|
|
760
|
-
function indexShortformWorkflows(
|
|
761
|
-
data: DashboardData,
|
|
762
|
-
): Map<string, DraftWorkflowItem> {
|
|
763
|
-
const out = new Map<string, DraftWorkflowItem>();
|
|
764
|
-
for (const w of data.workflows) {
|
|
765
|
-
if (w.contentKind !== 'shortform') continue;
|
|
766
|
-
if (w.state === 'applied' || w.state === 'cancelled') continue;
|
|
767
|
-
if (!w.platform) continue;
|
|
768
|
-
const key = `${covKey(w.site, w.slug, w.entryId)}::${w.platform}`;
|
|
769
|
-
out.set(key, w);
|
|
770
|
-
}
|
|
771
|
-
return out;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function renderShortformMatrix(data: DashboardData, ctx: StudioContext): RawHtml {
|
|
775
|
-
if (data.publishedBlogEntries.length === 0) return unsafe('');
|
|
776
|
-
const wfIndex = indexShortformWorkflows(data);
|
|
777
|
-
const rows = data.publishedBlogEntries.map(({ site, entry }) => {
|
|
778
|
-
const covered =
|
|
779
|
-
data.shortformCoverage.get(covKey(site, entry.slug, entry.id)) ??
|
|
780
|
-
new Set<Platform>();
|
|
781
|
-
const cells = PLATFORMS_ORDER.map((p) => {
|
|
782
|
-
const has = covered.has(p);
|
|
783
|
-
const wfKey = `${covKey(site, entry.slug, entry.id)}::${p}`;
|
|
784
|
-
const wf = wfIndex.get(wfKey);
|
|
785
|
-
// A covered cell with a live workflow → link straight into the
|
|
786
|
-
// unified review surface. A covered cell without a workflow
|
|
787
|
-
// (distribution recorded outside the studio's pipeline — legacy
|
|
788
|
-
// data) keeps the static "✓" so the matrix doesn't lie about
|
|
789
|
-
// what's clickable. Empty cells render a real start button that
|
|
790
|
-
// POSTs to /api/dev/editorial-review/start-shortform and
|
|
791
|
-
// navigates to the new workflow's review URL.
|
|
792
|
-
let inner: string;
|
|
793
|
-
if (has && wf) {
|
|
794
|
-
inner = html`<a class="er-sf-link" href="/dev/editorial-review/${wf.id}"
|
|
795
|
-
title="${p} workflow · open in review">✓</a>`;
|
|
796
|
-
} else if (has) {
|
|
797
|
-
inner = html`<span class="er-sf-check" title="${p} copy drafted">✓</span>`;
|
|
798
|
-
} else {
|
|
799
|
-
inner = html`<button class="er-sf-start-btn" type="button"
|
|
800
|
-
data-action="start-shortform"
|
|
801
|
-
data-site="${site}"
|
|
802
|
-
data-slug="${entry.slug}"
|
|
803
|
-
data-platform="${p}"
|
|
804
|
-
title="Start a ${p} shortform draft for ${entry.slug}">start</button>`;
|
|
805
|
-
}
|
|
806
|
-
const cls = has ? 'er-sf-cell er-sf-cell-covered' : 'er-sf-cell er-sf-cell-empty';
|
|
807
|
-
return html`<td class="${cls}">${unsafe(inner)}</td>`;
|
|
808
|
-
}).join('');
|
|
809
|
-
const host = ctx.config.sites[site]?.host ?? site;
|
|
810
|
-
return html`
|
|
811
|
-
<tr data-site="${site}">
|
|
812
|
-
<th scope="row" class="er-sf-slug">
|
|
813
|
-
<span class="er-row-site er-row-site--${site}" title="${host}">${siteLabel(site)}</span>
|
|
814
|
-
${entry.slug}
|
|
815
|
-
</th>
|
|
816
|
-
${unsafe(cells)}
|
|
817
|
-
</tr>`;
|
|
818
|
-
}).join('');
|
|
819
|
-
|
|
820
|
-
return unsafe(html`
|
|
821
|
-
<section class="er-section">
|
|
822
|
-
<h2 class="er-section-head">
|
|
823
|
-
<span>Short form · coverage</span>
|
|
824
|
-
<span class="count">${data.publishedBlogEntries.length} × ${PLATFORMS_ORDER.length}</span>
|
|
825
|
-
</h2>
|
|
826
|
-
<table class="er-sf-matrix">
|
|
827
|
-
<thead>
|
|
828
|
-
<tr>
|
|
829
|
-
<th scope="col" class="er-sf-slug-col">slug</th>
|
|
830
|
-
${PLATFORMS_ORDER.map(
|
|
831
|
-
(p) => unsafe(html`<th scope="col" class="er-sf-platform er-sf-platform-${p}">${p}</th>`),
|
|
832
|
-
)}
|
|
833
|
-
</tr>
|
|
834
|
-
</thead>
|
|
835
|
-
<tbody>${unsafe(rows)}</tbody>
|
|
836
|
-
</table>
|
|
837
|
-
</section>`);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
function renderApprovedSection(data: DashboardData, ctx: StudioContext): RawHtml {
|
|
841
|
-
if (data.approved.length === 0) return unsafe('');
|
|
842
|
-
const rows = data.approved
|
|
843
|
-
.map((w) => {
|
|
844
|
-
const host = ctx.config.sites[w.site]?.host ?? w.site;
|
|
845
|
-
const platformBit =
|
|
846
|
-
w.contentKind === 'shortform' && w.platform
|
|
847
|
-
? html`<span class="er-row-channel"> · ${w.platform}${w.channel ? ` · ${w.channel}` : ''}</span>`
|
|
848
|
-
: '';
|
|
849
|
-
const flagBit =
|
|
850
|
-
w.contentKind === 'shortform' && w.platform
|
|
851
|
-
? ` --platform ${w.platform}${w.channel ? ` --channel ${w.channel}` : ''}`
|
|
852
|
-
: '';
|
|
853
|
-
return html`
|
|
854
|
-
<a class="er-row" href="${workflowLink(w)}" data-slug="${w.slug}"
|
|
855
|
-
data-site="${w.site}" data-state="${w.state}">
|
|
856
|
-
<span class="er-row-num">→</span>
|
|
857
|
-
<span class="er-row-site er-row-site--${w.site}" title="${host}">${siteLabel(w.site)}</span>
|
|
858
|
-
<span class="er-row-slug">${w.slug}</span>
|
|
859
|
-
<span class="er-row-kind">${w.contentKind}${unsafe(platformBit)}</span>
|
|
860
|
-
<span class="er-stamp er-stamp-approved">approved</span>
|
|
861
|
-
<span class="er-row-ts">v${w.currentVersion}</span>
|
|
862
|
-
<span class="er-row-hint">
|
|
863
|
-
Run · <code>/deskwork:approve --site ${w.site} ${w.slug}${flagBit}</code>
|
|
864
|
-
</span>
|
|
865
|
-
</a>`;
|
|
866
|
-
})
|
|
867
|
-
.join('');
|
|
868
|
-
return unsafe(html`
|
|
869
|
-
<section class="er-section">
|
|
870
|
-
<h2 class="er-section-head">
|
|
871
|
-
<span>Awaiting press</span>
|
|
872
|
-
<span class="count">№ ${data.approved.length}</span>
|
|
873
|
-
</h2>
|
|
874
|
-
${unsafe(rows)}
|
|
875
|
-
</section>`);
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
function renderTerminalSection(data: DashboardData, ctx: StudioContext, now: Date): RawHtml {
|
|
879
|
-
if (data.terminal.length === 0) return unsafe('');
|
|
880
|
-
// Issue #110: Recent proofs rows are now `<a>` so adopters can
|
|
881
|
-
// click into the historical review record (the review surface
|
|
882
|
-
// renders terminal workflows for inspection just as well as
|
|
883
|
-
// active ones).
|
|
884
|
-
const rows = data.terminal
|
|
885
|
-
.map((w) => {
|
|
886
|
-
const host = ctx.config.sites[w.site]?.host ?? w.site;
|
|
887
|
-
const platformBit =
|
|
888
|
-
w.contentKind === 'shortform' && w.platform
|
|
889
|
-
? html`<span class="er-row-channel"> · ${w.platform}</span>`
|
|
890
|
-
: '';
|
|
891
|
-
return html`
|
|
892
|
-
<a class="er-row" href="${workflowLink(w)}" data-state="${w.state}" data-site="${w.site}"
|
|
893
|
-
title="open the ${w.state} review record">
|
|
894
|
-
<span class="er-row-num">—</span>
|
|
895
|
-
<span class="er-row-site er-row-site--${w.site}" title="${host}">${siteLabel(w.site)}</span>
|
|
896
|
-
<span class="er-row-slug" style="color: var(--er-ink-soft);">${w.slug}</span>
|
|
897
|
-
<span class="er-row-kind">${w.contentKind}${unsafe(platformBit)}</span>
|
|
898
|
-
<span class="er-stamp er-stamp-${w.state}">${w.state}</span>
|
|
899
|
-
<span class="er-row-ts">${fmtRelTime(w.updatedAt, now)}</span>
|
|
900
|
-
</a>`;
|
|
901
|
-
})
|
|
902
|
-
.join('');
|
|
903
|
-
return unsafe(html`
|
|
904
|
-
<section class="er-section">
|
|
905
|
-
<h2 class="er-section-head">
|
|
906
|
-
<span>Recent proofs</span>
|
|
907
|
-
<span class="count">last ${data.terminal.length}</span>
|
|
908
|
-
</h2>
|
|
909
|
-
${unsafe(rows)}
|
|
910
|
-
</section>`);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
function renderSidebar(data: DashboardData): RawHtml {
|
|
914
|
-
const totalTerminal = data.report.all.approvedCount + data.report.all.cancelledCount;
|
|
915
|
-
const hasEnoughSignal = totalTerminal >= 5;
|
|
916
|
-
const topTwo = data.report.topCategories.filter((c) => c.count > 0).slice(0, 2);
|
|
917
|
-
|
|
918
|
-
const driftBody = hasEnoughSignal && topTwo.length > 0
|
|
919
|
-
? unsafe(html`
|
|
920
|
-
<p class="er-drift-primary">
|
|
921
|
-
<em>${topTwo[0].category}</em>
|
|
922
|
-
<span class="count"> ${topTwo[0].count}</span>
|
|
923
|
-
</p>
|
|
924
|
-
${
|
|
925
|
-
topTwo[1]
|
|
926
|
-
? unsafe(html`<p class="er-drift-secondary" style="margin: 0;">
|
|
927
|
-
then <em style="color: var(--er-red-pencil);">${topTwo[1].category}</em> · ${topTwo[1].count}
|
|
928
|
-
</p>`)
|
|
929
|
-
: ''
|
|
930
|
-
}
|
|
931
|
-
<div style="margin-top: var(--er-space-2); font-family: var(--er-font-mono); font-size: 0.68rem; color: var(--er-faded);">
|
|
932
|
-
from ${data.report.all.approvedCount} approved · ${data.report.all.cancelledCount} cancelled<br />
|
|
933
|
-
<code style="margin-top: 0.25rem; display: inline-block;">/deskwork:review-report --site <site></code>
|
|
934
|
-
</div>`)
|
|
935
|
-
: unsafe(html`
|
|
936
|
-
<p style="font-family: var(--er-font-display); font-style: italic; color: var(--er-ink-soft); margin: 0;">
|
|
937
|
-
${
|
|
938
|
-
totalTerminal === 0
|
|
939
|
-
? 'No proofs yet. The signal builds with use.'
|
|
940
|
-
: `Only ${totalTerminal} terminal ${totalTerminal === 1 ? 'proof' : 'proofs'} so far — need ${5 - totalTerminal} more.`
|
|
941
|
-
}
|
|
942
|
-
</p>`);
|
|
943
|
-
|
|
944
|
-
return unsafe(html`
|
|
945
|
-
<aside>
|
|
946
|
-
<section class="er-drift">
|
|
947
|
-
<div class="er-drift-label">Voice-drift · signal</div>
|
|
948
|
-
${driftBody}
|
|
949
|
-
</section>
|
|
950
|
-
<section class="er-slip" style="margin-top: var(--er-space-4);">
|
|
951
|
-
<div class="er-slip-header">Short form</div>
|
|
952
|
-
<h3 class="er-slip-title">Social copy</h3>
|
|
953
|
-
<p style="font-size: 0.85rem; margin: 0 0 var(--er-space-1); color: var(--er-ink-soft);">
|
|
954
|
-
Click <em>start</em> in the coverage matrix above to begin a
|
|
955
|
-
shortform draft. Edit, iterate, and approve in the unified
|
|
956
|
-
review surface.
|
|
957
|
-
</p>
|
|
958
|
-
<p style="margin-top: var(--er-space-2);">
|
|
959
|
-
<a href="/dev/editorial-review-shortform">Go to the shortform desk →</a>
|
|
960
|
-
</p>
|
|
961
|
-
</section>
|
|
962
|
-
</aside>`);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
// ---------------------------------------------------------------------------
|
|
966
|
-
// Entry point
|
|
967
|
-
// ---------------------------------------------------------------------------
|
|
968
|
-
|
|
969
|
-
export function renderDashboard(
|
|
970
|
-
ctx: StudioContext,
|
|
971
|
-
getIndex?: DashboardIndexGetter,
|
|
972
|
-
): string {
|
|
973
|
-
const sites = Object.keys(ctx.config.sites);
|
|
974
|
-
const data = loadDashboardData(ctx, getIndex);
|
|
59
|
+
const data = await loadDashboardData(ctx.projectRoot);
|
|
975
60
|
const now = ctx.now ? ctx.now() : new Date();
|
|
976
61
|
|
|
977
|
-
const stageSections =
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
// and `the-outbound/characters/strivers`. This is purely a display
|
|
981
|
-
// ordering; the underlying calendar storage stays a flat table.
|
|
982
|
-
const stageEntries = data.calendarEntries
|
|
983
|
-
.filter((e) => e.entry.stage === stage)
|
|
984
|
-
.sort((a, b) => {
|
|
985
|
-
const siteCmp = a.site.localeCompare(b.site);
|
|
986
|
-
if (siteCmp !== 0) return siteCmp;
|
|
987
|
-
return a.entry.slug.localeCompare(b.entry.slug);
|
|
988
|
-
});
|
|
989
|
-
return renderStageSection(ctx, data, stage, stageEntries, sites, getIndex).__raw;
|
|
62
|
+
const stageSections = DASHBOARD_STAGE_ORDER.map((stage) => {
|
|
63
|
+
const bucket = data.byStage.get(stage) ?? [];
|
|
64
|
+
return renderStageSection(stage, bucket).__raw;
|
|
990
65
|
}).join('\n');
|
|
991
66
|
|
|
992
67
|
const body = html`
|
|
993
68
|
${renderEditorialFolio('dashboard', 'press-check')}
|
|
994
|
-
${renderHeader(data, ctx, now)}
|
|
69
|
+
${renderHeader(data, ctx.projectRoot, now)}
|
|
995
70
|
<main class="er-container">
|
|
996
|
-
${renderFilterStrip(
|
|
71
|
+
${renderFilterStrip()}
|
|
997
72
|
<div class="er-layout">
|
|
998
73
|
<div>
|
|
999
74
|
${unsafe(stageSections)}
|
|
1000
|
-
${
|
|
1001
|
-
${renderApprovedSection(data, ctx)}
|
|
1002
|
-
${renderTerminalSection(data, ctx, now)}
|
|
75
|
+
${renderDistributionPlaceholder()}
|
|
1003
76
|
</div>
|
|
1004
|
-
${renderSidebar(data)}
|
|
1005
77
|
</div>
|
|
1006
78
|
</main>
|
|
1007
79
|
<div class="er-toast" data-toast hidden></div>
|
|
@@ -1016,14 +88,6 @@ export function renderDashboard(
|
|
|
1016
88
|
],
|
|
1017
89
|
bodyAttrs: 'data-review-ui="studio"',
|
|
1018
90
|
bodyHtml: body,
|
|
1019
|
-
embeddedJson: [
|
|
1020
|
-
{
|
|
1021
|
-
id: '',
|
|
1022
|
-
attr: 'data-rename-slugs',
|
|
1023
|
-
data: data.slugsBySite,
|
|
1024
|
-
},
|
|
1025
|
-
],
|
|
1026
91
|
scriptModules: ['/static/dist/editorial-studio-client.js'],
|
|
1027
92
|
});
|
|
1028
93
|
}
|
|
1029
|
-
|