@deskwork/studio 0.9.5 → 0.9.6
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/README.md +32 -0
- package/dist/pages/chrome.ts +70 -0
- package/dist/pages/content-detail.ts +352 -0
- package/dist/pages/content.ts +532 -0
- package/dist/pages/dashboard.ts +992 -0
- package/dist/pages/help.ts +457 -0
- package/dist/pages/html.ts +80 -0
- package/dist/pages/index.ts +213 -0
- package/dist/pages/layout.ts +94 -0
- package/dist/pages/review-scrapbook-drawer.ts +150 -0
- package/dist/pages/review.ts +559 -0
- package/dist/pages/scrapbook.ts +299 -0
- package/dist/pages/shortform.ts +162 -0
- package/package.json +9 -9
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrapbook viewer — `/dev/scrapbook/:site/<path>` (path may include `/`).
|
|
3
|
+
*
|
|
4
|
+
* Reads the scrapbook directory at the given path and lists every
|
|
5
|
+
* file with type chips + relative timestamps, plus secret items
|
|
6
|
+
* (inside `scrapbook/secret/`) in a quiet second section. Empty
|
|
7
|
+
* scrapbooks render an empty state with quick-add affordances.
|
|
8
|
+
*
|
|
9
|
+
* The `path` argument is the hierarchical address of the scrapbook —
|
|
10
|
+
* any slash-separated kebab-case identifier under the site's
|
|
11
|
+
* contentDir. It does not need to correspond to a calendar entry;
|
|
12
|
+
* organizational nodes (e.g. `the-outbound/characters` with no
|
|
13
|
+
* own README) can host their own scrapbooks too.
|
|
14
|
+
*
|
|
15
|
+
* Port of `pages/dev/scrapbook/[site]/[slug].astro`. Layout swap
|
|
16
|
+
* (Astro `<Layout>` → studio shell) and CSS link added; structurally
|
|
17
|
+
* similar otherwise.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
formatRelativeTime,
|
|
22
|
+
formatSize,
|
|
23
|
+
listScrapbook,
|
|
24
|
+
type ScrapbookItem,
|
|
25
|
+
} from '@deskwork/core/scrapbook';
|
|
26
|
+
import type { StudioContext } from '../routes/api.ts';
|
|
27
|
+
import { html, unsafe, type RawHtml } from './html.ts';
|
|
28
|
+
import { layout } from './layout.ts';
|
|
29
|
+
import { renderEditorialFolio } from './chrome.ts';
|
|
30
|
+
|
|
31
|
+
interface RenderItemRowOptions {
|
|
32
|
+
/** Mark the row visually as belonging to the secret section. */
|
|
33
|
+
secret?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* When true, render disclosure controls + toolbar. Both public AND
|
|
36
|
+
* secret rows now ship the toolbar (#28); the per-section tools
|
|
37
|
+
* include a "Mark secret" / "Mark public" toggle that the client
|
|
38
|
+
* resolves into a cross-section rename. Pre-#28 secret rows were
|
|
39
|
+
* read-only — that decision is reversed here so operators have full
|
|
40
|
+
* CRUD over secret/ items from the standalone viewer.
|
|
41
|
+
*/
|
|
42
|
+
withTools?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderItemRow(
|
|
46
|
+
item: ScrapbookItem,
|
|
47
|
+
index: number,
|
|
48
|
+
opts: RenderItemRowOptions = {},
|
|
49
|
+
): RawHtml {
|
|
50
|
+
const { secret = false, withTools = true } = opts;
|
|
51
|
+
const editBtn =
|
|
52
|
+
withTools && item.kind === 'md'
|
|
53
|
+
? unsafe(html`<button type="button" class="scrapbook-tool" data-action="edit">edit</button>`)
|
|
54
|
+
: '';
|
|
55
|
+
const seq = String(index + 1).padStart(2, '0');
|
|
56
|
+
const kindLabel = item.kind === 'other' ? '·' : item.kind.toUpperCase();
|
|
57
|
+
const idPrefix = secret ? 'secret-' : '';
|
|
58
|
+
const dataSecret = secret ? ' data-secret="true"' : '';
|
|
59
|
+
// The "mark secret/public" toggle is the cross-section rename
|
|
60
|
+
// affordance. The button label flips with the source section.
|
|
61
|
+
const sectionToggleLabel = secret ? 'mark public' : 'mark secret';
|
|
62
|
+
const toolbar = withTools
|
|
63
|
+
? unsafe(html`<div class="scrapbook-toolbar" data-toolbar>
|
|
64
|
+
${editBtn}
|
|
65
|
+
<button type="button" class="scrapbook-tool" data-action="rename">rename</button>
|
|
66
|
+
<button type="button" class="scrapbook-tool" data-action="toggle-secret">${sectionToggleLabel}</button>
|
|
67
|
+
<button type="button" class="scrapbook-tool scrapbook-tool--delete" data-action="delete">delete</button>
|
|
68
|
+
</div>`)
|
|
69
|
+
: '';
|
|
70
|
+
return unsafe(html`
|
|
71
|
+
<li class="scrapbook-item${secret ? ' scrapbook-item--secret' : ''}" data-state="closed" data-open="false"
|
|
72
|
+
data-filename="${item.name}" data-kind="${item.kind}"
|
|
73
|
+
data-size="${item.size}" data-mtime="${item.mtime}"${unsafe(dataSecret)}
|
|
74
|
+
id="${idPrefix}item-${encodeURIComponent(item.name)}">
|
|
75
|
+
<button type="button" class="scrapbook-item-header" aria-expanded="false">
|
|
76
|
+
<span class="scrapbook-seq" aria-hidden="true">§ ${seq}</span>
|
|
77
|
+
<span class="scrapbook-kind scrapbook-kind--${item.kind}" aria-hidden="true">${kindLabel}</span>
|
|
78
|
+
<span class="scrapbook-filename" data-filename-cell>${item.name}</span>
|
|
79
|
+
<time class="scrapbook-mtime" datetime="${item.mtime}">${formatRelativeTime(item.mtime)}</time>
|
|
80
|
+
<span class="scrapbook-disclosure" aria-hidden="true">▸</span>
|
|
81
|
+
</button>
|
|
82
|
+
${toolbar}
|
|
83
|
+
<div class="scrapbook-perforation" aria-hidden="true"></div>
|
|
84
|
+
<div class="scrapbook-item-body" data-body>
|
|
85
|
+
<div data-body-content></div>
|
|
86
|
+
</div>
|
|
87
|
+
</li>`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build a hierarchical breadcrumb from the path. Each segment links to
|
|
92
|
+
* the scrapbook view for its prefix. The root segment (site) just
|
|
93
|
+
* goes back to the editorial dashboard.
|
|
94
|
+
*/
|
|
95
|
+
function renderBreadcrumb(site: string, path: string): RawHtml {
|
|
96
|
+
const segments = path.split('/');
|
|
97
|
+
const links: string[] = [];
|
|
98
|
+
for (let i = 0; i < segments.length; i++) {
|
|
99
|
+
const prefix = segments.slice(0, i + 1).join('/');
|
|
100
|
+
const isLast = i === segments.length - 1;
|
|
101
|
+
if (isLast) {
|
|
102
|
+
links.push(html`<span class="scrapbook-breadcrumb-current">${segments[i]}</span>`);
|
|
103
|
+
} else {
|
|
104
|
+
links.push(
|
|
105
|
+
html`<a class="scrapbook-breadcrumb-link" href="/dev/scrapbook/${site}/${prefix}">${segments[i]}</a>`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const sep = '<span class="scrapbook-breadcrumb-sep" aria-hidden="true">›</span>';
|
|
110
|
+
const joined = links.join(`\n${sep}\n`);
|
|
111
|
+
return unsafe(html`
|
|
112
|
+
<nav class="scrapbook-breadcrumb" aria-label="scrapbook hierarchy">
|
|
113
|
+
<a class="scrapbook-breadcrumb-link" href="/dev/editorial-studio">${site}</a>
|
|
114
|
+
<span class="scrapbook-breadcrumb-sep" aria-hidden="true">›</span>
|
|
115
|
+
${unsafe(joined)}
|
|
116
|
+
</nav>`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderIndexSidebar(items: readonly ScrapbookItem[], site: string, path: string): RawHtml {
|
|
120
|
+
const totalBytes = items.reduce((acc, item) => acc + item.size, 0);
|
|
121
|
+
const lastModified =
|
|
122
|
+
items.length > 0
|
|
123
|
+
? items.reduce((a, b) => (a.mtime > b.mtime ? a : b)).mtime
|
|
124
|
+
: null;
|
|
125
|
+
return unsafe(html`
|
|
126
|
+
<aside class="scrapbook-index">
|
|
127
|
+
<p class="scrapbook-index-kicker">
|
|
128
|
+
<span aria-hidden="true">§</span> The folder
|
|
129
|
+
</p>
|
|
130
|
+
<p class="scrapbook-index-meta">${path}</p>
|
|
131
|
+
<p class="scrapbook-index-meta">${site}</p>
|
|
132
|
+
<hr />
|
|
133
|
+
<ol class="scrapbook-index-list" data-scrapbook-index>
|
|
134
|
+
${items.map(
|
|
135
|
+
(item, i) => unsafe(html`<li data-index-for="${item.name}">
|
|
136
|
+
<span class="scrapbook-index-num">No. ${String(i + 1).padStart(2, '0')}</span>
|
|
137
|
+
<a href="#item-${encodeURIComponent(item.name)}">${item.name}</a>
|
|
138
|
+
</li>`),
|
|
139
|
+
)}
|
|
140
|
+
</ol>
|
|
141
|
+
<hr />
|
|
142
|
+
<p class="scrapbook-index-totals">${items.length} ${items.length === 1 ? 'item' : 'items'} · ${formatSize(totalBytes)}</p>
|
|
143
|
+
${
|
|
144
|
+
lastModified
|
|
145
|
+
? unsafe(html`<p class="scrapbook-index-subtotal">last modified ${formatRelativeTime(lastModified)}</p>`)
|
|
146
|
+
: ''
|
|
147
|
+
}
|
|
148
|
+
<hr />
|
|
149
|
+
<div class="scrapbook-index-actions">
|
|
150
|
+
<button type="button" class="scrapbook-index-btn" data-action="new-note">+ new note</button>
|
|
151
|
+
<button type="button" class="scrapbook-index-btn" data-action="upload">+ upload file</button>
|
|
152
|
+
</div>
|
|
153
|
+
<hr />
|
|
154
|
+
<p class="scrapbook-index-path">${site}/${path}/scrapbook/</p>
|
|
155
|
+
</aside>`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderEmpty(): RawHtml {
|
|
159
|
+
return unsafe(html`
|
|
160
|
+
<section class="scrapbook-empty">
|
|
161
|
+
<p>
|
|
162
|
+
This scrapbook is empty. Write the first note, or drop a file
|
|
163
|
+
anywhere on this page.
|
|
164
|
+
</p>
|
|
165
|
+
<div class="scrapbook-empty-actions">
|
|
166
|
+
<button type="button" class="scrapbook-index-btn" data-action="new-note">+ new note</button>
|
|
167
|
+
<button type="button" class="scrapbook-index-btn" data-action="upload">+ upload file</button>
|
|
168
|
+
</div>
|
|
169
|
+
</section>`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderReadingPanel(items: readonly ScrapbookItem[]): RawHtml {
|
|
173
|
+
return unsafe(html`
|
|
174
|
+
<section class="scrapbook-reading">
|
|
175
|
+
<form class="scrapbook-composer" data-scrapbook-composer hidden>
|
|
176
|
+
<div class="scrapbook-composer-header">
|
|
177
|
+
<span class="scrapbook-composer-seq" aria-hidden="true">✎</span>
|
|
178
|
+
<span class="scrapbook-composer-kind">NEW</span>
|
|
179
|
+
<input type="text" class="scrapbook-composer-filename" data-composer-filename
|
|
180
|
+
placeholder="note-name.md" aria-label="new note filename" />
|
|
181
|
+
<div class="scrapbook-editor-footer scrapbook-composer-actions">
|
|
182
|
+
<label class="scrapbook-secret-toggle" title="save under scrapbook/secret/ — never published">
|
|
183
|
+
<input type="checkbox" data-composer-secret />
|
|
184
|
+
<span>secret</span>
|
|
185
|
+
</label>
|
|
186
|
+
<button type="button" class="scrapbook-tool" data-action="composer-cancel">cancel</button>
|
|
187
|
+
<button type="submit" class="scrapbook-tool scrapbook-tool--primary" data-action="composer-save">save →</button>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="scrapbook-composer-body">
|
|
191
|
+
<textarea data-composer-body
|
|
192
|
+
placeholder="Write the note in markdown. Cmd/Ctrl+S saves."
|
|
193
|
+
aria-label="new note body"></textarea>
|
|
194
|
+
</div>
|
|
195
|
+
</form>
|
|
196
|
+
<ol class="scrapbook-items" data-scrapbook-items>
|
|
197
|
+
${items.map((item, i) => renderItemRow(item, i))}
|
|
198
|
+
</ol>
|
|
199
|
+
<div class="scrapbook-drop" data-scrapbook-drop role="button" tabindex="0"
|
|
200
|
+
aria-label="upload a file to the scrapbook">
|
|
201
|
+
<span class="scrapbook-drop-label">── drop a file here, or pick one ──</span>
|
|
202
|
+
<input type="file" data-scrapbook-file-input
|
|
203
|
+
accept="image/*,application/json,text/plain,text/markdown,.md,.json,.txt" />
|
|
204
|
+
<label class="scrapbook-secret-toggle scrapbook-secret-toggle--upload"
|
|
205
|
+
title="save the upload under scrapbook/secret/ — never published">
|
|
206
|
+
<input type="checkbox" data-upload-secret />
|
|
207
|
+
<span>upload as secret</span>
|
|
208
|
+
</label>
|
|
209
|
+
</div>
|
|
210
|
+
</section>`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Quiet second section listing items inside `scrapbook/secret/`. Read-
|
|
215
|
+
* only in v1 — operators populate the directory by hand or via the
|
|
216
|
+
* core API; the studio surface just shows what's there. The "private"
|
|
217
|
+
* badge gives unmistakable visual differentiation from the public
|
|
218
|
+
* items above.
|
|
219
|
+
*/
|
|
220
|
+
function renderSecretSection(items: readonly ScrapbookItem[]): RawHtml {
|
|
221
|
+
return unsafe(html`
|
|
222
|
+
<section class="scrapbook-secret" data-scrapbook-secret>
|
|
223
|
+
<header class="scrapbook-secret-header">
|
|
224
|
+
<span class="scrapbook-secret-mark" aria-hidden="true">⚿</span>
|
|
225
|
+
<h2 class="scrapbook-secret-title">Secret</h2>
|
|
226
|
+
<span class="scrapbook-secret-badge" aria-label="private — never published">
|
|
227
|
+
private
|
|
228
|
+
</span>
|
|
229
|
+
<span class="scrapbook-secret-count">
|
|
230
|
+
${items.length} ${items.length === 1 ? 'item' : 'items'}
|
|
231
|
+
</span>
|
|
232
|
+
</header>
|
|
233
|
+
<p class="scrapbook-secret-help">
|
|
234
|
+
Items inside <code>scrapbook/secret/</code>. Excluded from the
|
|
235
|
+
public site by the host's content-collection patterns.
|
|
236
|
+
</p>
|
|
237
|
+
<ol class="scrapbook-items scrapbook-items--secret">
|
|
238
|
+
${items.map((item, i) =>
|
|
239
|
+
renderItemRow(item, i, { secret: true, withTools: true }),
|
|
240
|
+
)}
|
|
241
|
+
</ol>
|
|
242
|
+
</section>`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function renderScrapbookPage(
|
|
246
|
+
ctx: StudioContext,
|
|
247
|
+
site: string,
|
|
248
|
+
path: string,
|
|
249
|
+
): string {
|
|
250
|
+
// Validate site against the project's configured site list. Without
|
|
251
|
+
// this check, an unknown site key reaches the path resolver and
|
|
252
|
+
// produces either an opaque error or a path traversal vector.
|
|
253
|
+
if (!(site in ctx.config.sites)) {
|
|
254
|
+
throw new Error(`unknown site: ${site}`);
|
|
255
|
+
}
|
|
256
|
+
const summary = listScrapbook(ctx.projectRoot, ctx.config, site, path);
|
|
257
|
+
const items = summary.items;
|
|
258
|
+
const secretItems = summary.secretItems;
|
|
259
|
+
|
|
260
|
+
const publicBlock =
|
|
261
|
+
items.length === 0
|
|
262
|
+
? renderEmpty().__raw
|
|
263
|
+
: renderReadingPanel(items).__raw + renderIndexSidebar(items, site, path).__raw;
|
|
264
|
+
|
|
265
|
+
const secretBlock = secretItems.length > 0 ? renderSecretSection(secretItems).__raw : '';
|
|
266
|
+
|
|
267
|
+
const body = html`
|
|
268
|
+
${renderEditorialFolio('content', `scrapbook · ${site}/${path}`)}
|
|
269
|
+
<main class="scrapbook-page" data-site="${site}" data-slug="${path}" data-scrapbook-root>
|
|
270
|
+
<header class="er-pagehead er-pagehead--compact scrapbook-header">
|
|
271
|
+
${renderBreadcrumb(site, path)}
|
|
272
|
+
<p class="er-pagehead__kicker scrapbook-kicker">
|
|
273
|
+
<span class="scrapbook-kicker-mark" aria-hidden="true">§</span>
|
|
274
|
+
Scrapbook
|
|
275
|
+
</p>
|
|
276
|
+
<h1 class="er-pagehead__title scrapbook-title">${path}</h1>
|
|
277
|
+
<a class="scrapbook-back" href="/dev/editorial-studio">← back to the desk</a>
|
|
278
|
+
</header>
|
|
279
|
+
<div class="scrapbook-status" data-scrapbook-status hidden></div>
|
|
280
|
+
${unsafe(publicBlock)}
|
|
281
|
+
${unsafe(secretBlock)}
|
|
282
|
+
<div class="scrapbook-drop-overlay" data-scrapbook-overlay aria-hidden="true">
|
|
283
|
+
<span class="scrapbook-drop-overlay-text">drop to add to the scrapbook ◇</span>
|
|
284
|
+
</div>
|
|
285
|
+
</main>`;
|
|
286
|
+
|
|
287
|
+
return layout({
|
|
288
|
+
title: `scrapbook · ${path} — dev`,
|
|
289
|
+
cssHrefs: [
|
|
290
|
+
'/static/css/editorial-review.css',
|
|
291
|
+
'/static/css/editorial-nav.css',
|
|
292
|
+
'/static/css/scrapbook.css',
|
|
293
|
+
'/static/css/blog-figure.css',
|
|
294
|
+
],
|
|
295
|
+
bodyAttrs: 'data-review-ui="studio"',
|
|
296
|
+
bodyHtml: body,
|
|
297
|
+
scriptModules: ['/static/dist/scrapbook-client.js'],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shortform desk index — `/dev/editorial-review-shortform`.
|
|
3
|
+
*
|
|
4
|
+
* Phase 21c: this page used to render compose textareas + dead Save /
|
|
5
|
+
* Approve / Iterate / Reject buttons that had no handlers. The new
|
|
6
|
+
* design unifies shortform + longform behind one review surface, so
|
|
7
|
+
* the desk becomes a pure navigation index — every open shortform
|
|
8
|
+
* workflow is a link into `/dev/editorial-review/<workflow.id>` where
|
|
9
|
+
* the operator gets the full editor (save / iterate / approve /
|
|
10
|
+
* reject) without a parallel composer to maintain.
|
|
11
|
+
*
|
|
12
|
+
* The folio strip + page chrome stay; the per-card textarea +
|
|
13
|
+
* inline action buttons go away.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { listOpen } from '@deskwork/core/review/pipeline';
|
|
17
|
+
import type { DraftWorkflowItem } from '@deskwork/core/review/types';
|
|
18
|
+
import type { StudioContext } from '../routes/api.ts';
|
|
19
|
+
import { html, unsafe, type RawHtml } from './html.ts';
|
|
20
|
+
import { layout } from './layout.ts';
|
|
21
|
+
import { renderEditorialFolio } from './chrome.ts';
|
|
22
|
+
|
|
23
|
+
const PLATFORM_ORDER = ['reddit', 'linkedin', 'youtube', 'instagram'] as const;
|
|
24
|
+
|
|
25
|
+
function siteLabel(site: string): string {
|
|
26
|
+
return site.slice(0, 2).toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadOpenShortform(ctx: StudioContext): DraftWorkflowItem[] {
|
|
30
|
+
const open: DraftWorkflowItem[] = [];
|
|
31
|
+
for (const w of listOpen(ctx.projectRoot, ctx.config)) {
|
|
32
|
+
if (w.contentKind === 'shortform') open.push(w);
|
|
33
|
+
}
|
|
34
|
+
open.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
35
|
+
return open;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function groupByPlatform(workflows: readonly DraftWorkflowItem[]): {
|
|
39
|
+
byPlatform: Map<string, DraftWorkflowItem[]>;
|
|
40
|
+
ordered: string[];
|
|
41
|
+
} {
|
|
42
|
+
const byPlatform = new Map<string, DraftWorkflowItem[]>();
|
|
43
|
+
for (const w of workflows) {
|
|
44
|
+
const key = w.platform ?? 'other';
|
|
45
|
+
const list = byPlatform.get(key) ?? [];
|
|
46
|
+
list.push(w);
|
|
47
|
+
byPlatform.set(key, list);
|
|
48
|
+
}
|
|
49
|
+
const ordered = [
|
|
50
|
+
...PLATFORM_ORDER.filter((p) => byPlatform.has(p)),
|
|
51
|
+
...[...byPlatform.keys()].filter(
|
|
52
|
+
(p) => !(PLATFORM_ORDER as readonly string[]).includes(p),
|
|
53
|
+
),
|
|
54
|
+
];
|
|
55
|
+
return { byPlatform, ordered };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fmtRelTime(iso: string, now: Date): string {
|
|
59
|
+
const t = new Date(iso).getTime();
|
|
60
|
+
const s = Math.max(0, Math.floor((now.getTime() - t) / 1000));
|
|
61
|
+
if (s < 60) return `${s}s ago`;
|
|
62
|
+
const m = Math.floor(s / 60);
|
|
63
|
+
if (m < 60) return `${m}m ago`;
|
|
64
|
+
const h = Math.floor(m / 60);
|
|
65
|
+
if (h < 48) return `${h}h ago`;
|
|
66
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderRow(w: DraftWorkflowItem, now: Date): RawHtml {
|
|
70
|
+
const channelMarkup: RawHtml = w.channel
|
|
71
|
+
? unsafe(html`<span class="channel">${w.channel}</span>`)
|
|
72
|
+
: unsafe('');
|
|
73
|
+
const reviewUrl = `/dev/editorial-review/${w.id}`;
|
|
74
|
+
return unsafe(html`
|
|
75
|
+
<a class="er-row er-shortform-row"
|
|
76
|
+
href="${reviewUrl}"
|
|
77
|
+
data-workflow-id="${w.id}"
|
|
78
|
+
data-platform="${w.platform ?? 'other'}"
|
|
79
|
+
data-state="${w.state}"
|
|
80
|
+
data-site="${w.site}">
|
|
81
|
+
<span class="er-row-num">→</span>
|
|
82
|
+
<span class="er-row-site er-row-site--${w.site}" title="${w.site}">${siteLabel(w.site)}</span>
|
|
83
|
+
<span class="er-row-slug">${w.slug}</span>
|
|
84
|
+
${channelMarkup}
|
|
85
|
+
<span class="er-stamp er-stamp-${w.state}">${w.state.replace('-', ' ')}</span>
|
|
86
|
+
<span class="er-row-ts">v${w.currentVersion} · ${fmtRelTime(w.updatedAt, now)}</span>
|
|
87
|
+
<span class="er-row-hint">Open in review →</span>
|
|
88
|
+
</a>`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderPlatformSection(
|
|
92
|
+
platform: string,
|
|
93
|
+
workflows: readonly DraftWorkflowItem[],
|
|
94
|
+
now: Date,
|
|
95
|
+
): RawHtml {
|
|
96
|
+
const rows = workflows.map((w) => renderRow(w, now).__raw).join('');
|
|
97
|
+
return unsafe(html`
|
|
98
|
+
<section class="er-platform-section">
|
|
99
|
+
<div class="er-platform-header">
|
|
100
|
+
<h2>${platform}</h2>
|
|
101
|
+
<span class="er-platform-count">№ ${String(workflows.length).padStart(2, '0')}</span>
|
|
102
|
+
</div>
|
|
103
|
+
${unsafe(rows)}
|
|
104
|
+
</section>`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderEmptyState(): RawHtml {
|
|
108
|
+
const platformList = PLATFORM_ORDER.join(', ');
|
|
109
|
+
return unsafe(html`
|
|
110
|
+
<div class="er-empty" style="margin-top: var(--er-space-5);">
|
|
111
|
+
No short-form galleys on the desk.<br />
|
|
112
|
+
Supported platforms: <em>${platformList}</em>.<br />
|
|
113
|
+
Start a new shortform draft from the dashboard's
|
|
114
|
+
<a href="/dev/editorial-studio">coverage matrix</a>.
|
|
115
|
+
</div>`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function renderShortformPage(ctx: StudioContext): string {
|
|
119
|
+
const workflows = loadOpenShortform(ctx);
|
|
120
|
+
const { byPlatform, ordered } = groupByPlatform(workflows);
|
|
121
|
+
const now = ctx.now ? ctx.now() : new Date();
|
|
122
|
+
|
|
123
|
+
const cardsBlock =
|
|
124
|
+
workflows.length === 0
|
|
125
|
+
? renderEmptyState().__raw
|
|
126
|
+
: ordered
|
|
127
|
+
.map((p) => renderPlatformSection(p, byPlatform.get(p) ?? [], now).__raw)
|
|
128
|
+
.join('');
|
|
129
|
+
|
|
130
|
+
const body = html`
|
|
131
|
+
${renderEditorialFolio('reviews', 'shortform desk')}
|
|
132
|
+
<header class="er-pagehead er-pagehead--centered">
|
|
133
|
+
<p class="er-pagehead__kicker">All sites · short form</p>
|
|
134
|
+
<h1 class="er-pagehead__title">The <em>compositor</em>'s desk</h1>
|
|
135
|
+
<p class="er-pagehead__deck">Open shortform galleys — click any row to open the unified review surface.</p>
|
|
136
|
+
<p class="er-pagehead__meta">
|
|
137
|
+
<span>${workflows.length} in flight</span>
|
|
138
|
+
<span class="sep">·</span>
|
|
139
|
+
<span>${ordered.length} ${ordered.length === 1 ? 'platform' : 'platforms'}</span>
|
|
140
|
+
</p>
|
|
141
|
+
</header>
|
|
142
|
+
<main class="er-container" style="padding-top: var(--er-space-4); padding-bottom: var(--er-space-6);">
|
|
143
|
+
${unsafe(cardsBlock)}
|
|
144
|
+
<p style="margin-top: var(--er-space-5); font-family: var(--er-font-display); font-style: italic; color: var(--er-faded);">
|
|
145
|
+
<a href="/dev/editorial-studio">← back to the studio</a>
|
|
146
|
+
</p>
|
|
147
|
+
</main>
|
|
148
|
+
<div class="er-toast" id="toast" hidden></div>`;
|
|
149
|
+
|
|
150
|
+
return layout({
|
|
151
|
+
title: 'Short form — all sites — dev',
|
|
152
|
+
cssHrefs: [
|
|
153
|
+
'/static/css/editorial-review.css',
|
|
154
|
+
'/static/css/editorial-nav.css',
|
|
155
|
+
'/static/css/editorial-studio.css',
|
|
156
|
+
],
|
|
157
|
+
bodyAttrs: 'data-review-ui="shortform"',
|
|
158
|
+
bodyHtml: body,
|
|
159
|
+
embeddedJson: [],
|
|
160
|
+
scriptModules: ['/static/dist/editorial-studio-client.js'],
|
|
161
|
+
});
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deskwork/studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Editorial review studio — local web UI for the deskwork plugin",
|
|
6
6
|
"homepage": "https://github.com/audiocontrol-org/deskwork#readme",
|
|
@@ -33,26 +33,26 @@
|
|
|
33
33
|
"./package.json": "./package.json"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
|
-
"build": "tsc -b tsconfig.build.json",
|
|
37
|
-
"prepack": "tsc -b tsconfig.build.json",
|
|
36
|
+
"build": "tsc -b tsconfig.build.json && mkdir -p dist/pages && cp src/pages/*.ts dist/pages/",
|
|
37
|
+
"prepack": "tsc -b tsconfig.build.json && mkdir -p dist/pages && cp src/pages/*.ts dist/pages/",
|
|
38
38
|
"test": "vitest run",
|
|
39
39
|
"test:watch": "vitest",
|
|
40
40
|
"typecheck": "tsc --noEmit"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@deskwork/core": "*",
|
|
44
|
-
"@hono/node-server": "^1.13.7",
|
|
45
|
-
"hono": "^4.6.0"
|
|
46
|
-
},
|
|
47
|
-
"devDependencies": {
|
|
48
43
|
"@codemirror/commands": "^6.10.3",
|
|
49
44
|
"@codemirror/lang-markdown": "^6.5.0",
|
|
50
45
|
"@codemirror/language": "^6.12.3",
|
|
51
46
|
"@codemirror/state": "^6.6.0",
|
|
52
47
|
"@codemirror/view": "^6.41.1",
|
|
48
|
+
"@deskwork/core": "*",
|
|
49
|
+
"@hono/node-server": "^1.13.7",
|
|
53
50
|
"@lezer/highlight": "^1.2.3",
|
|
54
|
-
"@types/node": "^22.10.0",
|
|
55
51
|
"esbuild": "^0.28.0",
|
|
52
|
+
"hono": "^4.6.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^22.10.0",
|
|
56
56
|
"tsx": "^4.21.0",
|
|
57
57
|
"typescript": "^5.7.0",
|
|
58
58
|
"vitest": "^4.1.2"
|