@dirwell/theme-ledger 0.0.0-stage → 0.0.1-alpha
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 +16 -2
- package/dist/THIRD_PARTY_NOTICES.txt +30 -0
- package/dist/client.js +82 -0
- package/dist/component.css +0 -0
- package/dist/index.d.mts +10 -0
- package/dist/index.mjs +601 -0
- package/dist/index.mjs.map +1 -0
- package/dist/model-K7UzS9YB.d.mts +46 -0
- package/dist/model.d.mts +2 -0
- package/dist/model.mjs +1 -0
- package/dist/source/Ledger.svelte +639 -0
- package/dist/source/component.d.mts +4 -0
- package/dist/source/shared/icons.mjs +12 -0
- package/dist/source/shared/navigation.mjs +294 -0
- package/dist/source/shared/records.mjs +60 -0
- package/dist/styles.css +815 -0
- package/package.json +47 -3
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount, tick, untrack } from "svelte";
|
|
3
|
+
import { entryIcon } from "./shared/icons.mjs";
|
|
4
|
+
import { loadListing } from "@dirwell/core/client";
|
|
5
|
+
import {
|
|
6
|
+
fetchThemePage,
|
|
7
|
+
applyThemeMetadata,
|
|
8
|
+
restoreThemePage,
|
|
9
|
+
} from "./shared/navigation.mjs";
|
|
10
|
+
import {
|
|
11
|
+
visibleRecords,
|
|
12
|
+
directoryLike,
|
|
13
|
+
entryType,
|
|
14
|
+
size,
|
|
15
|
+
modified,
|
|
16
|
+
fuzzy,
|
|
17
|
+
} from "./shared/records.mjs";
|
|
18
|
+
let { model: initialModel, header, row, footer } = $props();
|
|
19
|
+
const snapshot = untrack(() =>
|
|
20
|
+
restoreThemePage(initialModel, Boolean(import.meta.hot)),
|
|
21
|
+
);
|
|
22
|
+
let model = $state.raw(snapshot),
|
|
23
|
+
entries = $state.raw(snapshot.entries),
|
|
24
|
+
query = $state(""),
|
|
25
|
+
types = $state(["directory", "file", "link"]),
|
|
26
|
+
sort = $state.raw(snapshot.sort),
|
|
27
|
+
scheme = $state("system"),
|
|
28
|
+
selected = $state(null),
|
|
29
|
+
ready = $state(false),
|
|
30
|
+
busy = $state(false),
|
|
31
|
+
error = $state(""),
|
|
32
|
+
timestamp = $state(null),
|
|
33
|
+
composing = $state(false),
|
|
34
|
+
scrollTop = $state(0),
|
|
35
|
+
globalOpen = $state(false),
|
|
36
|
+
globalQuery = $state(""),
|
|
37
|
+
globalRows = $state.raw([]),
|
|
38
|
+
globalStatus = $state("Enter a search to begin."),
|
|
39
|
+
pendingUpdates = $state(null);
|
|
40
|
+
let heights = $state.raw(new Map());
|
|
41
|
+
let updatesHref = $derived(
|
|
42
|
+
ready && model.updatesHref
|
|
43
|
+
? new URL(model.updatesHref, document.baseURI).href
|
|
44
|
+
: null,
|
|
45
|
+
);
|
|
46
|
+
let complete = $derived(entries.length === model.total),
|
|
47
|
+
rows = $derived(
|
|
48
|
+
visibleRecords(entries, composing ? "" : query, types, sort),
|
|
49
|
+
),
|
|
50
|
+
virtual = $derived(ready && rows.length > 500);
|
|
51
|
+
let offsets = $derived.by(() => {
|
|
52
|
+
const values = [0];
|
|
53
|
+
for (const entry of rows)
|
|
54
|
+
values.push(values.at(-1) + (heights.get(entry.id) ?? 76));
|
|
55
|
+
return values;
|
|
56
|
+
});
|
|
57
|
+
let start = $derived.by(() => {
|
|
58
|
+
if (!virtual) return 0;
|
|
59
|
+
let low = 0,
|
|
60
|
+
high = rows.length;
|
|
61
|
+
while (low < high) {
|
|
62
|
+
const mid = (low + high) >> 1;
|
|
63
|
+
if (offsets[mid + 1] <= scrollTop) low = mid + 1;
|
|
64
|
+
else high = mid;
|
|
65
|
+
}
|
|
66
|
+
return Math.max(0, low - 5);
|
|
67
|
+
}),
|
|
68
|
+
end = $derived(virtual ? Math.min(rows.length, start + 35) : rows.length);
|
|
69
|
+
function measure(node, id) {
|
|
70
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
71
|
+
const observer = new ResizeObserver(() => {
|
|
72
|
+
const value = node.getBoundingClientRect().height;
|
|
73
|
+
if (value > 0 && heights.get(id) !== value) {
|
|
74
|
+
const next = new Map(heights);
|
|
75
|
+
next.set(id, value);
|
|
76
|
+
heights = next;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
observer.observe(node);
|
|
80
|
+
return { destroy: () => observer.disconnect() };
|
|
81
|
+
}
|
|
82
|
+
let list = $state(),
|
|
83
|
+
searchInput = $state(),
|
|
84
|
+
globalInput = $state(),
|
|
85
|
+
timeDialog = $state(),
|
|
86
|
+
globalDialog = $state();
|
|
87
|
+
let navigation = 0,
|
|
88
|
+
abort,
|
|
89
|
+
archiveAbort,
|
|
90
|
+
archiveRequest = 0;
|
|
91
|
+
const isDirectory = directoryLike;
|
|
92
|
+
function safeHref(record) {
|
|
93
|
+
if (
|
|
94
|
+
typeof location !== "undefined" &&
|
|
95
|
+
location.protocol === "file:" &&
|
|
96
|
+
isDirectory(record) &&
|
|
97
|
+
!record.exitsExplorer &&
|
|
98
|
+
record.href?.endsWith("/")
|
|
99
|
+
)
|
|
100
|
+
return record.href + "index.html";
|
|
101
|
+
return record.href;
|
|
102
|
+
}
|
|
103
|
+
async function hydrateData() {
|
|
104
|
+
if (complete || !model.manifestHref || location.protocol === "file:")
|
|
105
|
+
return;
|
|
106
|
+
const local = abort,
|
|
107
|
+
token = navigation;
|
|
108
|
+
busy = true;
|
|
109
|
+
try {
|
|
110
|
+
const loaded = await loadListing(
|
|
111
|
+
new URL(model.manifestHref, document.baseURI).href,
|
|
112
|
+
{
|
|
113
|
+
signal: local.signal,
|
|
114
|
+
cache: model.updatesHref ? "no-store" : "default",
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
if (token === navigation && !local.signal.aborted)
|
|
118
|
+
entries = loaded.entries;
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
if (token === navigation && !local.signal.aborted)
|
|
121
|
+
error =
|
|
122
|
+
"Could not load complete directory data. Native page links remain available.";
|
|
123
|
+
} finally {
|
|
124
|
+
if (token === navigation) busy = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function navigate(event, record) {
|
|
128
|
+
const dataHref = record.presentation?.folderDataHref;
|
|
129
|
+
if (
|
|
130
|
+
record.exitsExplorer ||
|
|
131
|
+
event?.altKey ||
|
|
132
|
+
event?.ctrlKey ||
|
|
133
|
+
event?.metaKey ||
|
|
134
|
+
event?.shiftKey ||
|
|
135
|
+
event?.button > 0 ||
|
|
136
|
+
typeof dataHref !== "string" ||
|
|
137
|
+
location.protocol === "file:"
|
|
138
|
+
)
|
|
139
|
+
return;
|
|
140
|
+
event?.preventDefault();
|
|
141
|
+
await openFolder(
|
|
142
|
+
dataHref,
|
|
143
|
+
new URL(record.href, document.baseURI).href,
|
|
144
|
+
true,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
async function openFolder(dataHref, url, push, refresh = false) {
|
|
148
|
+
const token = ++navigation;
|
|
149
|
+
abort?.abort();
|
|
150
|
+
const local = (abort = new AbortController());
|
|
151
|
+
busy = true;
|
|
152
|
+
error = "";
|
|
153
|
+
try {
|
|
154
|
+
const next = await fetchThemePage(
|
|
155
|
+
dataHref,
|
|
156
|
+
url,
|
|
157
|
+
"ledger",
|
|
158
|
+
local.signal,
|
|
159
|
+
refresh,
|
|
160
|
+
);
|
|
161
|
+
let nextEntries = next.entries;
|
|
162
|
+
if (refresh && next.manifestHref && nextEntries.length !== next.total) {
|
|
163
|
+
nextEntries = (
|
|
164
|
+
await loadListing(next.manifestHref, {
|
|
165
|
+
signal: local.signal,
|
|
166
|
+
cache: "no-store",
|
|
167
|
+
})
|
|
168
|
+
).entries;
|
|
169
|
+
}
|
|
170
|
+
if (token !== navigation || local.signal.aborted) return;
|
|
171
|
+
model = next;
|
|
172
|
+
entries = nextEntries;
|
|
173
|
+
if (!refresh) {
|
|
174
|
+
query = "";
|
|
175
|
+
selected = null;
|
|
176
|
+
scrollTop = 0;
|
|
177
|
+
}
|
|
178
|
+
if (push) history.pushState({ dataHref: next.dataHref }, "", url);
|
|
179
|
+
applyThemeMetadata(next, url);
|
|
180
|
+
await hydrateData();
|
|
181
|
+
if (!refresh) window.scrollTo({ top: 0 });
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
if (token === navigation && !local.signal.aborted)
|
|
184
|
+
error =
|
|
185
|
+
"Could not open this folder. Open its native link or try again.";
|
|
186
|
+
} finally {
|
|
187
|
+
if (token === navigation) busy = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function navigateLink(event, href, dataHref) {
|
|
191
|
+
if (
|
|
192
|
+
event?.altKey ||
|
|
193
|
+
event?.ctrlKey ||
|
|
194
|
+
event?.metaKey ||
|
|
195
|
+
event?.shiftKey ||
|
|
196
|
+
event?.button > 0 ||
|
|
197
|
+
location.protocol === "file:"
|
|
198
|
+
)
|
|
199
|
+
return;
|
|
200
|
+
event?.preventDefault();
|
|
201
|
+
openFolder(dataHref, new URL(href, document.baseURI).href, true);
|
|
202
|
+
}
|
|
203
|
+
async function move(index) {
|
|
204
|
+
const row = rows[Math.max(0, Math.min(rows.length - 1, index))];
|
|
205
|
+
if (!row) return;
|
|
206
|
+
selected = row.id;
|
|
207
|
+
if (virtual) {
|
|
208
|
+
scrollTop = Math.max(0, offsets[index] - 100);
|
|
209
|
+
if (list)
|
|
210
|
+
window.scrollTo({
|
|
211
|
+
top: list.getBoundingClientRect().top + window.scrollY + scrollTop,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
await tick();
|
|
215
|
+
list
|
|
216
|
+
?.querySelector(`[data-order="${index}"] a`)
|
|
217
|
+
?.focus({ preventScroll: true });
|
|
218
|
+
}
|
|
219
|
+
function keyboard(event) {
|
|
220
|
+
if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey)
|
|
221
|
+
return;
|
|
222
|
+
if (globalOpen) {
|
|
223
|
+
if (event.key === "Escape") {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
globalOpen = false;
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (timestamp) {
|
|
230
|
+
if (event.key === "Escape") timestamp = null;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const editable = /INPUT|TEXTAREA|SELECT/.test(event.target?.tagName ?? "");
|
|
234
|
+
if (event.key === "/" && !editable) {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
searchInput?.focus();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (editable) {
|
|
240
|
+
if (event.key === "Escape") {
|
|
241
|
+
query = "";
|
|
242
|
+
searchInput?.blur();
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (event.key === "Backspace" && model.parentHref) {
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
if (location.protocol === "file:") location.href = model.parentHref;
|
|
249
|
+
else
|
|
250
|
+
openFolder(
|
|
251
|
+
model.parentDataHref,
|
|
252
|
+
new URL(model.parentHref, document.baseURI).href,
|
|
253
|
+
true,
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const index = rows.findIndex((row) => row.id === selected);
|
|
258
|
+
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
move(
|
|
261
|
+
event.key === "Home"
|
|
262
|
+
? 0
|
|
263
|
+
: event.key === "End"
|
|
264
|
+
? rows.length - 1
|
|
265
|
+
: index + (event.key === "ArrowDown" ? 1 : -1),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function updateScheme(value) {
|
|
270
|
+
scheme = value;
|
|
271
|
+
try {
|
|
272
|
+
localStorage.setItem("dirwell-color-scheme", value);
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
async function searchArchive() {
|
|
276
|
+
const token = ++archiveRequest;
|
|
277
|
+
archiveAbort?.abort();
|
|
278
|
+
const local = (archiveAbort = new AbortController());
|
|
279
|
+
globalRows = [];
|
|
280
|
+
const query = globalQuery.trim();
|
|
281
|
+
if (!query) {
|
|
282
|
+
globalStatus = "Enter a search to begin.";
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
globalStatus = "Searching archive…";
|
|
286
|
+
try {
|
|
287
|
+
const url = new URL(model.searchIndexHref, document.baseURI);
|
|
288
|
+
const response = await fetch(url, { signal: local.signal });
|
|
289
|
+
if (!response.ok) throw new Error("Unavailable archive");
|
|
290
|
+
const index = await response.json();
|
|
291
|
+
if (
|
|
292
|
+
index.version !== 3 ||
|
|
293
|
+
!Array.isArray(index.shards) ||
|
|
294
|
+
!Number.isSafeInteger(index.count) ||
|
|
295
|
+
index.count > 100000
|
|
296
|
+
)
|
|
297
|
+
throw new Error("Unsupported archive");
|
|
298
|
+
const all = [];
|
|
299
|
+
for (const shard of index.shards) {
|
|
300
|
+
if (typeof shard !== "string") throw new Error("Invalid shard");
|
|
301
|
+
const shardUrl = new URL(shard, url);
|
|
302
|
+
if (shardUrl.origin !== url.origin) throw new Error("Unsafe shard");
|
|
303
|
+
const chunk = await fetch(shardUrl, { signal: local.signal });
|
|
304
|
+
if (!chunk.ok) throw new Error("Unavailable shard");
|
|
305
|
+
const data = await chunk.json();
|
|
306
|
+
if (
|
|
307
|
+
!Array.isArray(data.entries) ||
|
|
308
|
+
all.length + data.entries.length > 100000
|
|
309
|
+
)
|
|
310
|
+
throw new Error("Invalid archive");
|
|
311
|
+
for (const record of data.entries) {
|
|
312
|
+
if (
|
|
313
|
+
typeof record.name !== "string" ||
|
|
314
|
+
typeof record.path !== "string" ||
|
|
315
|
+
typeof record.exitsExplorer !== "boolean"
|
|
316
|
+
)
|
|
317
|
+
throw new Error("Invalid archive record");
|
|
318
|
+
const href = record.href === null ? null : new URL(record.href, url);
|
|
319
|
+
if (href && !["http:", "https:"].includes(href.protocol))
|
|
320
|
+
throw new Error("Unsupported archive link");
|
|
321
|
+
all.push({
|
|
322
|
+
name: record.name,
|
|
323
|
+
path: record.path,
|
|
324
|
+
href: href?.href ?? null,
|
|
325
|
+
exitsExplorer: record.exitsExplorer,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const matches = all
|
|
330
|
+
.map((record) => ({
|
|
331
|
+
record,
|
|
332
|
+
score: fuzzy(query, record.name + " " + record.path),
|
|
333
|
+
}))
|
|
334
|
+
.filter((item) => item.score !== null)
|
|
335
|
+
.sort(
|
|
336
|
+
(a, b) =>
|
|
337
|
+
b.score - a.score || a.record.path.localeCompare(b.record.path),
|
|
338
|
+
);
|
|
339
|
+
if (token !== archiveRequest || local.signal.aborted) return;
|
|
340
|
+
globalRows = matches.slice(0, 100).map((item) => item.record);
|
|
341
|
+
globalStatus =
|
|
342
|
+
matches.length > 100
|
|
343
|
+
? "Showing 100 of " + matches.length + " results"
|
|
344
|
+
: matches.length + " results";
|
|
345
|
+
} catch {
|
|
346
|
+
if (token === archiveRequest && !local.signal.aborted)
|
|
347
|
+
globalStatus = "Could not load archive search. Try again.";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
onMount(() => {
|
|
351
|
+
ready = true;
|
|
352
|
+
abort = new AbortController();
|
|
353
|
+
try {
|
|
354
|
+
const stored = localStorage.getItem("dirwell-color-scheme");
|
|
355
|
+
if (["system", "light", "dark"].includes(stored)) scheme = stored;
|
|
356
|
+
} catch {}
|
|
357
|
+
history.replaceState(
|
|
358
|
+
{ dataHref: new URL(model.dataHref, document.baseURI).href },
|
|
359
|
+
"",
|
|
360
|
+
);
|
|
361
|
+
const onPop = (event) => {
|
|
362
|
+
if (event.state?.dataHref)
|
|
363
|
+
openFolder(event.state.dataHref, location.href, false);
|
|
364
|
+
else location.reload();
|
|
365
|
+
};
|
|
366
|
+
window.addEventListener("popstate", onPop);
|
|
367
|
+
hydrateData();
|
|
368
|
+
return () => {
|
|
369
|
+
abort?.abort();
|
|
370
|
+
archiveAbort?.abort();
|
|
371
|
+
window.removeEventListener("popstate", onPop);
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
$effect(() => {
|
|
375
|
+
const href = updatesHref;
|
|
376
|
+
if (!href || typeof EventSource === "undefined") return;
|
|
377
|
+
const updates = new EventSource(href);
|
|
378
|
+
updates.onmessage = () => (pendingUpdates = href);
|
|
379
|
+
updates.onerror = () =>
|
|
380
|
+
(error = "Live updates disconnected. Refresh to retry.");
|
|
381
|
+
updates.onopen = () => {
|
|
382
|
+
if (error.startsWith("Live updates")) error = "";
|
|
383
|
+
};
|
|
384
|
+
return () => updates.close();
|
|
385
|
+
});
|
|
386
|
+
$effect(() => {
|
|
387
|
+
if (ready && !busy && pendingUpdates) {
|
|
388
|
+
const href = pendingUpdates;
|
|
389
|
+
pendingUpdates = null;
|
|
390
|
+
if (href === updatesHref)
|
|
391
|
+
openFolder(model.dataHref, location.href, false, true);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
$effect(() => {
|
|
395
|
+
if (ready) document.documentElement.dataset.theme = scheme;
|
|
396
|
+
});
|
|
397
|
+
$effect(() => {
|
|
398
|
+
if (timeDialog && timestamp && !timeDialog.open) timeDialog.showModal();
|
|
399
|
+
});
|
|
400
|
+
$effect(() => {
|
|
401
|
+
if (globalDialog && globalOpen && !globalDialog.open) {
|
|
402
|
+
globalDialog.showModal();
|
|
403
|
+
tick().then(() => globalInput?.focus());
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
</script>
|
|
407
|
+
|
|
408
|
+
<svelte:window
|
|
409
|
+
onkeydown={keyboard}
|
|
410
|
+
onscroll={() => {
|
|
411
|
+
if (list) scrollTop = Math.max(0, window.scrollY - list.offsetTop);
|
|
412
|
+
}}
|
|
413
|
+
/>
|
|
414
|
+
<header class="page-header">
|
|
415
|
+
<div class="wordmark">
|
|
416
|
+
{#if header}{@render header({ model })}{:else}<h1>{model.title}</h1>
|
|
417
|
+
<span>File archive</span>{/if}
|
|
418
|
+
</div>
|
|
419
|
+
<nav aria-label="Breadcrumb">
|
|
420
|
+
<a
|
|
421
|
+
href={model.rootHref}
|
|
422
|
+
onclick={(event) =>
|
|
423
|
+
navigateLink(event, model.rootHref, model.rootDataHref)}>Home</a
|
|
424
|
+
>{#each model.breadcrumbs as crumb}
|
|
425
|
+
/ <a
|
|
426
|
+
href={crumb.href}
|
|
427
|
+
onclick={(event) => navigateLink(event, crumb.href, crumb.dataHref)}
|
|
428
|
+
>{crumb.name}</a
|
|
429
|
+
>{/each}
|
|
430
|
+
</nav>
|
|
431
|
+
</header>
|
|
432
|
+
<main data-explorer data-framework="svelte" aria-busy={busy}>
|
|
433
|
+
<div class="toolbar">
|
|
434
|
+
<label class="search-field"
|
|
435
|
+
>Search this directory<input
|
|
436
|
+
bind:this={searchInput}
|
|
437
|
+
type="search"
|
|
438
|
+
placeholder="Search files"
|
|
439
|
+
bind:value={query}
|
|
440
|
+
disabled={!ready || !complete}
|
|
441
|
+
oncompositionstart={() => (composing = true)}
|
|
442
|
+
oncompositionend={() => (composing = false)}
|
|
443
|
+
/></label
|
|
444
|
+
>
|
|
445
|
+
<fieldset disabled={!ready || !complete}>
|
|
446
|
+
<legend>Types</legend>{#each ["directory", "file", "link"] as type}<label
|
|
447
|
+
><input
|
|
448
|
+
type="checkbox"
|
|
449
|
+
checked={types.includes(type)}
|
|
450
|
+
onchange={(event) =>
|
|
451
|
+
(types = event.currentTarget.checked
|
|
452
|
+
? [...types, type]
|
|
453
|
+
: types.filter((value) => value !== type))}
|
|
454
|
+
/>{type}</label
|
|
455
|
+
>{/each}
|
|
456
|
+
</fieldset>
|
|
457
|
+
<label
|
|
458
|
+
>Sort<select
|
|
459
|
+
value={sort.field}
|
|
460
|
+
disabled={!ready || !complete}
|
|
461
|
+
onchange={(event) =>
|
|
462
|
+
(sort = { ...sort, field: event.currentTarget.value })}
|
|
463
|
+
><option value="name">Name</option><option value="size">Size</option
|
|
464
|
+
><option value="modified">Modified</option></select
|
|
465
|
+
></label
|
|
466
|
+
>
|
|
467
|
+
<label
|
|
468
|
+
>Names<select
|
|
469
|
+
value={sort.nameMode}
|
|
470
|
+
disabled={!ready || !complete}
|
|
471
|
+
onchange={(event) =>
|
|
472
|
+
(sort = { ...sort, nameMode: event.currentTarget.value })}
|
|
473
|
+
><option value="natural">Natural</option><option value="locale"
|
|
474
|
+
>Locale</option
|
|
475
|
+
><option value="unicode">Unicode</option></select
|
|
476
|
+
></label
|
|
477
|
+
>
|
|
478
|
+
<label
|
|
479
|
+
>Order<select
|
|
480
|
+
value={sort.direction}
|
|
481
|
+
disabled={!ready || !complete}
|
|
482
|
+
onchange={(event) =>
|
|
483
|
+
(sort = { ...sort, direction: event.currentTarget.value })}
|
|
484
|
+
><option value="asc">Ascending</option><option value="desc"
|
|
485
|
+
>Descending</option
|
|
486
|
+
></select
|
|
487
|
+
></label
|
|
488
|
+
>
|
|
489
|
+
<label
|
|
490
|
+
><input
|
|
491
|
+
type="checkbox"
|
|
492
|
+
checked={sort.directoriesFirst}
|
|
493
|
+
disabled={!ready || !complete}
|
|
494
|
+
onchange={(event) =>
|
|
495
|
+
(sort = { ...sort, directoriesFirst: event.currentTarget.checked })}
|
|
496
|
+
/>Folders first</label
|
|
497
|
+
>
|
|
498
|
+
<label
|
|
499
|
+
>Appearance<select
|
|
500
|
+
value={scheme}
|
|
501
|
+
disabled={!ready}
|
|
502
|
+
onchange={(event) => updateScheme(event.currentTarget.value)}
|
|
503
|
+
><option value="system">System</option><option value="light"
|
|
504
|
+
>Light</option
|
|
505
|
+
><option value="dark">Dark</option></select
|
|
506
|
+
></label
|
|
507
|
+
>
|
|
508
|
+
<button
|
|
509
|
+
disabled={!ready || !model.searchIndexHref}
|
|
510
|
+
onclick={() => (globalOpen = true)}>Search archive</button
|
|
511
|
+
>
|
|
512
|
+
</div>
|
|
513
|
+
<section class="listing-card">
|
|
514
|
+
<div class="listing-summary">
|
|
515
|
+
<span data-visible-count>{complete ? rows.length : entries.length}</span>
|
|
516
|
+
entries{#if !complete}
|
|
517
|
+
on this page · {model.total} in directory{/if}<span
|
|
518
|
+
role="status"
|
|
519
|
+
aria-live="polite">{busy ? "Loading…" : error}</span
|
|
520
|
+
>
|
|
521
|
+
</div>
|
|
522
|
+
<div class="entry-head">
|
|
523
|
+
<span>Name</span><span>Size</span><span>Modified</span>
|
|
524
|
+
</div>
|
|
525
|
+
<ul class="entry-list" bind:this={list}>
|
|
526
|
+
{#if model.parentHref}<li class="entry">
|
|
527
|
+
<a
|
|
528
|
+
href={model.parentHref}
|
|
529
|
+
onclick={(event) =>
|
|
530
|
+
navigateLink(event, model.parentHref, model.parentDataHref)}
|
|
531
|
+
>../</a
|
|
532
|
+
>
|
|
533
|
+
</li>{/if}
|
|
534
|
+
{#if start}<li
|
|
535
|
+
aria-hidden="true"
|
|
536
|
+
style:height={offsets[start] + "px"}
|
|
537
|
+
></li>{/if}
|
|
538
|
+
{#each rows.slice(start, end) as entry, index (entry.id)}
|
|
539
|
+
<li
|
|
540
|
+
class="entry"
|
|
541
|
+
use:measure={entry.id}
|
|
542
|
+
data-entry
|
|
543
|
+
data-id={entry.id}
|
|
544
|
+
data-order={start + index}
|
|
545
|
+
data-active={selected === entry.id}
|
|
546
|
+
>
|
|
547
|
+
<span class="identity"
|
|
548
|
+
>{#if entry.href}<a
|
|
549
|
+
class="name"
|
|
550
|
+
href={safeHref(entry)}
|
|
551
|
+
target={entry.exitsExplorer ? "_blank" : undefined}
|
|
552
|
+
rel={entry.exitsExplorer ? "noopener" : undefined}
|
|
553
|
+
onclick={(event) => navigate(event, entry)}
|
|
554
|
+
onfocus={() => (selected = entry.id)}
|
|
555
|
+
>{#if row}{@render row({
|
|
556
|
+
model,
|
|
557
|
+
entry,
|
|
558
|
+
selected: selected === entry.id,
|
|
559
|
+
})}{:else}<span class="file-icon" aria-hidden="true"
|
|
560
|
+
>{@html entryIcon(entryType(entry))}</span
|
|
561
|
+
>
|
|
562
|
+
{entry.name}{isDirectory(entry) ? "/" : ""}{/if}</a
|
|
563
|
+
>{:else}<span
|
|
564
|
+
>{#if row}{@render row({
|
|
565
|
+
model,
|
|
566
|
+
entry,
|
|
567
|
+
selected: selected === entry.id,
|
|
568
|
+
})}{:else}{entry.name}{/if}</span
|
|
569
|
+
>{/if}{#if entry.link}<small class="target"
|
|
570
|
+
>{entry.link.unavailable ? "Target unavailable · " : "→ "}{entry
|
|
571
|
+
.link.target}</small
|
|
572
|
+
>{/if}</span
|
|
573
|
+
>
|
|
574
|
+
<span class="kind"
|
|
575
|
+
>{isDirectory(entry) ? "Folder" : size(entry.size)}</span
|
|
576
|
+
>
|
|
577
|
+
<button
|
|
578
|
+
class="timestamp-button"
|
|
579
|
+
disabled={!ready}
|
|
580
|
+
onclick={() => (timestamp = entry.modifiedAt)}
|
|
581
|
+
><time datetime={entry.modifiedAt}
|
|
582
|
+
>{modified(entry.modifiedAt, ready)}</time
|
|
583
|
+
></button
|
|
584
|
+
>
|
|
585
|
+
</li>
|
|
586
|
+
{/each}
|
|
587
|
+
{#if end < rows.length}<li
|
|
588
|
+
aria-hidden="true"
|
|
589
|
+
style:height={offsets[rows.length] - offsets[end] + "px"}
|
|
590
|
+
></li>{/if}
|
|
591
|
+
</ul>
|
|
592
|
+
{#if rows.length === 0}<p>No matching entries.</p>{/if}
|
|
593
|
+
</section>
|
|
594
|
+
{#if model.page?.count > 1 && !complete}<nav aria-label="Pagination">
|
|
595
|
+
{#if model.page.previousHref}<a rel="prev" href={model.page.previousHref}
|
|
596
|
+
>Previous</a
|
|
597
|
+
>{/if} Page {model.page.number} of {model.page.count}
|
|
598
|
+
{#if model.page.nextHref}<a rel="next" href={model.page.nextHref}>Next</a
|
|
599
|
+
>{/if}
|
|
600
|
+
</nav>{/if}
|
|
601
|
+
{#if footer}{@render footer({ model })}{:else}<p class="keyboard-help">
|
|
602
|
+
Arrow keys select files · Home / End jump · / searches · Backspace opens
|
|
603
|
+
parent
|
|
604
|
+
</p>{/if}
|
|
605
|
+
</main>
|
|
606
|
+
{#if timestamp}<dialog
|
|
607
|
+
bind:this={timeDialog}
|
|
608
|
+
onclose={() => (timestamp = null)}
|
|
609
|
+
>
|
|
610
|
+
<h2>Modified time</h2>
|
|
611
|
+
<p>{timestamp}</p>
|
|
612
|
+
<p>{new Date(timestamp).toLocaleString()}</p>
|
|
613
|
+
<button onclick={() => (timestamp = null)}>Close</button>
|
|
614
|
+
</dialog>{/if}
|
|
615
|
+
{#if globalOpen}<dialog
|
|
616
|
+
bind:this={globalDialog}
|
|
617
|
+
onclose={() => (globalOpen = false)}
|
|
618
|
+
>
|
|
619
|
+
<h2>Search archive</h2>
|
|
620
|
+
<input
|
|
621
|
+
aria-label="Search archive files"
|
|
622
|
+
bind:this={globalInput}
|
|
623
|
+
type="search"
|
|
624
|
+
bind:value={globalQuery}
|
|
625
|
+
oninput={searchArchive}
|
|
626
|
+
/>
|
|
627
|
+
<p role="status">{globalStatus}</p>
|
|
628
|
+
<ul>
|
|
629
|
+
{#each globalRows as record}<li>
|
|
630
|
+
{#if record.href}<a
|
|
631
|
+
href={record.href}
|
|
632
|
+
target={record.exitsExplorer ? "_blank" : undefined}
|
|
633
|
+
rel={record.exitsExplorer ? "noopener" : undefined}
|
|
634
|
+
>{record.path}</a
|
|
635
|
+
>{:else}{record.path}{/if}
|
|
636
|
+
</li>{/each}
|
|
637
|
+
</ul>
|
|
638
|
+
<button onclick={() => (globalOpen = false)}>Close</button>
|
|
639
|
+
</dialog>{/if}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type {Component, Snippet} from 'svelte';
|
|
2
|
+
import type {ThemePage, RowContext} from '../model.mjs';
|
|
3
|
+
export interface LedgerProps {readonly model: ThemePage; readonly header?: Snippet<[{model: ThemePage}]>; readonly row?: Snippet<[RowContext]>; readonly footer?: Snippet<[{model: ThemePage}]>;}
|
|
4
|
+
declare const Ledger: Component<LedgerProps>; export default Ledger;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// ../shared-theme/src/icons.ts
|
|
2
|
+
var paths = {
|
|
3
|
+
directory: '<path d="M3 7V5h6l2 3h10v12H3Z"/><path d="M3 10h18"/>',
|
|
4
|
+
file: '<path d="M5 3h9l5 5v13H5ZM14 3v5h5M8 12h8M8 16h6"/>',
|
|
5
|
+
link: '<path d="m9 15 6-6M8 10l-3 3a4 4 0 0 0 6 6l3-3M10 8l3-3a4 4 0 0 1 6 6l-3 3"/>'
|
|
6
|
+
};
|
|
7
|
+
function entryIcon(kind) {
|
|
8
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[kind]}</svg>`;
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
entryIcon
|
|
12
|
+
};
|