@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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio index page — `/dev/`.
|
|
3
|
+
*
|
|
4
|
+
* The title page of the studio. Reads like the table-of-contents spread
|
|
5
|
+
* of a pressed volume: roman numerals, leader dots, route paths in mono
|
|
6
|
+
* on the right (the "page numbers"). Templated routes (longform reviews,
|
|
7
|
+
* scrapbook) render their slug placeholder in red-pencil italic — they
|
|
8
|
+
* can't be linked because they require a slug, so they appear as
|
|
9
|
+
* non-link entries with the path shown.
|
|
10
|
+
*
|
|
11
|
+
* Four sections × six entries:
|
|
12
|
+
* - Pipeline (i.) — Dashboard
|
|
13
|
+
* - Review desk (ii.–iii.) — Shortform, Longform (templated)
|
|
14
|
+
* - Browse (iv.–v.) — Content view, Scrapbook (templated)
|
|
15
|
+
* - Reference (vi.) — The Compositor's Manual
|
|
16
|
+
*
|
|
17
|
+
* Read-only — links to existing routes only. No editing capability here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { StudioContext } from '../routes/api.ts';
|
|
21
|
+
import { html, unsafe, type RawHtml } from './html.ts';
|
|
22
|
+
import { layout } from './layout.ts';
|
|
23
|
+
import { renderEditorialFolio } from './chrome.ts';
|
|
24
|
+
|
|
25
|
+
interface IndexEntry {
|
|
26
|
+
/** Roman numeral display ("I", "II", …). */
|
|
27
|
+
numeral: string;
|
|
28
|
+
/** Page title. May contain HTML for italic emphasis. */
|
|
29
|
+
titleHtml: string;
|
|
30
|
+
/** Plain-text fallback for accessibility (used as link text). */
|
|
31
|
+
titleText: string;
|
|
32
|
+
/** Route path. When `template` is set, no link renders. */
|
|
33
|
+
route: string;
|
|
34
|
+
/**
|
|
35
|
+
* For templated routes (longform reviews, scrapbook), this is the
|
|
36
|
+
* placeholder text shown in red-pencil italic. The route string still
|
|
37
|
+
* shows the static prefix; the placeholder is appended.
|
|
38
|
+
*/
|
|
39
|
+
template?: { prefix: string; placeholder: string };
|
|
40
|
+
/** Italic description hung below the title. */
|
|
41
|
+
desc: string;
|
|
42
|
+
/** Optional small uppercase mono hint pill. */
|
|
43
|
+
hint?: string;
|
|
44
|
+
/** Optional secondary italic line shown after the hint. */
|
|
45
|
+
postHint?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface IndexSection {
|
|
49
|
+
ornament: string;
|
|
50
|
+
name: string;
|
|
51
|
+
count: string;
|
|
52
|
+
entries: IndexEntry[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SECTIONS: readonly IndexSection[] = [
|
|
56
|
+
{
|
|
57
|
+
ornament: '¶',
|
|
58
|
+
name: 'Pipeline',
|
|
59
|
+
count: 'i. — 1 surface',
|
|
60
|
+
entries: [
|
|
61
|
+
{
|
|
62
|
+
numeral: 'I',
|
|
63
|
+
titleHtml: 'Dashboard',
|
|
64
|
+
titleText: 'Dashboard',
|
|
65
|
+
route: '/dev/editorial-studio',
|
|
66
|
+
desc: 'Press-check. The calendar across all sites; awaiting press; recent proofs; voice-drift signal.',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
ornament: '¶',
|
|
72
|
+
name: 'Review desk',
|
|
73
|
+
count: 'ii.–iii. — 2 surfaces',
|
|
74
|
+
entries: [
|
|
75
|
+
{
|
|
76
|
+
numeral: 'II',
|
|
77
|
+
titleHtml: 'Shortform reviews',
|
|
78
|
+
titleText: 'Shortform reviews',
|
|
79
|
+
route: '/dev/editorial-review-shortform',
|
|
80
|
+
desc: 'Cross-platform copy desk. Reddit, LinkedIn, YouTube, Instagram — galley slips, one per platform.',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
numeral: 'III',
|
|
84
|
+
titleHtml: 'Longform reviews',
|
|
85
|
+
titleText: 'Longform reviews',
|
|
86
|
+
route: '/dev/editorial-review/<slug>',
|
|
87
|
+
template: { prefix: '/dev/editorial-review/', placeholder: '<slug>' },
|
|
88
|
+
desc: 'Per-entry margin notes, decisions, iterate flow.',
|
|
89
|
+
hint: 'entry-by-entry',
|
|
90
|
+
postHint: 'Reach via the Dashboard or Content view; each review is opened against a specific slug.',
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
ornament: '¶',
|
|
96
|
+
name: 'Browse',
|
|
97
|
+
count: 'iv.–v. — 2 surfaces',
|
|
98
|
+
entries: [
|
|
99
|
+
{
|
|
100
|
+
numeral: 'IV',
|
|
101
|
+
titleHtml: 'Content view',
|
|
102
|
+
titleText: 'Content view',
|
|
103
|
+
route: '/dev/content',
|
|
104
|
+
desc: 'The shape of the work. A drillable tree of nodes; click any to read its head matter and browse its scrapbook.',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
numeral: 'V',
|
|
108
|
+
titleHtml: 'Scrapbook',
|
|
109
|
+
titleText: 'Scrapbook',
|
|
110
|
+
route: '/dev/scrapbook/<site>/<path>',
|
|
111
|
+
template: { prefix: '/dev/scrapbook/', placeholder: '<site>/<path>' },
|
|
112
|
+
desc: 'Research, receipts, working notes. Addressed by hierarchical path; secret items appear in their own section.',
|
|
113
|
+
hint: 'path-addressed',
|
|
114
|
+
postHint: "Reach via the Content view's per-node drawer, or address directly.",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
ornament: '¶',
|
|
120
|
+
name: 'Reference',
|
|
121
|
+
count: 'vi. — 1 surface',
|
|
122
|
+
entries: [
|
|
123
|
+
{
|
|
124
|
+
numeral: 'VI',
|
|
125
|
+
titleHtml: "The Compositor's <em>Manual</em>",
|
|
126
|
+
titleText: "The Compositor's Manual",
|
|
127
|
+
route: '/dev/editorial-help',
|
|
128
|
+
desc: 'The workflow, the skill catalogue, the names of the things — read once, return when the work asks.',
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
function renderEntryTitle(entry: IndexEntry): string {
|
|
135
|
+
if (entry.template) {
|
|
136
|
+
return html`<span class="er-toc-entry__title">${unsafe(entry.titleHtml)}</span>`;
|
|
137
|
+
}
|
|
138
|
+
return html`<a class="er-toc-entry__title" href="${entry.route}">${unsafe(entry.titleHtml)}</a>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function renderEntryRoute(entry: IndexEntry): string {
|
|
142
|
+
if (entry.template) {
|
|
143
|
+
return html`<span class="er-toc-entry__route is-template">${entry.template.prefix}<em>${entry.template.placeholder}</em></span>`;
|
|
144
|
+
}
|
|
145
|
+
return html`<span class="er-toc-entry__route">${entry.route}</span>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderEntryDesc(entry: IndexEntry): string {
|
|
149
|
+
const hint = entry.hint
|
|
150
|
+
? html` <span class="er-toc-entry__hint">${entry.hint}</span>`
|
|
151
|
+
: '';
|
|
152
|
+
const post = entry.postHint
|
|
153
|
+
? html` <em>${entry.postHint}</em>`
|
|
154
|
+
: '';
|
|
155
|
+
return html`<p class="er-toc-entry__desc">${entry.desc}${unsafe(hint)}${unsafe(post)}</p>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderEntry(entry: IndexEntry): RawHtml {
|
|
159
|
+
return unsafe(html`
|
|
160
|
+
<li class="er-toc-entry">
|
|
161
|
+
<div class="er-toc-entry__row">
|
|
162
|
+
<span class="er-toc-entry__num">${entry.numeral}</span>
|
|
163
|
+
${unsafe(renderEntryTitle(entry))}
|
|
164
|
+
${unsafe(renderEntryRoute(entry))}
|
|
165
|
+
</div>
|
|
166
|
+
${unsafe(renderEntryDesc(entry))}
|
|
167
|
+
</li>`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderSection(section: IndexSection): RawHtml {
|
|
171
|
+
return unsafe(html`
|
|
172
|
+
<section class="er-toc-section">
|
|
173
|
+
<div class="er-toc-section-head">
|
|
174
|
+
<span class="er-toc-section-head__ornament">${section.ornament}</span>
|
|
175
|
+
<span class="er-toc-section-head__name">${section.name}</span>
|
|
176
|
+
<span class="er-toc-section-head__count">${section.count}</span>
|
|
177
|
+
</div>
|
|
178
|
+
<ol class="er-toc-list">
|
|
179
|
+
${section.entries.map(renderEntry)}
|
|
180
|
+
</ol>
|
|
181
|
+
</section>`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function renderStudioIndex(_ctx: StudioContext): string {
|
|
185
|
+
const body = html`
|
|
186
|
+
${renderEditorialFolio('index', 'index of the press')}
|
|
187
|
+
<main class="er-toc-page">
|
|
188
|
+
<header class="er-pagehead er-pagehead--centered er-pagehead--toc">
|
|
189
|
+
<p class="er-pagehead__kicker">Index of the <em>Press</em></p>
|
|
190
|
+
<h1 class="er-pagehead__title">Editorial <em>Studio</em></h1>
|
|
191
|
+
<p class="er-pagehead__deck">
|
|
192
|
+
A reference of the dev surfaces — pipeline, review desk, browse, manual.
|
|
193
|
+
Begin where the work is.
|
|
194
|
+
</p>
|
|
195
|
+
</header>
|
|
196
|
+
${SECTIONS.map(renderSection)}
|
|
197
|
+
<footer class="er-toc-colophon">
|
|
198
|
+
Pressed in the deskwork studio. Loopback only.<br>
|
|
199
|
+
<span class="er-toc-colophon__rule"></span>
|
|
200
|
+
</footer>
|
|
201
|
+
</main>`;
|
|
202
|
+
|
|
203
|
+
return layout({
|
|
204
|
+
title: 'Editorial Studio — Index',
|
|
205
|
+
cssHrefs: [
|
|
206
|
+
'/static/css/editorial-review.css',
|
|
207
|
+
'/static/css/editorial-nav.css',
|
|
208
|
+
],
|
|
209
|
+
bodyAttrs: 'data-review-ui="studio"',
|
|
210
|
+
bodyHtml: body,
|
|
211
|
+
scriptModules: [],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTML shell for studio pages. Replaces audiocontrol's `<BlogLayout>`
|
|
3
|
+
* Astro component with a plain string-builder.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { escapeHtml } from './html.ts';
|
|
7
|
+
|
|
8
|
+
export interface EmbeddedJson {
|
|
9
|
+
/** `id` attribute of the `<script type="application/json">` tag. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Value to JSON-stringify into the tag body. */
|
|
12
|
+
data: unknown;
|
|
13
|
+
/** Optional extra attribute (e.g. `data-rename-slugs`). */
|
|
14
|
+
attr?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LayoutOptions {
|
|
18
|
+
title: string;
|
|
19
|
+
cssHrefs: string[];
|
|
20
|
+
bodyHtml: string;
|
|
21
|
+
/**
|
|
22
|
+
* Optional attributes for the `<body>` tag itself, e.g.
|
|
23
|
+
* `data-review-ui="studio"`. Caller is responsible for any escaping
|
|
24
|
+
* inside the string — typically these are static.
|
|
25
|
+
*/
|
|
26
|
+
bodyAttrs?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Embed JSON payloads as `<script type="application/json" id="...">`.
|
|
29
|
+
* The client reads `document.getElementById(id).textContent` and
|
|
30
|
+
* `JSON.parse`s it for hydration.
|
|
31
|
+
*/
|
|
32
|
+
embeddedJson?: ReadonlyArray<EmbeddedJson>;
|
|
33
|
+
/** Module scripts loaded after the body. */
|
|
34
|
+
scriptModules: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function layout(options: LayoutOptions): string {
|
|
38
|
+
const {
|
|
39
|
+
title,
|
|
40
|
+
cssHrefs,
|
|
41
|
+
bodyHtml,
|
|
42
|
+
bodyAttrs,
|
|
43
|
+
embeddedJson,
|
|
44
|
+
scriptModules,
|
|
45
|
+
} = options;
|
|
46
|
+
|
|
47
|
+
const cssTags = cssHrefs
|
|
48
|
+
.map((href) => ` <link rel="stylesheet" href="${escapeAttr(href)}">`)
|
|
49
|
+
.join('\n');
|
|
50
|
+
|
|
51
|
+
const jsonTags = (embeddedJson ?? [])
|
|
52
|
+
.map((j) => {
|
|
53
|
+
const attrPart = j.attr ? ` ${j.attr}` : '';
|
|
54
|
+
const idPart = j.id ? ` id="${escapeAttr(j.id)}"` : '';
|
|
55
|
+
return ` <script type="application/json"${idPart}${attrPart}>${escapeForScriptTag(JSON.stringify(j.data))}</script>`;
|
|
56
|
+
})
|
|
57
|
+
.join('\n');
|
|
58
|
+
|
|
59
|
+
const scriptTags = scriptModules
|
|
60
|
+
.map((src) => ` <script type="module" src="${escapeAttr(src)}"></script>`)
|
|
61
|
+
.join('\n');
|
|
62
|
+
|
|
63
|
+
const bodyOpen = bodyAttrs ? `<body ${bodyAttrs}>` : '<body>';
|
|
64
|
+
|
|
65
|
+
return `<!DOCTYPE html>
|
|
66
|
+
<html lang="en">
|
|
67
|
+
<head>
|
|
68
|
+
<meta charset="utf-8">
|
|
69
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
70
|
+
<meta name="robots" content="noindex">
|
|
71
|
+
<title>${escapeHtml(title)}</title>
|
|
72
|
+
${cssTags}
|
|
73
|
+
</head>
|
|
74
|
+
${bodyOpen}
|
|
75
|
+
${bodyHtml}
|
|
76
|
+
${jsonTags}
|
|
77
|
+
${scriptTags}
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function escapeAttr(s: string): string {
|
|
84
|
+
return escapeHtml(s);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Escape a JSON payload so it's safe inside a `<script>` tag. The only
|
|
89
|
+
* sequence we need to neutralize is `</script>` (and a few defense-in-
|
|
90
|
+
* depth cousins) so the browser doesn't terminate the script element.
|
|
91
|
+
*/
|
|
92
|
+
function escapeForScriptTag(json: string): string {
|
|
93
|
+
return json.replace(/<\/(script|!--)/gi, '<\\/$1');
|
|
94
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrapbook drawer for the review surface — extracted from `review.ts`
|
|
3
|
+
* to keep that file under the project's 500-line guideline.
|
|
4
|
+
*
|
|
5
|
+
* Renders the IMMEDIATE node's scrapbook (no ancestors) per Phase 16c.
|
|
6
|
+
* Always visible: an empty scrapbook still renders the section so the
|
|
7
|
+
* operator sees the affordance for this node.
|
|
8
|
+
*
|
|
9
|
+
* Phase 19c+: when the calendar entry has a stable id binding AND a
|
|
10
|
+
* per-request content index is wired, resolve the on-disk scrapbook
|
|
11
|
+
* directory via the index. This makes writingcontrol-shape entries
|
|
12
|
+
* (slug != fs path) list their items at the actual file location.
|
|
13
|
+
* Falls back to slug-template addressing for unbound / legacy entries.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import {
|
|
19
|
+
listScrapbook,
|
|
20
|
+
listScrapbookAtDir,
|
|
21
|
+
scrapbookDirForEntry,
|
|
22
|
+
type ScrapbookItem,
|
|
23
|
+
type ScrapbookSummary,
|
|
24
|
+
} from '@deskwork/core/scrapbook';
|
|
25
|
+
import { resolveContentDir } from '@deskwork/core/paths';
|
|
26
|
+
import type { ContentIndex } from '@deskwork/core/content-index';
|
|
27
|
+
import type { CalendarEntry } from '@deskwork/core/types';
|
|
28
|
+
import type { StudioContext } from '../routes/api.ts';
|
|
29
|
+
import {
|
|
30
|
+
renderEmptyScrapbookRow,
|
|
31
|
+
renderReadOnlyScrapbookRow,
|
|
32
|
+
scrapbookViewerUrl,
|
|
33
|
+
type InlineTextLoader,
|
|
34
|
+
} from '../components/scrapbook-item.ts';
|
|
35
|
+
import { html, unsafe, type RawHtml } from './html.ts';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build an inline-text loader for the shared scrapbook-item renderer.
|
|
39
|
+
* Reads at most `maxBytes` from a file inside the scrapbook directory
|
|
40
|
+
* and returns the bytes decoded as UTF-8. Returns null when the file
|
|
41
|
+
* isn't readable as text — the renderer falls back to a download link.
|
|
42
|
+
*
|
|
43
|
+
* Path resolution prefers the index-driven binding (the entry's id is
|
|
44
|
+
* looked up against the per-request content index) so writingcontrol-
|
|
45
|
+
* shape entries — slug `the-outbound`, file at
|
|
46
|
+
* `<contentDir>/projects/the-outbound/index.md` — read scrapbook items
|
|
47
|
+
* from the right on-disk directory. Falls back to the slug-template
|
|
48
|
+
* directory for entries that have no id binding yet (pre-doctor).
|
|
49
|
+
*/
|
|
50
|
+
function makeInlineTextLoader(
|
|
51
|
+
ctx: StudioContext,
|
|
52
|
+
site: string,
|
|
53
|
+
entry: { id?: string; slug: string } | null,
|
|
54
|
+
slug: string,
|
|
55
|
+
index?: ContentIndex,
|
|
56
|
+
): InlineTextLoader {
|
|
57
|
+
const scrapbookDir = entry
|
|
58
|
+
? scrapbookDirForEntry(ctx.projectRoot, ctx.config, site, entry, index)
|
|
59
|
+
: join(
|
|
60
|
+
resolveContentDir(ctx.projectRoot, ctx.config, site),
|
|
61
|
+
slug,
|
|
62
|
+
'scrapbook',
|
|
63
|
+
);
|
|
64
|
+
return (filename, maxBytes) => {
|
|
65
|
+
try {
|
|
66
|
+
const buf = readFileSync(join(scrapbookDir, filename));
|
|
67
|
+
const slice = buf.subarray(0, Math.min(buf.byteLength, maxBytes));
|
|
68
|
+
return slice.toString('utf-8');
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderScrapbookDrawerItems(
|
|
76
|
+
site: string,
|
|
77
|
+
slug: string,
|
|
78
|
+
items: readonly ScrapbookItem[],
|
|
79
|
+
loader: InlineTextLoader,
|
|
80
|
+
): RawHtml {
|
|
81
|
+
if (items.length === 0) {
|
|
82
|
+
return renderEmptyScrapbookRow();
|
|
83
|
+
}
|
|
84
|
+
const rows = items.map((item) =>
|
|
85
|
+
renderReadOnlyScrapbookRow(
|
|
86
|
+
{ site, path: slug },
|
|
87
|
+
item,
|
|
88
|
+
{ inlinePreviewLoader: loader },
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
return unsafe(rows.map((r) => r.__raw).join(''));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function renderScrapbookDrawer(
|
|
95
|
+
ctx: StudioContext,
|
|
96
|
+
site: string,
|
|
97
|
+
entry: CalendarEntry | null,
|
|
98
|
+
slug: string,
|
|
99
|
+
index?: ContentIndex,
|
|
100
|
+
): RawHtml {
|
|
101
|
+
const summary: ScrapbookSummary | null = (() => {
|
|
102
|
+
try {
|
|
103
|
+
if (entry !== null && entry.id !== undefined && entry.id !== '') {
|
|
104
|
+
const scrapbookDir = scrapbookDirForEntry(
|
|
105
|
+
ctx.projectRoot,
|
|
106
|
+
ctx.config,
|
|
107
|
+
site,
|
|
108
|
+
entry,
|
|
109
|
+
index,
|
|
110
|
+
);
|
|
111
|
+
return listScrapbookAtDir(site, entry.slug, scrapbookDir);
|
|
112
|
+
}
|
|
113
|
+
return listScrapbook(ctx.projectRoot, ctx.config, site, slug);
|
|
114
|
+
} catch {
|
|
115
|
+
// listScrapbook validates the slug; an invalid slug shouldn't
|
|
116
|
+
// tank the whole review page. Treat as empty drawer + log via
|
|
117
|
+
// the unobtrusive empty state.
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
|
|
122
|
+
const items = summary?.items ?? [];
|
|
123
|
+
const secretItems = summary?.secretItems ?? [];
|
|
124
|
+
const total = items.length + secretItems.length;
|
|
125
|
+
const loader = makeInlineTextLoader(ctx, site, entry, slug, index);
|
|
126
|
+
|
|
127
|
+
return unsafe(html`
|
|
128
|
+
<aside class="er-scrapbook-drawer" data-scrapbook-drawer aria-label="Scrapbook for this entry">
|
|
129
|
+
<header class="er-scrapbook-drawer-head">
|
|
130
|
+
<span class="er-scrapbook-drawer-kicker">§ Scrapbook</span>
|
|
131
|
+
<span class="er-scrapbook-drawer-count">${total} ${total === 1 ? 'item' : 'items'}</span>
|
|
132
|
+
<a class="er-scrapbook-drawer-open" href="${scrapbookViewerUrl({ site, path: slug })}"
|
|
133
|
+
title="Open the standalone scrapbook viewer">open ↗</a>
|
|
134
|
+
</header>
|
|
135
|
+
<div class="er-scrapbook-drawer-body">
|
|
136
|
+
${renderScrapbookDrawerItems(site, slug, items, loader)}
|
|
137
|
+
${
|
|
138
|
+
secretItems.length > 0
|
|
139
|
+
? unsafe(html`
|
|
140
|
+
<div class="er-scrapbook-drawer-secret">
|
|
141
|
+
<p class="er-scrapbook-drawer-secret-head">
|
|
142
|
+
<span aria-hidden="true">⚿</span> secret · ${secretItems.length}
|
|
143
|
+
</p>
|
|
144
|
+
${renderScrapbookDrawerItems(site, slug, secretItems, loader)}
|
|
145
|
+
</div>`)
|
|
146
|
+
: ''
|
|
147
|
+
}
|
|
148
|
+
</div>
|
|
149
|
+
</aside>`);
|
|
150
|
+
}
|