@deskwork/studio 0.12.0 → 0.13.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/components/scrapbook-item.d.ts +9 -1
- package/dist/components/scrapbook-item.d.ts.map +1 -1
- package/dist/components/scrapbook-item.js +11 -2
- package/dist/components/scrapbook-item.js.map +1 -1
- package/dist/data/glossary.json +62 -0
- package/dist/lib/glossary-helper.d.ts +16 -0
- package/dist/lib/glossary-helper.d.ts.map +1 -0
- package/dist/lib/glossary-helper.js +26 -0
- package/dist/lib/glossary-helper.js.map +1 -0
- package/dist/lib/version.d.ts +10 -0
- package/dist/lib/version.d.ts.map +1 -0
- package/dist/lib/version.js +29 -0
- package/dist/lib/version.js.map +1 -0
- package/dist/pages/chrome.d.ts +24 -13
- package/dist/pages/chrome.d.ts.map +1 -1
- package/dist/pages/chrome.js +25 -24
- package/dist/pages/chrome.js.map +1 -1
- package/dist/pages/chrome.ts +38 -27
- package/dist/pages/content-detail.js +1 -1
- package/dist/pages/content-detail.js.map +1 -1
- package/dist/pages/content-detail.ts +1 -1
- package/dist/pages/content.d.ts.map +1 -1
- package/dist/pages/content.js +48 -0
- package/dist/pages/content.js.map +1 -1
- package/dist/pages/content.ts +51 -0
- package/dist/pages/dashboard/header.d.ts.map +1 -1
- package/dist/pages/dashboard/header.js +3 -0
- package/dist/pages/dashboard/header.js.map +1 -1
- package/dist/pages/dashboard/header.ts +3 -0
- package/dist/pages/dashboard/section.d.ts +6 -3
- package/dist/pages/dashboard/section.d.ts.map +1 -1
- package/dist/pages/dashboard/section.js +25 -12
- package/dist/pages/dashboard/section.js.map +1 -1
- package/dist/pages/dashboard/section.ts +26 -13
- package/dist/pages/entry-review.js +2 -2
- package/dist/pages/entry-review.js.map +1 -1
- package/dist/pages/entry-review.ts +2 -2
- package/dist/pages/html.d.ts +2 -0
- package/dist/pages/html.d.ts.map +1 -1
- package/dist/pages/html.js +2 -0
- package/dist/pages/html.js.map +1 -1
- package/dist/pages/html.ts +4 -0
- package/dist/pages/layout.d.ts.map +1 -1
- package/dist/pages/layout.js +6 -0
- package/dist/pages/layout.js.map +1 -1
- package/dist/pages/layout.ts +7 -0
- package/dist/pages/review-scrapbook-drawer.d.ts +7 -0
- package/dist/pages/review-scrapbook-drawer.d.ts.map +1 -1
- package/dist/pages/review-scrapbook-drawer.js +45 -6
- package/dist/pages/review-scrapbook-drawer.js.map +1 -1
- package/dist/pages/review-scrapbook-drawer.ts +50 -6
- package/dist/pages/review.d.ts.map +1 -1
- package/dist/pages/review.js +168 -41
- package/dist/pages/review.js.map +1 -1
- package/dist/pages/review.ts +192 -41
- package/dist/pages/scrapbook.d.ts +7 -14
- package/dist/pages/scrapbook.d.ts.map +1 -1
- package/dist/pages/scrapbook.js +352 -193
- package/dist/pages/scrapbook.js.map +1 -1
- package/dist/pages/scrapbook.ts +390 -222
- package/dist/pages/shortform.js +1 -1
- package/dist/pages/shortform.js.map +1 -1
- package/dist/pages/shortform.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +26 -13
- package/dist/server.js.map +1 -1
- package/package.json +4 -4
package/dist/pages/scrapbook.ts
CHANGED
|
@@ -1,243 +1,408 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scrapbook viewer — `/dev/scrapbook/:site/<path
|
|
2
|
+
* Scrapbook viewer — `/dev/scrapbook/:site/<path>`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Issue #161 redesign: aside-left folder card with numbered item list,
|
|
5
|
+
* vertical card grid with per-kind colored ribbons + always-visible foot
|
|
6
|
+
* toolbar + per-kind preview rendering, drop zone, secret section,
|
|
7
|
+
* single-expanded card invariant, aside cross-linking.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
9
|
+
* Mockup: docs/superpowers/frontend-design/2026-05-02-review-redesign/scrapbook-redesign.html
|
|
10
|
+
* Spec: docs/superpowers/specs/2026-05-02-scrapbook-redesign-impl-spec.md
|
|
18
11
|
*/
|
|
19
12
|
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
20
14
|
import {
|
|
21
15
|
formatRelativeTime,
|
|
22
16
|
formatSize,
|
|
23
17
|
listScrapbook,
|
|
18
|
+
scrapbookFilePath,
|
|
24
19
|
type ScrapbookItem,
|
|
20
|
+
type ScrapbookItemKind,
|
|
25
21
|
} from '@deskwork/core/scrapbook';
|
|
26
22
|
import type { StudioContext } from '../routes/api.ts';
|
|
27
23
|
import { html, unsafe, type RawHtml } from './html.ts';
|
|
28
24
|
import { layout } from './layout.ts';
|
|
29
25
|
import { renderEditorialFolio } from './chrome.ts';
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
27
|
+
const KIND_LABEL: Record<ScrapbookItemKind, string> = {
|
|
28
|
+
md: 'MD',
|
|
29
|
+
img: 'IMG',
|
|
30
|
+
json: 'JSON',
|
|
31
|
+
js: 'JS',
|
|
32
|
+
txt: 'TXT',
|
|
33
|
+
other: '·',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function escapeHtml(s: string): string {
|
|
37
|
+
return s
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/'/g, ''');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Strip a YAML frontmatter block from the top of an md file. Only strips
|
|
47
|
+
* the leading `---\n...\n---\n` block; body-level `---` separators (Setext
|
|
48
|
+
* H2 underline, thematic break) are preserved because the function only
|
|
49
|
+
* looks at the first 4 chars for the opener.
|
|
50
|
+
*/
|
|
51
|
+
function stripFrontmatter(text: string): string {
|
|
52
|
+
if (!text.startsWith('---\n')) return text;
|
|
53
|
+
const closeIdx = text.indexOf('\n---\n', 4);
|
|
54
|
+
if (closeIdx < 0) return text;
|
|
55
|
+
return text.slice(closeIdx + 5).replace(/^\n+/, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the closed-state preview excerpt for md/json/txt. Returns null
|
|
60
|
+
* when there's nothing useful to render — empty file, frontmatter-only
|
|
61
|
+
* file, or binary masquerading as text — so the caller can omit the
|
|
62
|
+
* preview block entirely (matches "other" kind treatment, avoids the
|
|
63
|
+
* 6rem min-height void).
|
|
64
|
+
*
|
|
65
|
+
* For json: pretty-print via JSON.parse + JSON.stringify(_, null, 2) so
|
|
66
|
+
* minified single-line files still render multi-line. Falls back to raw
|
|
67
|
+
* content on parse error (bad JSON is still readable as text).
|
|
68
|
+
*
|
|
69
|
+
* Binary detection: NUL byte presence after UTF-8 decode. Real text
|
|
70
|
+
* almost never has NUL; binary files have it within the first KB.
|
|
71
|
+
*/
|
|
72
|
+
function previewExcerpt(buf: Buffer, kind: 'md' | 'json' | 'txt'): string | null {
|
|
73
|
+
let text = buf.subarray(0, Math.min(buf.byteLength, 2400)).toString('utf-8');
|
|
74
|
+
if (text.indexOf('\0') >= 0) return null;
|
|
75
|
+
if (kind === 'md') text = stripFrontmatter(text);
|
|
76
|
+
if (kind === 'json') {
|
|
77
|
+
try {
|
|
78
|
+
const fullText = buf.toString('utf-8');
|
|
79
|
+
text = JSON.stringify(JSON.parse(fullText), null, 2);
|
|
80
|
+
} catch {
|
|
81
|
+
// Invalid JSON — fall through to the raw-text excerpt below.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const excerpt = text.split('\n').slice(0, 8).join('\n').slice(0, 600);
|
|
85
|
+
if (excerpt.trim() === '') return null;
|
|
86
|
+
return excerpt;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Count lines in a text file: number of `\n` bytes plus 1 if the last
|
|
91
|
+
* byte isn't `\n` (so a 3-line file whether or not it has a trailing
|
|
92
|
+
* newline reports 3).
|
|
93
|
+
*/
|
|
94
|
+
function countLines(buf: Buffer): number {
|
|
95
|
+
let count = 0;
|
|
96
|
+
for (const b of buf) if (b === 0x0a) count++;
|
|
97
|
+
if (buf.length > 0 && buf[buf.length - 1] !== 0x0a) count++;
|
|
98
|
+
return count;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Count top-level keys in a JSON object. Returns null if the file is not
|
|
103
|
+
* valid JSON or its root is not a plain object (arrays, primitives →
|
|
104
|
+
* null; caller renders no extra meta).
|
|
105
|
+
*/
|
|
106
|
+
function countJsonKeys(buf: Buffer): number | null {
|
|
107
|
+
try {
|
|
108
|
+
const obj: unknown = JSON.parse(buf.toString('utf-8'));
|
|
109
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
110
|
+
return Object.keys(obj).length;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface ImageDimensions { readonly width: number; readonly height: number; }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read PNG dimensions from the IHDR chunk. Returns null for non-PNG or
|
|
122
|
+
* truncated files. JPEG/WebP/GIF support deferred — most deskwork
|
|
123
|
+
* scrapbook images are screenshots / icons (PNG) and the meta is purely
|
|
124
|
+
* informational, so the empty-string fallback is acceptable for other
|
|
125
|
+
* formats.
|
|
126
|
+
*/
|
|
127
|
+
function readImageDimensions(buf: Buffer): ImageDimensions | null {
|
|
128
|
+
if (buf.length < 24) return null;
|
|
129
|
+
if (buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) return null;
|
|
130
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
43
131
|
}
|
|
44
132
|
|
|
45
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Compute the per-kind extra meta string shown after the kind chip + size:
|
|
135
|
+
* md / txt → "{N} lines"
|
|
136
|
+
* json → "{N} keys" (root must be a plain object; otherwise empty)
|
|
137
|
+
* img → "{W} × {H}" (PNG only; other formats → empty)
|
|
138
|
+
* other → empty
|
|
139
|
+
*
|
|
140
|
+
* ENOENT (race-window with delete) returns empty so the card still
|
|
141
|
+
* renders; other errors propagate to the page renderer.
|
|
142
|
+
*/
|
|
143
|
+
function computeKindMeta(
|
|
144
|
+
ctx: StudioContext,
|
|
145
|
+
site: string,
|
|
146
|
+
path: string,
|
|
46
147
|
item: ScrapbookItem,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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>`);
|
|
148
|
+
): string {
|
|
149
|
+
if (item.kind !== 'md' && item.kind !== 'txt' && item.kind !== 'json' && item.kind !== 'img') {
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
let buf: Buffer;
|
|
153
|
+
try {
|
|
154
|
+
const fullPath = scrapbookFilePath(ctx.projectRoot, ctx.config, site, path, item.name);
|
|
155
|
+
buf = readFileSync(fullPath);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') return '';
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
if (item.kind === 'md' || item.kind === 'txt') return `${countLines(buf)} lines`;
|
|
161
|
+
if (item.kind === 'json') {
|
|
162
|
+
const keys = countJsonKeys(buf);
|
|
163
|
+
return keys !== null ? `${keys} keys` : '';
|
|
164
|
+
}
|
|
165
|
+
// img
|
|
166
|
+
const dims = readImageDimensions(buf);
|
|
167
|
+
return dims ? `${dims.width} × ${dims.height}` : '';
|
|
88
168
|
}
|
|
89
169
|
|
|
90
170
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
171
|
+
* Server-side preview for the closed-state card. Img → bg-frame URL;
|
|
172
|
+
* md → italic Newsreader excerpt with frontmatter stripped; json → mono
|
|
173
|
+
* pre with parse-then-stringify pretty-print; txt → mono pre raw excerpt.
|
|
174
|
+
* Other / empty / binary-as-text → no preview block.
|
|
94
175
|
*/
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
176
|
+
function renderPreview(
|
|
177
|
+
ctx: StudioContext,
|
|
178
|
+
site: string,
|
|
179
|
+
path: string,
|
|
180
|
+
item: ScrapbookItem,
|
|
181
|
+
opts: { secret?: boolean } = {},
|
|
182
|
+
): RawHtml {
|
|
183
|
+
const { secret = false } = opts;
|
|
184
|
+
if (item.kind === 'img') {
|
|
185
|
+
const params = new URLSearchParams({ site, path, name: item.name });
|
|
186
|
+
if (secret) params.set('secret', '1');
|
|
187
|
+
const url = `/api/dev/scrapbook-file?${params.toString()}`;
|
|
188
|
+
return unsafe(html`
|
|
189
|
+
<div class="scrap-preview scrap-preview--img" aria-hidden="true">
|
|
190
|
+
<div class="scrap-preview--img-frame" style="background-image: url("${url}");"></div>
|
|
191
|
+
</div>`);
|
|
192
|
+
}
|
|
193
|
+
if (item.kind !== 'md' && item.kind !== 'txt' && item.kind !== 'json') {
|
|
194
|
+
return unsafe('');
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const fullPath = scrapbookFilePath(
|
|
198
|
+
ctx.projectRoot,
|
|
199
|
+
ctx.config,
|
|
200
|
+
site,
|
|
201
|
+
path,
|
|
202
|
+
item.name,
|
|
203
|
+
secret ? { secret: true } : {},
|
|
204
|
+
);
|
|
205
|
+
const buf = readFileSync(fullPath);
|
|
206
|
+
const excerpt = previewExcerpt(buf, item.kind);
|
|
207
|
+
if (excerpt === null) return unsafe('');
|
|
208
|
+
const safe = escapeHtml(excerpt);
|
|
209
|
+
if (item.kind === 'json' || item.kind === 'txt') {
|
|
210
|
+
return unsafe(html`
|
|
211
|
+
<pre class="scrap-preview scrap-preview--mono" aria-hidden="true">${unsafe(safe)}</pre>`);
|
|
212
|
+
}
|
|
213
|
+
return unsafe(html`
|
|
214
|
+
<div class="scrap-preview scrap-preview-md" aria-hidden="true"><p>${unsafe(safe)}</p></div>`);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
// ENOENT = file disappeared between listScrapbook and this read (race
|
|
217
|
+
// window with delete); rendering an empty preview is the right call.
|
|
218
|
+
// Anything else (EACCES, EISDIR, encoding bugs) propagates so the
|
|
219
|
+
// operator sees a real error instead of a silently-broken page.
|
|
220
|
+
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
|
221
|
+
return unsafe('');
|
|
107
222
|
}
|
|
223
|
+
throw e;
|
|
108
224
|
}
|
|
109
|
-
|
|
110
|
-
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface KindCounts {
|
|
228
|
+
all: number;
|
|
229
|
+
md: number;
|
|
230
|
+
img: number;
|
|
231
|
+
json: number;
|
|
232
|
+
js: number;
|
|
233
|
+
txt: number;
|
|
234
|
+
other: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function countByKind(items: readonly ScrapbookItem[]): KindCounts {
|
|
238
|
+
const counts: KindCounts = {
|
|
239
|
+
all: items.length,
|
|
240
|
+
md: 0,
|
|
241
|
+
img: 0,
|
|
242
|
+
json: 0,
|
|
243
|
+
js: 0,
|
|
244
|
+
txt: 0,
|
|
245
|
+
other: 0,
|
|
246
|
+
};
|
|
247
|
+
for (const i of items) counts[i.kind]++;
|
|
248
|
+
return counts;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renderFilterChips(counts: KindCounts): RawHtml {
|
|
252
|
+
const chip = (kind: keyof KindCounts, label: string, isAll = false): RawHtml =>
|
|
253
|
+
unsafe(html`
|
|
254
|
+
<button class="scrap-filter" type="button" data-filter="${kind}"
|
|
255
|
+
aria-pressed="${isAll ? 'true' : 'false'}">${label} · ${counts[kind]}</button>`);
|
|
256
|
+
return unsafe(html`
|
|
257
|
+
<div class="scrap-filters" role="toolbar" aria-label="filter by kind">
|
|
258
|
+
${chip('all', 'all', true)}
|
|
259
|
+
${chip('md', 'md')}
|
|
260
|
+
${chip('img', 'img')}
|
|
261
|
+
${chip('json', 'json')}
|
|
262
|
+
${chip('txt', 'txt')}
|
|
263
|
+
${chip('other', 'other')}
|
|
264
|
+
</div>`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderSearch(): RawHtml {
|
|
111
268
|
return unsafe(html`
|
|
112
|
-
<
|
|
113
|
-
<
|
|
114
|
-
<span class="
|
|
115
|
-
|
|
269
|
+
<div class="scrap-search">
|
|
270
|
+
<input type="search" placeholder="filter by name or content" aria-label="filter scrapbook" data-scrap-search />
|
|
271
|
+
<span class="scrap-search-kbd">/</span>
|
|
272
|
+
</div>`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderBreadcrumb(site: string, path: string): RawHtml {
|
|
276
|
+
const segments = path.split('/').filter(Boolean);
|
|
277
|
+
const last = segments[segments.length - 1] ?? path;
|
|
278
|
+
return unsafe(html`
|
|
279
|
+
<nav class="scrap-breadcrumb" aria-label="hierarchy">
|
|
280
|
+
<a href="/dev/content/${site}">${site}</a><span class="sep">›</span>
|
|
281
|
+
<b>${last}</b>
|
|
116
282
|
</nav>`);
|
|
117
283
|
}
|
|
118
284
|
|
|
119
|
-
function
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
285
|
+
function renderAside(
|
|
286
|
+
site: string,
|
|
287
|
+
path: string,
|
|
288
|
+
items: readonly ScrapbookItem[],
|
|
289
|
+
totalSize: number,
|
|
290
|
+
lastModified: string | null,
|
|
291
|
+
secretCount: number,
|
|
292
|
+
): RawHtml {
|
|
293
|
+
const lastModifiedLabel = lastModified ? formatRelativeTime(lastModified) : '—';
|
|
294
|
+
const publicCount = items.length;
|
|
295
|
+
const sizeLabel = formatSize(totalSize);
|
|
296
|
+
const folderLabel = path.split('/').filter(Boolean).pop() ?? path;
|
|
297
|
+
const fullPath = `${site}/${path}/scrapbook/`;
|
|
125
298
|
return unsafe(html`
|
|
126
|
-
<aside class="
|
|
127
|
-
<p class="
|
|
128
|
-
|
|
299
|
+
<aside class="scrap-aside">
|
|
300
|
+
<p class="scrap-aside-kicker"><em>§</em> The folder</p>
|
|
301
|
+
<h1 class="scrap-aside-title">${folderLabel}</h1>
|
|
302
|
+
<p class="scrap-aside-meta">${site}</p>
|
|
303
|
+
<hr />
|
|
304
|
+
<p class="scrap-aside-totals">
|
|
305
|
+
<strong>${publicCount}</strong> public ·
|
|
306
|
+
<strong>${secretCount}</strong> secret ·
|
|
307
|
+
<em>${sizeLabel}</em>
|
|
129
308
|
</p>
|
|
130
|
-
<p class="
|
|
131
|
-
<p class="scrapbook-index-meta">${site}</p>
|
|
309
|
+
<p class="scrap-aside-meta">last modified ${lastModifiedLabel}</p>
|
|
132
310
|
<hr />
|
|
133
|
-
<ol class="
|
|
134
|
-
${items.map(
|
|
135
|
-
(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
</li>`),
|
|
139
|
-
)}
|
|
311
|
+
<ol class="scrap-aside-list" data-scrap-aside-list>
|
|
312
|
+
${items.map((item, i) => {
|
|
313
|
+
const seq = String(i + 1).padStart(2, '0');
|
|
314
|
+
return unsafe(html`<li><span class="num">${seq}</span><a href="#item-${i + 1}" data-scrap-aside-link>${item.name}</a></li>`);
|
|
315
|
+
})}
|
|
140
316
|
</ol>
|
|
141
317
|
<hr />
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
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>
|
|
318
|
+
<div class="scrap-aside-actions">
|
|
319
|
+
<button class="scrap-aside-btn scrap-aside-btn--primary" type="button" data-action="new-note">+ new note</button>
|
|
320
|
+
<button class="scrap-aside-btn" type="button" data-action="upload">+ upload file</button>
|
|
152
321
|
</div>
|
|
153
322
|
<hr />
|
|
154
|
-
<p class="
|
|
323
|
+
<p class="scrap-aside-path">${fullPath}</p>
|
|
155
324
|
</aside>`);
|
|
156
325
|
}
|
|
157
326
|
|
|
158
|
-
function
|
|
327
|
+
function renderCard(
|
|
328
|
+
ctx: StudioContext,
|
|
329
|
+
site: string,
|
|
330
|
+
path: string,
|
|
331
|
+
item: ScrapbookItem,
|
|
332
|
+
index: number,
|
|
333
|
+
opts: { secret?: boolean } = {},
|
|
334
|
+
): RawHtml {
|
|
335
|
+
const { secret = false } = opts;
|
|
336
|
+
const seq = String(index + 1).padStart(2, '0');
|
|
337
|
+
const kindLabel = KIND_LABEL[item.kind];
|
|
338
|
+
const kindClass = item.kind === 'other' ? '' : `scrap-kind--${item.kind}`;
|
|
339
|
+
const time = item.mtime
|
|
340
|
+
? html`<time class="scrap-time" datetime="${item.mtime}">${formatRelativeTime(item.mtime)}</time>`
|
|
341
|
+
: '';
|
|
342
|
+
const preview = renderPreview(ctx, site, path, item, { secret });
|
|
343
|
+
const kindMeta = computeKindMeta(ctx, site, path, item);
|
|
344
|
+
const kindMetaHtml: RawHtml = kindMeta
|
|
345
|
+
? unsafe(html`<span>·</span><span>${kindMeta}</span>`)
|
|
346
|
+
: unsafe('');
|
|
347
|
+
const editBtn = item.kind === 'img'
|
|
348
|
+
? unsafe('')
|
|
349
|
+
: unsafe(html`<button class="scrap-tool" type="button" data-action="edit">edit</button>`);
|
|
350
|
+
// Secret cards get id="secret-item-N" to disambiguate from public ids in
|
|
351
|
+
// restoreFromHash + aside cross-link lookups (F4 contract); the
|
|
352
|
+
// mark-secret action toggle reads "mark public" since clicking it moves
|
|
353
|
+
// the card OUT of the secret section.
|
|
354
|
+
const id = secret ? `secret-item-${index + 1}` : `item-${index + 1}`;
|
|
355
|
+
const markSecretLabel = secret ? 'mark public' : 'mark secret';
|
|
356
|
+
const dataSecretAttr = secret ? ' data-secret="true"' : '';
|
|
159
357
|
return unsafe(html`
|
|
160
|
-
<
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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>
|
|
358
|
+
<li class="scrap-card" data-kind="${item.kind}" data-state="closed" id="${id}"${unsafe(dataSecretAttr)}>
|
|
359
|
+
<div class="scrap-card-head">
|
|
360
|
+
<span class="scrap-seq">N° ${seq}</span>
|
|
361
|
+
<span class="scrap-name" data-action="open">${item.name}</span>
|
|
362
|
+
${unsafe(time)}
|
|
168
363
|
</div>
|
|
169
|
-
|
|
364
|
+
<div class="scrap-card-meta">
|
|
365
|
+
<span class="scrap-kind ${kindClass}">${kindLabel}</span>
|
|
366
|
+
<span class="scrap-size">${formatSize(item.size)}</span>
|
|
367
|
+
${kindMetaHtml}
|
|
368
|
+
</div>
|
|
369
|
+
${preview}
|
|
370
|
+
<div class="scrap-card-foot">
|
|
371
|
+
<button class="scrap-tool scrap-tool--primary" type="button" data-action="open">open</button>
|
|
372
|
+
${editBtn}
|
|
373
|
+
<button class="scrap-tool" type="button" data-action="rename">rename</button>
|
|
374
|
+
<button class="scrap-tool" type="button" data-action="mark-secret">${markSecretLabel}</button>
|
|
375
|
+
<span class="spacer"></span>
|
|
376
|
+
<button class="scrap-tool scrap-tool--delete" type="button" data-action="delete">delete</button>
|
|
377
|
+
</div>
|
|
378
|
+
</li>`);
|
|
170
379
|
}
|
|
171
380
|
|
|
172
|
-
function
|
|
381
|
+
function renderDropZone(): RawHtml {
|
|
173
382
|
return unsafe(html`
|
|
174
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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>`);
|
|
383
|
+
<div class="scrap-drop" role="button" tabindex="0" data-action="upload"
|
|
384
|
+
aria-label="Drop a file here, or press Enter to pick one">
|
|
385
|
+
── drop a file here, or pick one ──
|
|
386
|
+
</div>`);
|
|
211
387
|
}
|
|
212
388
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
389
|
+
function renderSecretSection(
|
|
390
|
+
ctx: StudioContext,
|
|
391
|
+
site: string,
|
|
392
|
+
path: string,
|
|
393
|
+
secretItems: readonly ScrapbookItem[],
|
|
394
|
+
): RawHtml {
|
|
395
|
+
if (secretItems.length === 0) return unsafe('');
|
|
396
|
+
const cards = secretItems.map((item, i) => renderCard(ctx, site, path, item, i, { secret: true }));
|
|
221
397
|
return unsafe(html`
|
|
222
|
-
<section class="
|
|
223
|
-
<header class="
|
|
224
|
-
<span class="
|
|
225
|
-
<h2 class="
|
|
226
|
-
<span class="
|
|
227
|
-
private
|
|
228
|
-
</span>
|
|
229
|
-
<span class="scrapbook-secret-count">
|
|
230
|
-
${items.length} ${items.length === 1 ? 'item' : 'items'}
|
|
231
|
-
</span>
|
|
398
|
+
<section class="scrap-secret" aria-label="secret items">
|
|
399
|
+
<header class="scrap-secret-head">
|
|
400
|
+
<span class="scrap-secret-mark" aria-hidden="true">⚿</span>
|
|
401
|
+
<h2 class="scrap-secret-title">Secret</h2>
|
|
402
|
+
<span class="scrap-secret-badge">private — never published</span>
|
|
232
403
|
</header>
|
|
233
|
-
<
|
|
234
|
-
|
|
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
|
-
)}
|
|
404
|
+
<ol class="scrap-cards">
|
|
405
|
+
${cards}
|
|
241
406
|
</ol>
|
|
242
407
|
</section>`);
|
|
243
408
|
}
|
|
@@ -253,46 +418,49 @@ export function renderScrapbookPage(
|
|
|
253
418
|
if (!(site in ctx.config.sites)) {
|
|
254
419
|
throw new Error(`unknown site: ${site}`);
|
|
255
420
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
421
|
+
// listScrapbook returns { exists: false, items: [] } for missing dirs
|
|
422
|
+
// (packages/core/src/scrapbook.ts:337-339), so an empty scrapbook is not
|
|
423
|
+
// an error path. Real errors (slug validation, scrapbookDir resolution
|
|
424
|
+
// failures, FS permission issues) propagate to the studio's error handler.
|
|
425
|
+
const result = listScrapbook(ctx.projectRoot, ctx.config, site, path);
|
|
426
|
+
const items = result.items;
|
|
427
|
+
const secretItems = result.secretItems;
|
|
428
|
+
const totalSize = items.reduce((s, i) => s + i.size, 0);
|
|
429
|
+
const lastModified = items.reduce<string | null>((acc, i) => {
|
|
430
|
+
if (!i.mtime) return acc;
|
|
431
|
+
if (!acc || i.mtime > acc) return i.mtime;
|
|
432
|
+
return acc;
|
|
433
|
+
}, null);
|
|
434
|
+
const counts = countByKind(items);
|
|
435
|
+
const folderLabel = path.split('/').filter(Boolean).pop() ?? path;
|
|
436
|
+
const cards = items.map((item, i) => renderCard(ctx, site, path, item, i));
|
|
437
|
+
const cardsHtml = cards.map((c) => c.__raw).join('');
|
|
267
438
|
const body = html`
|
|
268
439
|
${renderEditorialFolio('content', `scrapbook · ${site}/${path}`)}
|
|
269
|
-
<main class="
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
<
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
</
|
|
276
|
-
|
|
277
|
-
<
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
<span class="scrapbook-drop-overlay-text">drop to add to the scrapbook ◇</span>
|
|
284
|
-
</div>
|
|
440
|
+
<main class="scrap-page" data-site="${site}" data-path="${path}">
|
|
441
|
+
${renderAside(site, path, items, totalSize, lastModified, secretItems.length)}
|
|
442
|
+
<section class="scrap-main">
|
|
443
|
+
<header class="scrap-main-header">
|
|
444
|
+
${renderBreadcrumb(site, path)}
|
|
445
|
+
${renderSearch()}
|
|
446
|
+
</header>
|
|
447
|
+
${renderFilterChips(counts)}
|
|
448
|
+
<ol class="scrap-cards" id="cards" data-scrap-cards>
|
|
449
|
+
${unsafe(cardsHtml)}
|
|
450
|
+
</ol>
|
|
451
|
+
${renderDropZone()}
|
|
452
|
+
${renderSecretSection(ctx, site, path, secretItems)}
|
|
453
|
+
</section>
|
|
285
454
|
</main>`;
|
|
286
|
-
|
|
287
455
|
return layout({
|
|
288
|
-
title: `scrapbook · ${
|
|
456
|
+
title: `scrapbook · ${folderLabel} — dev`,
|
|
289
457
|
cssHrefs: [
|
|
290
458
|
'/static/css/editorial-review.css',
|
|
291
459
|
'/static/css/editorial-nav.css',
|
|
292
460
|
'/static/css/scrapbook.css',
|
|
293
461
|
'/static/css/blog-figure.css',
|
|
294
462
|
],
|
|
295
|
-
bodyAttrs: 'data-review-ui="
|
|
463
|
+
bodyAttrs: 'data-review-ui="scrapbook"',
|
|
296
464
|
bodyHtml: body,
|
|
297
465
|
scriptModules: ['scrapbook-client'],
|
|
298
466
|
});
|