@3sln/deck 0.0.5
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 +21 -0
- package/README.md +50 -0
- package/bin/build.js +123 -0
- package/package.json +51 -0
- package/src/config.js +96 -0
- package/src/db.js +302 -0
- package/src/deck-demo.js +859 -0
- package/src/fetcher.js +35 -0
- package/src/highlight.js +45 -0
- package/src/main.js +326 -0
- package/src/state.js +227 -0
- package/src/sw.js +55 -0
- package/vite-plugin.js +147 -0
package/src/fetcher.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Provider } from '@3sln/ngin';
|
|
2
|
+
|
|
3
|
+
export class ThrottledFetcher {
|
|
4
|
+
#queue = [];
|
|
5
|
+
#activeRequests = 0;
|
|
6
|
+
#concurrency;
|
|
7
|
+
|
|
8
|
+
constructor(concurrency = 6) {
|
|
9
|
+
this.#concurrency = concurrency;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fetch(url, options) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
this.#queue.push({ url, options, resolve, reject });
|
|
15
|
+
this.#processQueue();
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#processQueue() {
|
|
20
|
+
if (this.#activeRequests >= this.#concurrency || this.#queue.length === 0) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
this.#activeRequests++;
|
|
25
|
+
const { url, options, resolve, reject } = this.#queue.shift();
|
|
26
|
+
|
|
27
|
+
fetch(url, options)
|
|
28
|
+
.then(response => resolve(response))
|
|
29
|
+
.catch(error => reject(error))
|
|
30
|
+
.finally(() => {
|
|
31
|
+
this.#activeRequests--;
|
|
32
|
+
this.#processQueue();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/highlight.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import hljs from 'highlight.js/lib/core';
|
|
2
|
+
import javascript from 'highlight.js/lib/languages/javascript';
|
|
3
|
+
import xml from 'highlight.js/lib/languages/xml';
|
|
4
|
+
import css from 'highlight.js/lib/languages/css';
|
|
5
|
+
import githubStyle from 'highlight.js/styles/github.css?inline';
|
|
6
|
+
import githubDarkStyle from 'highlight.js/styles/github-dark.css?inline';
|
|
7
|
+
import {css as createSheet} from '@3sln/bones/style';
|
|
8
|
+
|
|
9
|
+
hljs.registerLanguage('javascript', javascript);
|
|
10
|
+
hljs.registerLanguage('xml', xml); // For HTML
|
|
11
|
+
hljs.registerLanguage('css', css);
|
|
12
|
+
|
|
13
|
+
export const stylesheet = createSheet`
|
|
14
|
+
/* Light Theme */
|
|
15
|
+
${githubStyle}
|
|
16
|
+
|
|
17
|
+
/* Dark Theme */
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
${githubDarkStyle}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.hljs {
|
|
23
|
+
background: transparent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pre {
|
|
27
|
+
border: 1px solid var(--border-color);
|
|
28
|
+
border-radius: 8px;
|
|
29
|
+
padding: 1em;
|
|
30
|
+
overflow-x: auto;
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
export function highlight(element) {
|
|
35
|
+
// Case 1: The element itself is a <code> block that needs highlighting.
|
|
36
|
+
if (element.matches('pre > code')) {
|
|
37
|
+
hljs.highlightElement(element);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Case 2: The element is a container for <pre><code> blocks.
|
|
41
|
+
const blocks = element.querySelectorAll('pre > code');
|
|
42
|
+
blocks.forEach(block => {
|
|
43
|
+
hljs.highlightElement(block);
|
|
44
|
+
});
|
|
45
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import * as dodo from '@3sln/dodo';
|
|
2
|
+
import reactiveFactory from '@3sln/bones/reactive';
|
|
3
|
+
import resizeFactory from '@3sln/bones/resize';
|
|
4
|
+
import {Engine, Provider} from '@3sln/ngin';
|
|
5
|
+
import * as db from './db.js';
|
|
6
|
+
import { ThrottledFetcher } from './fetcher.js';
|
|
7
|
+
import {highlight, stylesheet as highlightStylesheet} from './highlight.js';
|
|
8
|
+
import {
|
|
9
|
+
uiStateProvider,
|
|
10
|
+
FilteredCards,
|
|
11
|
+
SelectedCard,
|
|
12
|
+
SetSearchQuery,
|
|
13
|
+
SelectCard,
|
|
14
|
+
ClearSelection,
|
|
15
|
+
LoadCard,
|
|
16
|
+
RemoveCard,
|
|
17
|
+
PruneCards,
|
|
18
|
+
SetPinnedCards,
|
|
19
|
+
} from './state.js';
|
|
20
|
+
import './deck-demo.js';
|
|
21
|
+
|
|
22
|
+
// Initialize dodo and bones components
|
|
23
|
+
const {reconcile, h, div, h1, h2, input, p, button, article, header, section, alias, span} = dodo;
|
|
24
|
+
const {watch, zip, map, dedup, pipe} = reactiveFactory({dodo});
|
|
25
|
+
const {withContainerSize} = resizeFactory({dodo});
|
|
26
|
+
|
|
27
|
+
// --- UI Components ---
|
|
28
|
+
|
|
29
|
+
const closeIcon = () =>
|
|
30
|
+
h(
|
|
31
|
+
'svg',
|
|
32
|
+
{
|
|
33
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
34
|
+
height: '24px',
|
|
35
|
+
viewBox: '0 0 24 24',
|
|
36
|
+
width: '24px',
|
|
37
|
+
fill: 'var(--text-color)',
|
|
38
|
+
},
|
|
39
|
+
h('path', {d: 'M0 0h24v24H0z', fill: 'none'}),
|
|
40
|
+
h('path', {
|
|
41
|
+
d: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z',
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const searchBar = alias(engine => {
|
|
46
|
+
return div(
|
|
47
|
+
{className: 'search-bar'},
|
|
48
|
+
input({
|
|
49
|
+
type: 'search',
|
|
50
|
+
placeholder: 'Search cards...',
|
|
51
|
+
$styling: {
|
|
52
|
+
width: '100%',
|
|
53
|
+
padding: '0.75em 1em',
|
|
54
|
+
'font-size': '1.1em',
|
|
55
|
+
border: '1px solid var(--input-border)',
|
|
56
|
+
'background-color': 'var(--input-bg)',
|
|
57
|
+
color: 'var(--text-color)',
|
|
58
|
+
'border-radius': '2em',
|
|
59
|
+
outline: 'none',
|
|
60
|
+
transition: 'box-shadow 0.2s',
|
|
61
|
+
},
|
|
62
|
+
}).on({
|
|
63
|
+
focus: e => (e.target.style.boxShadow = '0 0 5px rgba(81, 203, 238, 1)'),
|
|
64
|
+
blur: e => (e.target.style.boxShadow = 'none'),
|
|
65
|
+
input: e => engine.dispatch(new SetSearchQuery(e.target.value)),
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const cardListItem = alias((card, engine) => {
|
|
71
|
+
return div(
|
|
72
|
+
{
|
|
73
|
+
className: 'card-list-item',
|
|
74
|
+
$styling: {
|
|
75
|
+
padding: '1em',
|
|
76
|
+
border: '1px solid var(--border-color)',
|
|
77
|
+
'background-color': 'var(--card-bg)',
|
|
78
|
+
'border-radius': '8px',
|
|
79
|
+
cursor: 'pointer',
|
|
80
|
+
transition: 'background-color 0.2s',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
h2({$styling: {margin: '0 0 0.25em', 'font-size': '1.1em'}}, card.title),
|
|
84
|
+
p({$styling: {margin: 0, color: '#666', 'font-size': '0.9em'}}, card.summary),
|
|
85
|
+
).on({
|
|
86
|
+
click: () => engine.dispatch(new SelectCard(card.path)),
|
|
87
|
+
mouseover: e => (e.currentTarget.style.backgroundColor = 'var(--card-hover-bg)'),
|
|
88
|
+
mouseout: e => (e.currentTarget.style.backgroundColor = 'var(--card-bg)'),
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const cardList = alias(({search, recents, pinned} = {}, engine) => {
|
|
93
|
+
const heading = title =>
|
|
94
|
+
h2(
|
|
95
|
+
{
|
|
96
|
+
$styling: {
|
|
97
|
+
color: 'var(--text-color)',
|
|
98
|
+
opacity: 0.6,
|
|
99
|
+
'font-size': '0.8em',
|
|
100
|
+
'margin-top': '2em',
|
|
101
|
+
'margin-bottom': '1em',
|
|
102
|
+
'text-transform': 'uppercase',
|
|
103
|
+
'letter-spacing': '0.1em',
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
title,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return div(
|
|
110
|
+
{
|
|
111
|
+
className: 'card-list',
|
|
112
|
+
$styling: {
|
|
113
|
+
'overflow-y': 'auto',
|
|
114
|
+
display: 'flex',
|
|
115
|
+
'flex-direction': 'column',
|
|
116
|
+
gap: '1em',
|
|
117
|
+
padding: '1em',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
(search || []).length > 0 && search.map(card => cardListItem(card, engine)),
|
|
121
|
+
(pinned || []).length > 0 && [
|
|
122
|
+
heading('Pinned'),
|
|
123
|
+
...pinned.map(card => cardListItem(card, engine)),
|
|
124
|
+
],
|
|
125
|
+
(recents || []).length > 0 && [
|
|
126
|
+
heading('Recents'),
|
|
127
|
+
...recents.map(card => cardListItem(card, engine)),
|
|
128
|
+
],
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const detailView = alias((card, engine) => {
|
|
133
|
+
const closeButton = button(
|
|
134
|
+
{
|
|
135
|
+
$styling: {
|
|
136
|
+
background: 'none',
|
|
137
|
+
border: 'none',
|
|
138
|
+
cursor: 'pointer',
|
|
139
|
+
padding: '0.5em',
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
closeIcon(),
|
|
143
|
+
).on({click: () => engine.dispatch(new ClearSelection())});
|
|
144
|
+
|
|
145
|
+
if (card.title !== card.path) {
|
|
146
|
+
return article(
|
|
147
|
+
{
|
|
148
|
+
className: 'detail-view',
|
|
149
|
+
$styling: {
|
|
150
|
+
padding: '0 1em 1em 1em',
|
|
151
|
+
'overflow-y': 'auto',
|
|
152
|
+
width: '100%',
|
|
153
|
+
'max-width': '1200px',
|
|
154
|
+
'box-sizing': 'border-box',
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
h1(
|
|
158
|
+
{
|
|
159
|
+
$styling: {
|
|
160
|
+
display: 'flex',
|
|
161
|
+
'align-items': 'center',
|
|
162
|
+
'justify-content': 'space-between',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
span({$styling: {'flex-grow': 1}}, card.title),
|
|
166
|
+
closeButton,
|
|
167
|
+
),
|
|
168
|
+
section({innerHTML: card.body})
|
|
169
|
+
.opaque()
|
|
170
|
+
.on({
|
|
171
|
+
$update: el => highlight(el),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
} else {
|
|
175
|
+
return article(
|
|
176
|
+
{
|
|
177
|
+
className: 'detail-view',
|
|
178
|
+
$styling: {
|
|
179
|
+
padding: '1em',
|
|
180
|
+
'overflow-y': 'auto',
|
|
181
|
+
position: 'relative',
|
|
182
|
+
width: '100%',
|
|
183
|
+
'max-width': '1200px',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
header(
|
|
187
|
+
{
|
|
188
|
+
$styling: {
|
|
189
|
+
display: 'flex',
|
|
190
|
+
'justify-content': 'flex-end',
|
|
191
|
+
position: 'sticky',
|
|
192
|
+
top: 0,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
closeButton,
|
|
196
|
+
),
|
|
197
|
+
section({innerHTML: card.body})
|
|
198
|
+
.opaque()
|
|
199
|
+
.on({
|
|
200
|
+
$update: el => highlight(el),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const app = alias(engine => {
|
|
207
|
+
const state$ = zip(
|
|
208
|
+
(selectedCard, filteredCards) => ({selectedCard, filteredCards}),
|
|
209
|
+
engine.query(new SelectedCard()),
|
|
210
|
+
engine.query(new FilteredCards()),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return withContainerSize(size$ => {
|
|
214
|
+
const isWide$ = pipe(
|
|
215
|
+
size$,
|
|
216
|
+
map(size => size && size.width > 768),
|
|
217
|
+
dedup(),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return watch(isWide$, isWide =>
|
|
221
|
+
watch(
|
|
222
|
+
state$,
|
|
223
|
+
({selectedCard, filteredCards}) => {
|
|
224
|
+
const hasSelection = selectedCard != null;
|
|
225
|
+
|
|
226
|
+
const listView = div(
|
|
227
|
+
{
|
|
228
|
+
className: 'list-view',
|
|
229
|
+
$styling: {
|
|
230
|
+
display: 'flex',
|
|
231
|
+
'flex-direction': 'column',
|
|
232
|
+
flex: hasSelection && isWide ? '1 1 350px' : '0 0 clamp(400px, 60%, 700px)',
|
|
233
|
+
'max-width': hasSelection && isWide ? '500px' : '700px',
|
|
234
|
+
transition: 'flex 0.3s ease-in-out',
|
|
235
|
+
'padding-top': '1rem',
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
searchBar(engine),
|
|
239
|
+
cardList(filteredCards || [], engine),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (hasSelection) {
|
|
243
|
+
if (isWide) {
|
|
244
|
+
return div(
|
|
245
|
+
{
|
|
246
|
+
$styling: {
|
|
247
|
+
display: 'flex',
|
|
248
|
+
height: '100vh',
|
|
249
|
+
width: '100vw',
|
|
250
|
+
'align-items': 'stretch',
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
listView,
|
|
254
|
+
div(
|
|
255
|
+
{
|
|
256
|
+
$styling: {
|
|
257
|
+
flex: '2 1 50%',
|
|
258
|
+
display: 'flex',
|
|
259
|
+
'justify-content': 'center',
|
|
260
|
+
'overflow-x': 'auto',
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
detailView(selectedCard, engine),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
} else {
|
|
267
|
+
return detailView(selectedCard, engine);
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
return div(
|
|
271
|
+
{$styling: {display: 'flex', 'justify-content': 'center', padding: '2em 0'}},
|
|
272
|
+
listView,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
placeholder: () => p('Indexing cards...'),
|
|
278
|
+
},
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// --- Initial Render ---
|
|
285
|
+
|
|
286
|
+
export async function renderDeck({target, initialCardsData, pinnedCardPaths}) {
|
|
287
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, highlightStylesheet];
|
|
288
|
+
await db.initDB();
|
|
289
|
+
|
|
290
|
+
if ('serviceWorker' in navigator && !import.meta.hot) {
|
|
291
|
+
window.addEventListener('load', () => {
|
|
292
|
+
navigator.serviceWorker.register('/sw.js').then(registration => {
|
|
293
|
+
console.log('SW registered: ', registration);
|
|
294
|
+
}).catch(registrationError => {
|
|
295
|
+
console.log('SW registration failed: ', registrationError);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const engine = new Engine({
|
|
301
|
+
providers: {
|
|
302
|
+
state: uiStateProvider(),
|
|
303
|
+
fetcher: Provider.fromSingleton(new ThrottledFetcher()),
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Initial load
|
|
308
|
+
engine.dispatch(new SetPinnedCards(pinnedCardPaths || []));
|
|
309
|
+
initialCardsData.forEach(cardData => {
|
|
310
|
+
engine.dispatch(new LoadCard(cardData));
|
|
311
|
+
});
|
|
312
|
+
engine.dispatch(new PruneCards(initialCardsData.map(c => c.path)));
|
|
313
|
+
|
|
314
|
+
// Handle HMR
|
|
315
|
+
if (import.meta.hot) {
|
|
316
|
+
import.meta.hot.on('deck:card-changed', ({path}) => {
|
|
317
|
+
// In HMR, we don't have the hash, so we always load.
|
|
318
|
+
engine.dispatch(new LoadCard({ path }));
|
|
319
|
+
});
|
|
320
|
+
import.meta.hot.on('deck:card-removed', ({path}) => {
|
|
321
|
+
engine.dispatch(new RemoveCard(path));
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
reconcile(target, [app(engine)]);
|
|
326
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {Provider, Query, Action} from '@3sln/ngin';
|
|
2
|
+
import reactiveFactory from '@3sln/bones/reactive';
|
|
3
|
+
import * as dodo from '@3sln/dodo';
|
|
4
|
+
import {marked} from 'marked';
|
|
5
|
+
import * as db from './db.js';
|
|
6
|
+
|
|
7
|
+
const {ObservableSubject} = reactiveFactory({dodo});
|
|
8
|
+
|
|
9
|
+
// --- Data Transformation ---
|
|
10
|
+
|
|
11
|
+
function transformCard(path, markdown) {
|
|
12
|
+
const html = marked(markdown);
|
|
13
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
14
|
+
const h1El = doc.querySelector('h1');
|
|
15
|
+
const title = h1El?.textContent || path;
|
|
16
|
+
const summary = doc.querySelector('p')?.textContent || '';
|
|
17
|
+
|
|
18
|
+
h1El?.remove();
|
|
19
|
+
const body = doc.body.innerHTML;
|
|
20
|
+
|
|
21
|
+
return {path, title, summary, body};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- UI State Store ---
|
|
25
|
+
|
|
26
|
+
class UIState {
|
|
27
|
+
#subject;
|
|
28
|
+
|
|
29
|
+
constructor(initialState) {
|
|
30
|
+
this.#subject = new ObservableSubject(initialState);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get state$() {
|
|
34
|
+
return this.#subject;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
update(updater, ...args) {
|
|
38
|
+
const currentState = this.#subject.value;
|
|
39
|
+
const newState = updater(currentState, ...args);
|
|
40
|
+
this.#subject.next(newState);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Ngin Components ---
|
|
45
|
+
|
|
46
|
+
export const uiStateProvider = () => {
|
|
47
|
+
const uiState = new UIState({
|
|
48
|
+
query: '',
|
|
49
|
+
selectedCardPath: null,
|
|
50
|
+
pinnedCardPaths: [],
|
|
51
|
+
});
|
|
52
|
+
return Provider.fromSingleton(uiState);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// --- Queries ---
|
|
56
|
+
|
|
57
|
+
export class SearchQuery extends Query {
|
|
58
|
+
static deps = ['state'];
|
|
59
|
+
boot({state}, {notify}) {
|
|
60
|
+
state.state$.subscribe(s => notify(s.query));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class FilteredCards extends Query {
|
|
65
|
+
static deps = ['state'];
|
|
66
|
+
boot({state}, {notify, engineFeed}) {
|
|
67
|
+
let currentQuery = null;
|
|
68
|
+
let pinnedPaths = [];
|
|
69
|
+
|
|
70
|
+
const reQuery = async () => {
|
|
71
|
+
if (currentQuery) {
|
|
72
|
+
const cards = await db.findCardsByQuery(currentQuery);
|
|
73
|
+
notify({search: cards, recents: [], pinned: []});
|
|
74
|
+
} else {
|
|
75
|
+
const pinned = await Promise.all(pinnedPaths.map(p => db.getCard(p)));
|
|
76
|
+
const recent = await db.getRecentCards(100);
|
|
77
|
+
const pinnedPathsSet = new Set(pinnedPaths);
|
|
78
|
+
const filteredRecent = recent.filter(r => !pinnedPathsSet.has(r.path));
|
|
79
|
+
notify({search: [], recents: filteredRecent, pinned: pinned.filter(Boolean)});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
engineFeed.addEventListener('card-loaded', reQuery);
|
|
84
|
+
engineFeed.addEventListener('card-removed', reQuery);
|
|
85
|
+
engineFeed.addEventListener('cards-pruned', reQuery);
|
|
86
|
+
|
|
87
|
+
state.state$.subscribe(s => {
|
|
88
|
+
let queryChanged = false;
|
|
89
|
+
if (s.query !== currentQuery) {
|
|
90
|
+
currentQuery = s.query;
|
|
91
|
+
queryChanged = true;
|
|
92
|
+
}
|
|
93
|
+
if (s.pinnedCardPaths !== pinnedPaths) {
|
|
94
|
+
pinnedPaths = s.pinnedCardPaths;
|
|
95
|
+
queryChanged = true;
|
|
96
|
+
}
|
|
97
|
+
if (queryChanged) {
|
|
98
|
+
reQuery();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
reQuery(); // Initial query
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class SelectedCard extends Query {
|
|
107
|
+
static deps = ['state'];
|
|
108
|
+
boot({state}, {notify, engineFeed}) {
|
|
109
|
+
let currentPath = null;
|
|
110
|
+
|
|
111
|
+
// React to selection changes from UI
|
|
112
|
+
state.state$.subscribe(async s => {
|
|
113
|
+
currentPath = s.selectedCardPath;
|
|
114
|
+
if (!currentPath) {
|
|
115
|
+
notify(null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const card = await db.getCard(currentPath);
|
|
119
|
+
notify(card);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// React to HMR updates for the currently selected card
|
|
123
|
+
engineFeed.addEventListener('card-loaded', event => {
|
|
124
|
+
if (event.detail.card.path === currentPath) {
|
|
125
|
+
notify(event.detail.card);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Actions ---
|
|
132
|
+
|
|
133
|
+
export class SetPinnedCards extends Action {
|
|
134
|
+
static deps = ['state'];
|
|
135
|
+
constructor(paths) {
|
|
136
|
+
super();
|
|
137
|
+
this.paths = paths;
|
|
138
|
+
}
|
|
139
|
+
execute({state}) {
|
|
140
|
+
state.update(s => ({...s, pinnedCardPaths: this.paths}));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class LoadCard extends Action {
|
|
145
|
+
static deps = ['fetcher'];
|
|
146
|
+
constructor(cardData) {
|
|
147
|
+
super();
|
|
148
|
+
this.cardData = cardData;
|
|
149
|
+
}
|
|
150
|
+
async execute({fetcher}, {engineFeed}) {
|
|
151
|
+
const { path, hash } = this.cardData;
|
|
152
|
+
|
|
153
|
+
// HMR passes no hash, always load.
|
|
154
|
+
if (hash) {
|
|
155
|
+
const existing = await db.getCard(path);
|
|
156
|
+
if (existing && existing.hash === hash) {
|
|
157
|
+
return; // Content is up to date
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const url = new URL(path, location.href);
|
|
163
|
+
const res = await fetcher.fetch(url);
|
|
164
|
+
if (!res.ok) throw new Error(`Failed to fetch ${path}: ${res.statusText}`);
|
|
165
|
+
const markdown = await res.text();
|
|
166
|
+
const card = transformCard(path, markdown);
|
|
167
|
+
const newCard = await db.upsertCard({ ...card, hash });
|
|
168
|
+
if (newCard) {
|
|
169
|
+
engineFeed.dispatchEvent(new CustomEvent('card-loaded', {detail: {card: newCard}}));
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`Failed to load card ${path}:`, err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class RemoveCard extends Action {
|
|
178
|
+
constructor(path) {
|
|
179
|
+
super();
|
|
180
|
+
this.path = path;
|
|
181
|
+
}
|
|
182
|
+
async execute(_, {engineFeed}) {
|
|
183
|
+
await db.removeCard(this.path);
|
|
184
|
+
engineFeed.dispatchEvent(new CustomEvent('card-removed', {detail: {path: this.path}}));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export class SetSearchQuery extends Action {
|
|
189
|
+
static deps = ['state'];
|
|
190
|
+
constructor(query) {
|
|
191
|
+
super();
|
|
192
|
+
this.query = query;
|
|
193
|
+
}
|
|
194
|
+
execute({state}) {
|
|
195
|
+
state.update(s => ({...s, query: this.query}));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export class SelectCard extends Action {
|
|
200
|
+
static deps = ['state'];
|
|
201
|
+
constructor(cardPath) {
|
|
202
|
+
super();
|
|
203
|
+
this.cardPath = cardPath;
|
|
204
|
+
}
|
|
205
|
+
async execute({state}) {
|
|
206
|
+
await db.touchCard(this.cardPath);
|
|
207
|
+
state.update(s => ({...s, selectedCardPath: this.cardPath}));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export class ClearSelection extends Action {
|
|
212
|
+
static deps = ['state'];
|
|
213
|
+
execute({state}) {
|
|
214
|
+
state.update(s => ({...s, selectedCardPath: null}));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export class PruneCards extends Action {
|
|
219
|
+
constructor(livePaths) {
|
|
220
|
+
super();
|
|
221
|
+
this.livePaths = livePaths;
|
|
222
|
+
}
|
|
223
|
+
async execute(_, {engineFeed}) {
|
|
224
|
+
await db.pruneCards(this.livePaths);
|
|
225
|
+
engineFeed.dispatchEvent(new CustomEvent('cards-pruned'));
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/sw.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const CACHE_NAME = 'deck-cache-v1';
|
|
2
|
+
|
|
3
|
+
self.addEventListener('install', (event) => {
|
|
4
|
+
event.waitUntil((async () => {
|
|
5
|
+
const cache = await caches.open(CACHE_NAME);
|
|
6
|
+
try {
|
|
7
|
+
const assetManifest = await fetch('asset-manifest.json').then(res => res.json());
|
|
8
|
+
const assets = assetManifest.files;
|
|
9
|
+
assets.push('./');
|
|
10
|
+
await cache.addAll(assets);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
console.error('Failed to fetch asset-manifest.json, offline mode will not be available.', e);
|
|
13
|
+
}
|
|
14
|
+
})());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
self.addEventListener('activate', (event) => {
|
|
18
|
+
event.waitUntil(caches.keys().then((keyList) => {
|
|
19
|
+
return Promise.all(keyList.map((key) => {
|
|
20
|
+
if (key !== CACHE_NAME) {
|
|
21
|
+
return caches.delete(key);
|
|
22
|
+
}
|
|
23
|
+
}));
|
|
24
|
+
}));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
self.addEventListener('fetch', (event) => {
|
|
28
|
+
event.respondWith(
|
|
29
|
+
(async () => {
|
|
30
|
+
const cache = await caches.open(CACHE_NAME);
|
|
31
|
+
const cachedResponse = await cache.match(event.request);
|
|
32
|
+
|
|
33
|
+
const networkPromise = fetch(event.request).then(networkResponse => {
|
|
34
|
+
if (networkResponse.ok) {
|
|
35
|
+
cache.put(event.request, networkResponse.clone());
|
|
36
|
+
}
|
|
37
|
+
return networkResponse;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), 400));
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const firstResponse = await Promise.race([networkPromise, timeoutPromise]);
|
|
44
|
+
if (firstResponse) {
|
|
45
|
+
return firstResponse; // Network was fast enough
|
|
46
|
+
}
|
|
47
|
+
} catch(e) {
|
|
48
|
+
// networkPromise rejected before timeout, fall through to cache
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// If network is slow or failed, return from cache if available, otherwise wait for network.
|
|
52
|
+
return cachedResponse || networkPromise;
|
|
53
|
+
})()
|
|
54
|
+
);
|
|
55
|
+
});
|