@nysds/playground 0.1.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/LICENSE +25 -0
- package/README.md +364 -0
- package/bin/cli.mjs +223 -0
- package/bin/cli.test.mjs +36 -0
- package/decks/customizing-components.json +97 -0
- package/dist/assets/index-LO1tomgR.css +6 -0
- package/dist/assets/index-bBtGCGL_.js +5190 -0
- package/dist/assets/internal/typescript.js +193739 -0
- package/dist/assets/nys-icon.library-Bi_7DKlD-YSs5zqZy-DXISxj9N.js +609 -0
- package/dist/assets/nys-icon.library-CwuPZJAc-TryaOS7Z.js +600 -0
- package/dist/assets/playground-typescript-worker-BcTrPYfY.js +87 -0
- package/dist/assets/playground-typescript-worker.js +87 -0
- package/dist/favicon.svg +10 -0
- package/dist/index.html +769 -0
- package/dist/nysds-logo.svg +21 -0
- package/dist/nysds-symbol.svg +7 -0
- package/index.html +768 -0
- package/package.json +59 -0
- package/presets/00-welcome.json +8 -0
- package/presets/01-button.json +7 -0
- package/presets/02-alert.json +7 -0
- package/presets/03-badge-and-avatar.json +7 -0
- package/presets/04-text-input.json +7 -0
- package/presets/05-select-radio-checkbox.json +7 -0
- package/presets/06-form-validation.json +7 -0
- package/presets/07-card.json +7 -0
- package/presets/08-accordion.json +7 -0
- package/presets/09-tabs.json +7 -0
- package/presets/10-modal.json +7 -0
- package/presets/11-stepper.json +7 -0
- package/presets/12-table-and-pagination.json +7 -0
- package/presets/13-tooltip-and-dropdown.json +7 -0
- package/presets/14-navigation.json +7 -0
- package/presets/15-page-structure.json +7 -0
- package/presets/16-themes.json +7 -0
- package/presets/17-utility-classes.json +7 -0
- package/presets/README.md +118 -0
- package/public/favicon.svg +10 -0
- package/public/nysds-logo.svg +21 -0
- package/public/nysds-symbol.svg +7 -0
- package/src/app.css +1087 -0
- package/src/debounce.ts +86 -0
- package/src/deck-model.ts +299 -0
- package/src/deck-store.ts +144 -0
- package/src/decks.test.ts +288 -0
- package/src/editor-panes.ts +190 -0
- package/src/editors.ts +92 -0
- package/src/home.ts +225 -0
- package/src/icon-names.ts +117 -0
- package/src/icons.test.ts +58 -0
- package/src/keys.ts +162 -0
- package/src/main.ts +1456 -0
- package/src/playground.config.ts +74 -0
- package/src/playground.ts +261 -0
- package/src/present.ts +398 -0
- package/src/preset-schema.ts +228 -0
- package/src/route.test.ts +56 -0
- package/src/routing.ts +60 -0
- package/src/settings.ts +211 -0
- package/src/starters.ts +86 -0
- package/src/state.test.ts +544 -0
- package/src/state.ts +237 -0
- package/src/theme.ts +82 -0
- package/src/version-catalog.ts +42 -0
- package/src/versions.ts +88 -0
- package/src/wrapper.ts +84 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +62 -0
package/src/debounce.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quiet-period debounce that decides when the preview rebuilds.
|
|
3
|
+
*
|
|
4
|
+
* It takes its clock as an argument so `src/state.test.ts` can drive it with a
|
|
5
|
+
* fake one instead of waiting in real time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The timer functions the debounce needs. */
|
|
9
|
+
export interface Clock {
|
|
10
|
+
setTimeout(handler: () => void, delay: number): number;
|
|
11
|
+
clearTimeout(handle: number): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A debounce that runs `fn` once the calls stop for `delay` milliseconds. */
|
|
15
|
+
export interface QuietDebounce {
|
|
16
|
+
/** Notes an edit. Schedules `fn` unless the delay is `null`. */
|
|
17
|
+
schedule(): void;
|
|
18
|
+
/** Runs `fn` now and drops anything scheduled. */
|
|
19
|
+
flush(): void;
|
|
20
|
+
/** Drops anything scheduled without running `fn`. */
|
|
21
|
+
cancel(): void;
|
|
22
|
+
/** Changes the quiet period. `null` stops automatic runs. */
|
|
23
|
+
setDelay(delay: number | null): void;
|
|
24
|
+
/** Whether a run is waiting. */
|
|
25
|
+
readonly pending: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a debounce that waits for a pause rather than firing eagerly.
|
|
30
|
+
*
|
|
31
|
+
* A `null` delay means the caller triggers every run itself, which is what the
|
|
32
|
+
* playground's "Manually" update mode does.
|
|
33
|
+
*/
|
|
34
|
+
export function createQuietDebounce(
|
|
35
|
+
fn: () => void,
|
|
36
|
+
delay: number | null,
|
|
37
|
+
clock: Clock = globalThis as unknown as Clock,
|
|
38
|
+
): QuietDebounce {
|
|
39
|
+
let handle: number | undefined;
|
|
40
|
+
let currentDelay = delay;
|
|
41
|
+
let waiting = false;
|
|
42
|
+
|
|
43
|
+
const cancel = (): void => {
|
|
44
|
+
if (handle !== undefined) {
|
|
45
|
+
clock.clearTimeout(handle);
|
|
46
|
+
handle = undefined;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const start = (): void => {
|
|
51
|
+
cancel();
|
|
52
|
+
if (currentDelay === null) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
handle = clock.setTimeout(() => {
|
|
56
|
+
handle = undefined;
|
|
57
|
+
waiting = false;
|
|
58
|
+
fn();
|
|
59
|
+
}, currentDelay);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
schedule(): void {
|
|
64
|
+
waiting = true;
|
|
65
|
+
start();
|
|
66
|
+
},
|
|
67
|
+
flush(): void {
|
|
68
|
+
cancel();
|
|
69
|
+
waiting = false;
|
|
70
|
+
fn();
|
|
71
|
+
},
|
|
72
|
+
cancel(): void {
|
|
73
|
+
cancel();
|
|
74
|
+
waiting = false;
|
|
75
|
+
},
|
|
76
|
+
setDelay(next: number | null): void {
|
|
77
|
+
currentDelay = next;
|
|
78
|
+
if (waiting) {
|
|
79
|
+
start();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
get pending(): boolean {
|
|
83
|
+
return waiting;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape decks take in the browser store, and the pure helpers that build
|
|
3
|
+
* and normalize them.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here touches IndexedDB or the DOM, so `src/state.test.ts` can cover
|
|
6
|
+
* id generation, import normalization, the export shape, and seed diffing
|
|
7
|
+
* without a browser.
|
|
8
|
+
*/
|
|
9
|
+
import type {Preset} from './preset-schema.ts';
|
|
10
|
+
import {parseDeck, parsePreset} from './preset-schema.ts';
|
|
11
|
+
|
|
12
|
+
/** One slide. Same shape as a bundled preset. */
|
|
13
|
+
export type Slide = Preset;
|
|
14
|
+
|
|
15
|
+
/** A deck as the store holds it. */
|
|
16
|
+
export interface StoredDeck {
|
|
17
|
+
/** The slug used in `?deck=`. Unique in the store. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** The deck name. */
|
|
20
|
+
title: string;
|
|
21
|
+
/** One sentence describing the deck. */
|
|
22
|
+
description: string;
|
|
23
|
+
/** CSS injected into every slide's hidden head. */
|
|
24
|
+
baseCss: string;
|
|
25
|
+
/** The slides, in order. At least one. */
|
|
26
|
+
slides: Slide[];
|
|
27
|
+
/** When the deck was created, as an ISO timestamp. */
|
|
28
|
+
createdAt: string;
|
|
29
|
+
/** When the deck last changed, as an ISO timestamp. */
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
/** The bundled starter this deck came from, when it came from one. */
|
|
32
|
+
starter?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The markup a brand new slide starts from. */
|
|
36
|
+
export const BLANK_SLIDE_HTML = '<nys-button label="Excelsior"></nys-button>\n';
|
|
37
|
+
|
|
38
|
+
/** Turns a title into a slug that is safe in a URL and a filename. */
|
|
39
|
+
export function slugify(title: string): string {
|
|
40
|
+
const slug = title
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
+
.replace(/^-+|-+$/g, '')
|
|
44
|
+
.slice(0, 60);
|
|
45
|
+
return slug || 'deck';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns `candidate`, or `candidate-2`, `candidate-3`, and so on until the id
|
|
50
|
+
* is free.
|
|
51
|
+
*/
|
|
52
|
+
export function uniqueId(candidate: string, taken: readonly string[]): string {
|
|
53
|
+
if (!taken.includes(candidate)) {
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
57
|
+
const next = `${candidate}-${suffix}`;
|
|
58
|
+
if (!taken.includes(next)) {
|
|
59
|
+
return next;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Builds a slide id from a title, unique within the slides given. */
|
|
65
|
+
export function nextSlideId(title: string, slides: readonly Slide[]): string {
|
|
66
|
+
const taken = slides.map((slide) => slide.id);
|
|
67
|
+
const base = title.trim() ? slugify(title) : `slide-${slides.length + 1}`;
|
|
68
|
+
return uniqueId(base, taken);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fills in every field a slide needs, so callers can pass a partial one. */
|
|
72
|
+
export function makeSlide(partial: Partial<Slide> & {id: string; title: string}): Slide {
|
|
73
|
+
return {
|
|
74
|
+
id: partial.id,
|
|
75
|
+
title: partial.title,
|
|
76
|
+
description: partial.description ?? '',
|
|
77
|
+
group: partial.group ?? '',
|
|
78
|
+
notes: partial.notes ?? '',
|
|
79
|
+
html: partial.html ?? '',
|
|
80
|
+
css: partial.css ?? '',
|
|
81
|
+
js: partial.js ?? '',
|
|
82
|
+
version: partial.version ?? 'latest',
|
|
83
|
+
editors: partial.editors ?? null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Builds an empty deck with one starter slide. */
|
|
88
|
+
export function makeDeck(title: string, taken: readonly string[], now = new Date()): StoredDeck {
|
|
89
|
+
const stamp = now.toISOString();
|
|
90
|
+
return {
|
|
91
|
+
id: uniqueId(slugify(title), taken),
|
|
92
|
+
title: title.trim() || 'Untitled deck',
|
|
93
|
+
description: '',
|
|
94
|
+
baseCss: '',
|
|
95
|
+
slides: [makeSlide({id: 'slide-1', title: 'Slide 1', html: BLANK_SLIDE_HTML})],
|
|
96
|
+
createdAt: stamp,
|
|
97
|
+
updatedAt: stamp,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Builds a copy of a deck under a free id. */
|
|
102
|
+
export function copyOfDeck(
|
|
103
|
+
deck: StoredDeck,
|
|
104
|
+
taken: readonly string[],
|
|
105
|
+
now = new Date(),
|
|
106
|
+
): StoredDeck {
|
|
107
|
+
const stamp = now.toISOString();
|
|
108
|
+
return {
|
|
109
|
+
...deck,
|
|
110
|
+
id: uniqueId(`${deck.id}-copy`, taken),
|
|
111
|
+
title: `Copy of ${deck.title}`,
|
|
112
|
+
slides: deck.slides.map((slide) => ({...slide})),
|
|
113
|
+
createdAt: stamp,
|
|
114
|
+
updatedAt: stamp,
|
|
115
|
+
starter: undefined,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Returns the index of a slide, or `-1`. */
|
|
120
|
+
export function slideIndex(deck: StoredDeck, id: string | null | undefined): number {
|
|
121
|
+
return id ? deck.slides.findIndex((slide) => slide.id === id) : -1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Returns a slide by id. */
|
|
125
|
+
export function getSlide(deck: StoredDeck, id: string | null | undefined): Slide | undefined {
|
|
126
|
+
return id ? deck.slides.find((slide) => slide.id === id) : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The JSON shape a deck takes on disk, and what Export writes. */
|
|
130
|
+
export interface DeckFile {
|
|
131
|
+
title: string;
|
|
132
|
+
description: string;
|
|
133
|
+
boilerplate: {baseCss: string};
|
|
134
|
+
presets: Slide[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Converts a stored deck to the deck file format, without timestamps. */
|
|
138
|
+
export function toDeckFile(deck: StoredDeck): DeckFile {
|
|
139
|
+
return {
|
|
140
|
+
title: deck.title,
|
|
141
|
+
description: deck.description,
|
|
142
|
+
boilerplate: {baseCss: deck.baseCss},
|
|
143
|
+
presets: deck.slides.map((slide) => ({...slide})),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** What `normalizeImport` gives back. */
|
|
148
|
+
export type ImportResult =
|
|
149
|
+
| {ok: true; deck: StoredDeck}
|
|
150
|
+
| {ok: false; message: string};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Turns imported JSON into a deck.
|
|
154
|
+
*
|
|
155
|
+
* It accepts a deck file, tolerating `$comment` and `boilerplate.head`, and
|
|
156
|
+
* also a single preset object, which becomes a one-slide deck. Validation goes
|
|
157
|
+
* through the same schema the bundled files use.
|
|
158
|
+
*/
|
|
159
|
+
export function normalizeImport(
|
|
160
|
+
raw: unknown,
|
|
161
|
+
taken: readonly string[],
|
|
162
|
+
fallbackName = 'imported-deck',
|
|
163
|
+
now = new Date(),
|
|
164
|
+
): ImportResult {
|
|
165
|
+
// The id comes from the file name when there is one, the way the bundled
|
|
166
|
+
// decks get theirs, so re-importing a deck file lands next to the original
|
|
167
|
+
// as `<name>-2` rather than under a different slug.
|
|
168
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
169
|
+
return {ok: false, message: 'That file is not a JSON object.'};
|
|
170
|
+
}
|
|
171
|
+
const record = raw as Record<string, unknown>;
|
|
172
|
+
const stamp = now.toISOString();
|
|
173
|
+
const problems: string[] = [];
|
|
174
|
+
const collect = (message: string): void => {
|
|
175
|
+
problems.push(message);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (Array.isArray(record['presets']) || Array.isArray(record['slides'])) {
|
|
179
|
+
const presets = record['presets'] ?? record['slides'];
|
|
180
|
+
const deck = parseDeck(
|
|
181
|
+
`${fallbackName}.json`,
|
|
182
|
+
{...record, presets},
|
|
183
|
+
collect,
|
|
184
|
+
() => undefined,
|
|
185
|
+
);
|
|
186
|
+
if (!deck) {
|
|
187
|
+
return {ok: false, message: problems[0] ?? 'That deck has no usable slides.'};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
deck: {
|
|
192
|
+
id: uniqueId(slugify(fallbackName || deck.title), taken),
|
|
193
|
+
title: deck.title,
|
|
194
|
+
description: deck.description,
|
|
195
|
+
baseCss: deck.baseCss,
|
|
196
|
+
slides: deck.presets.map((preset) => ({...preset})),
|
|
197
|
+
createdAt: stamp,
|
|
198
|
+
updatedAt: stamp,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const preset = parsePreset(`${fallbackName}.json`, record, 'slide-1', collect);
|
|
204
|
+
if (!preset) {
|
|
205
|
+
return {ok: false, message: problems[0] ?? 'That file is not a deck or a preset.'};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
deck: {
|
|
210
|
+
id: uniqueId(slugify(fallbackName || preset.title), taken),
|
|
211
|
+
title: preset.title,
|
|
212
|
+
description: preset.description,
|
|
213
|
+
baseCss: '',
|
|
214
|
+
slides: [preset],
|
|
215
|
+
createdAt: stamp,
|
|
216
|
+
updatedAt: stamp,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Returns the starter decks that are not in the store yet. */
|
|
222
|
+
export function missingStarters(
|
|
223
|
+
starters: readonly StoredDeck[],
|
|
224
|
+
stored: readonly StoredDeck[],
|
|
225
|
+
): StoredDeck[] {
|
|
226
|
+
const seeded = new Set(stored.map((deck) => deck.starter).filter(Boolean));
|
|
227
|
+
const takenIds = stored.map((deck) => deck.id);
|
|
228
|
+
const additions: StoredDeck[] = [];
|
|
229
|
+
for (const starter of starters) {
|
|
230
|
+
if (starter.starter && seeded.has(starter.starter)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const id = uniqueId(starter.id, [...takenIds, ...additions.map((deck) => deck.id)]);
|
|
234
|
+
additions.push({...starter, id, slides: starter.slides.map((slide) => ({...slide}))});
|
|
235
|
+
}
|
|
236
|
+
return additions;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A starter key an earlier build seeded under a different file name. */
|
|
240
|
+
export interface RetiredStarter {
|
|
241
|
+
/** The old starter key. */
|
|
242
|
+
starter: string;
|
|
243
|
+
/** The starter key the same deck ships under now. */
|
|
244
|
+
replacedBy: string;
|
|
245
|
+
/** The title the deck had when it was seeded under the old key. */
|
|
246
|
+
title: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function sameCode(a: readonly Slide[], b: readonly Slide[]): boolean {
|
|
250
|
+
return (
|
|
251
|
+
a.length === b.length &&
|
|
252
|
+
a.every((slide, i) => {
|
|
253
|
+
const other = b[i]!;
|
|
254
|
+
return slide.html === other.html && slide.css === other.css && slide.js === other.js;
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Returns the stored copies of retired starters that nobody has edited.
|
|
261
|
+
*
|
|
262
|
+
* A copy counts as untouched when it still has the old title and the same
|
|
263
|
+
* slide code as the deck that replaced it. An edited copy is the user's
|
|
264
|
+
* content now, so it stays.
|
|
265
|
+
*/
|
|
266
|
+
export function staleStarters(
|
|
267
|
+
stored: readonly StoredDeck[],
|
|
268
|
+
retired: readonly RetiredStarter[],
|
|
269
|
+
starters: readonly StoredDeck[],
|
|
270
|
+
): StoredDeck[] {
|
|
271
|
+
return stored.filter((deck) => {
|
|
272
|
+
const retirement = retired.find((entry) => entry.starter === deck.starter);
|
|
273
|
+
if (!retirement || deck.title !== retirement.title) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
const replacement = starters.find((starter) => starter.starter === retirement.replacedBy);
|
|
277
|
+
return replacement !== undefined && sameCode(deck.slides, replacement.slides);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Formats an ISO timestamp as "today", "3 days ago", and so on. */
|
|
282
|
+
export function relativeTime(iso: string, now = new Date()): string {
|
|
283
|
+
const then = new Date(iso).getTime();
|
|
284
|
+
if (Number.isNaN(then)) {
|
|
285
|
+
return 'unknown';
|
|
286
|
+
}
|
|
287
|
+
const days = Math.floor((now.getTime() - then) / 86400000);
|
|
288
|
+
if (days <= 0) {
|
|
289
|
+
return 'today';
|
|
290
|
+
}
|
|
291
|
+
if (days === 1) {
|
|
292
|
+
return 'yesterday';
|
|
293
|
+
}
|
|
294
|
+
if (days < 30) {
|
|
295
|
+
return `${days} days ago`;
|
|
296
|
+
}
|
|
297
|
+
const months = Math.round(days / 30);
|
|
298
|
+
return months === 1 ? 'a month ago' : `${months} months ago`;
|
|
299
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser store that holds the user's decks.
|
|
3
|
+
*
|
|
4
|
+
* Decks live in IndexedDB, one entry per deck, so saving one deck never
|
|
5
|
+
* rewrites the others. The bundled files in `decks/` and `presets/` only seed
|
|
6
|
+
* the store on the first run.
|
|
7
|
+
*/
|
|
8
|
+
import {del, entries, get, set} from 'idb-keyval';
|
|
9
|
+
|
|
10
|
+
import type {Slide, StoredDeck} from './deck-model';
|
|
11
|
+
import {
|
|
12
|
+
copyOfDeck,
|
|
13
|
+
makeDeck,
|
|
14
|
+
missingStarters,
|
|
15
|
+
normalizeImport,
|
|
16
|
+
staleStarters,
|
|
17
|
+
toDeckFile,
|
|
18
|
+
} from './deck-model';
|
|
19
|
+
import {RETIRED_STARTERS, STARTER_DECKS} from './starters';
|
|
20
|
+
|
|
21
|
+
export type {Slide, StoredDeck};
|
|
22
|
+
|
|
23
|
+
/** The prefix every deck key carries inside the store. */
|
|
24
|
+
const KEY_PREFIX = 'nysds-playground:deck:';
|
|
25
|
+
|
|
26
|
+
function keyFor(id: string): string {
|
|
27
|
+
return `${KEY_PREFIX}${id}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isDeck(value: unknown): value is StoredDeck {
|
|
31
|
+
if (typeof value !== 'object' || value === null) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const deck = value as Partial<StoredDeck>;
|
|
35
|
+
// An empty id belongs to the scratch pad, which is never a stored deck.
|
|
36
|
+
return typeof deck.id === 'string' && deck.id !== '' && Array.isArray(deck.slides);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every deck in the store, newest change first. */
|
|
40
|
+
export async function listDecks(): Promise<StoredDeck[]> {
|
|
41
|
+
const all = await entries();
|
|
42
|
+
const decks: StoredDeck[] = [];
|
|
43
|
+
for (const [key, value] of all) {
|
|
44
|
+
if (typeof key === 'string' && key.startsWith(KEY_PREFIX) && isDeck(value)) {
|
|
45
|
+
decks.push(value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
decks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
49
|
+
return decks;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One deck, or `undefined` when the id is unknown. */
|
|
53
|
+
export async function getDeck(id: string): Promise<StoredDeck | undefined> {
|
|
54
|
+
const value = await get(keyFor(id));
|
|
55
|
+
return isDeck(value) ? value : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Writes a deck and stamps `updatedAt`. The scratch pad is never written. */
|
|
59
|
+
export async function saveDeck(deck: StoredDeck): Promise<StoredDeck> {
|
|
60
|
+
const saved: StoredDeck = {...deck, updatedAt: new Date().toISOString()};
|
|
61
|
+
if (saved.id === '') {
|
|
62
|
+
return saved;
|
|
63
|
+
}
|
|
64
|
+
await set(keyFor(saved.id), saved);
|
|
65
|
+
return saved;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Removes a deck. */
|
|
69
|
+
export async function deleteDeck(id: string): Promise<void> {
|
|
70
|
+
await del(keyFor(id));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Returns every id currently in use. */
|
|
74
|
+
async function takenIds(): Promise<string[]> {
|
|
75
|
+
return (await listDecks()).map((deck) => deck.id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Creates a deck with one blank slide and returns it. */
|
|
79
|
+
export async function createDeck(title: string): Promise<StoredDeck> {
|
|
80
|
+
const deck = makeDeck(title, await takenIds());
|
|
81
|
+
await set(keyFor(deck.id), deck);
|
|
82
|
+
return deck;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Copies a deck under a new id. */
|
|
86
|
+
export async function duplicateDeck(id: string): Promise<StoredDeck | undefined> {
|
|
87
|
+
const deck = await getDeck(id);
|
|
88
|
+
if (!deck) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const copy = copyOfDeck(deck, await takenIds());
|
|
92
|
+
await set(keyFor(copy.id), copy);
|
|
93
|
+
return copy;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The result of an import attempt. */
|
|
97
|
+
export type ImportOutcome =
|
|
98
|
+
| {ok: true; deck: StoredDeck}
|
|
99
|
+
| {ok: false; message: string};
|
|
100
|
+
|
|
101
|
+
/** Adds a deck from JSON text. */
|
|
102
|
+
export async function importDeck(json: string, fallbackName?: string): Promise<ImportOutcome> {
|
|
103
|
+
let raw: unknown;
|
|
104
|
+
try {
|
|
105
|
+
raw = JSON.parse(json);
|
|
106
|
+
} catch {
|
|
107
|
+
return {ok: false, message: 'That file is not valid JSON.'};
|
|
108
|
+
}
|
|
109
|
+
const result = normalizeImport(raw, await takenIds(), fallbackName);
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
await set(keyFor(result.deck.id), result.deck);
|
|
114
|
+
return {ok: true, deck: result.deck};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Returns a deck as the JSON text a deck file holds. */
|
|
118
|
+
export async function exportDeck(id: string): Promise<string | undefined> {
|
|
119
|
+
const deck = await getDeck(id);
|
|
120
|
+
return deck ? `${JSON.stringify(toDeckFile(deck), null, 2)}\n` : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Adds any bundled deck the store has not seen.
|
|
125
|
+
*
|
|
126
|
+
* First drops untouched copies of starters that now ship under a new name, so
|
|
127
|
+
* a rename in `decks/` does not leave the old deck beside the new one.
|
|
128
|
+
* Returns how many decks it added, so the home page can say so.
|
|
129
|
+
*/
|
|
130
|
+
export async function seedStarters(): Promise<number> {
|
|
131
|
+
const stale = staleStarters(await listDecks(), RETIRED_STARTERS, STARTER_DECKS);
|
|
132
|
+
for (const deck of stale) {
|
|
133
|
+
await del(keyFor(deck.id));
|
|
134
|
+
}
|
|
135
|
+
const stored = await listDecks();
|
|
136
|
+
const additions = missingStarters(STARTER_DECKS, stored);
|
|
137
|
+
// Starters carry a fixed timestamp so seeding is deterministic. Stamp them
|
|
138
|
+
// as they land so the home page reports when they arrived here.
|
|
139
|
+
const stamp = new Date().toISOString();
|
|
140
|
+
for (const deck of additions) {
|
|
141
|
+
await set(keyFor(deck.id), {...deck, createdAt: stamp, updatedAt: stamp});
|
|
142
|
+
}
|
|
143
|
+
return additions.length;
|
|
144
|
+
}
|