tangled
alpha
login
or
join now
olaren.dev
/
pdsls
forked from
pds.ls/pdsls
0
fork
atom
atmosphere explorer
0
fork
atom
overview
issues
pulls
pipelines
add unpack archive
handle.invalid
2 months ago
22fd9fd1
df77a5c2
verified
This commit was signed with the committer's
known signature
.
handle.invalid
SSH Key Fingerprint:
SHA256:mBrT4x0JdzLpbVR95g1hjI1aaErfC02kmLRkPXwsYCk=
+705
-176
11 changed files
expand all
collapse all
unified
split
package.json
pnpm-lock.yaml
src
components
tooltip.tsx
index.tsx
layout.tsx
views
car
explore.tsx
index.tsx
logger.tsx
shared.tsx
unpack.tsx
record.tsx
+2
package.json
···
48
48
"@fsegurai/codemirror-theme-basic-dark": "^6.2.3",
49
49
"@fsegurai/codemirror-theme-basic-light": "^6.2.3",
50
50
"@mary/exif-rm": "jsr:^0.2.2",
51
51
+
"@mary/zip": "jsr:^0.1.1",
51
52
"@skyware/firehose": "^0.5.2",
52
53
"@solidjs/meta": "^0.29.4",
53
54
"@solidjs/router": "^0.15.4",
54
55
"codemirror": "^6.0.2",
56
56
+
"native-file-system-adapter": "^3.0.1",
55
57
"solid-js": "^1.9.10"
56
58
},
57
59
"packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a"
+69
pnpm-lock.yaml
···
89
89
'@mary/exif-rm':
90
90
specifier: jsr:^0.2.2
91
91
version: '@jsr/mary__exif-rm@0.2.2'
92
92
+
'@mary/zip':
93
93
+
specifier: jsr:^0.1.1
94
94
+
version: '@jsr/mary__zip@0.1.1'
92
95
'@skyware/firehose':
93
96
specifier: ^0.5.2
94
97
version: 0.5.2
···
101
104
codemirror:
102
105
specifier: ^6.0.2
103
106
version: 6.0.2
107
107
+
native-file-system-adapter:
108
108
+
specifier: ^3.0.1
109
109
+
version: 3.0.1
104
110
solid-js:
105
111
specifier: ^1.9.10
106
112
version: 1.9.10
···
685
691
'@jridgewell/trace-mapping@0.3.31':
686
692
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
687
693
694
694
+
'@jsr/mary__date-fns@0.1.3':
695
695
+
resolution: {integrity: sha512-kjS04BESEHO9ZTqjOxk4ip8DsAdVDmt/jC5V4zVIYq3VD/04+WJK9kjdQda23eVZMuF9ZZY0zMswU7UXG+PSrg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__date-fns/0.1.3.tgz}
696
696
+
697
697
+
'@jsr/mary__ds-circular-buffer@0.1.0':
698
698
+
resolution: {integrity: sha512-2aQ7G++qZ+WGBrfuyQduAc4qyv610Ywab5hLe8y36/s5/Pi+fV+7L3g3g0dVqSChGQfi/qyIP/WDtXwM97nUtA==, tarball: https://npm.jsr.io/~/11/@jsr/mary__ds-circular-buffer/0.1.0.tgz}
699
699
+
700
700
+
'@jsr/mary__ds-queue@0.1.3':
701
701
+
resolution: {integrity: sha512-gGqIHXiAmhUUtonNI6YVvL7VlXjEHUpGdc7RGU8BLP4XnFvqovDTH5y9VlBZmvozTWgTIMoZF6/1//sMrvYKtQ==, tarball: https://npm.jsr.io/~/11/@jsr/mary__ds-queue/0.1.3.tgz}
702
702
+
688
703
'@jsr/mary__exif-rm@0.2.2':
689
704
resolution: {integrity: sha512-+ZpLaC+1CyqWhH608Sqd6/yTG0pOlokn2tCXha7s1SMQ+GLKo4Nn/PskTeeP9Pt+6gNYSu6ednoSlRvXb2ZGxg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__exif-rm/0.2.2.tgz}
690
705
706
706
+
'@jsr/mary__mutex@0.1.0':
707
707
+
resolution: {integrity: sha512-0G3CXC6VCZoVn2b97Xvvqs6zTGzjnAZiNA84CZjeA+g09UmFHq+ji44lq566NruUfG8Pq58n+6Uln8FGrL8G3w==, tarball: https://npm.jsr.io/~/11/@jsr/mary__mutex/0.1.0.tgz}
708
708
+
709
709
+
'@jsr/mary__zip@0.1.1':
710
710
+
resolution: {integrity: sha512-RIt6xSshG6x1X4qUVGXukgS8ysot+wLQc/WAkNogbiDwm3tzFyDxZdEs8TVDQyT1L7lApPPgbEgdcex7NcfGrw==, tarball: https://npm.jsr.io/~/11/@jsr/mary__zip/0.1.1.tgz}
711
711
+
691
712
'@lezer/common@1.5.0':
692
713
resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==}
693
714
···
1082
1103
picomatch:
1083
1104
optional: true
1084
1105
1106
1106
+
fetch-blob@3.2.0:
1107
1107
+
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
1108
1108
+
engines: {node: ^12.20 || >= 14.13}
1109
1109
+
1085
1110
fflate@0.8.2:
1086
1111
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
1087
1112
···
1234
1259
engines: {node: ^18 || >=20}
1235
1260
hasBin: true
1236
1261
1262
1262
+
native-file-system-adapter@3.0.1:
1263
1263
+
resolution: {integrity: sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==}
1264
1264
+
engines: {node: '>=14.8.0'}
1265
1265
+
1266
1266
+
node-domexception@1.0.0:
1267
1267
+
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
1268
1268
+
engines: {node: '>=10.5.0'}
1269
1269
+
deprecated: Use your platform's native DOMException instead
1270
1270
+
1237
1271
node-gyp-build@4.8.4:
1238
1272
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
1239
1273
hasBin: true
···
1486
1520
w3c-keyname@2.2.8:
1487
1521
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
1488
1522
1523
1523
+
web-streams-polyfill@3.3.3:
1524
1524
+
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
1525
1525
+
engines: {node: '>= 8'}
1526
1526
+
1489
1527
yallist@3.1.1:
1490
1528
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
1491
1529
···
2038
2076
'@jridgewell/resolve-uri': 3.1.2
2039
2077
'@jridgewell/sourcemap-codec': 1.5.5
2040
2078
2079
2079
+
'@jsr/mary__date-fns@0.1.3': {}
2080
2080
+
2081
2081
+
'@jsr/mary__ds-circular-buffer@0.1.0': {}
2082
2082
+
2083
2083
+
'@jsr/mary__ds-queue@0.1.3': {}
2084
2084
+
2041
2085
'@jsr/mary__exif-rm@0.2.2': {}
2042
2086
2087
2087
+
'@jsr/mary__mutex@0.1.0': {}
2088
2088
+
2089
2089
+
'@jsr/mary__zip@0.1.1':
2090
2090
+
dependencies:
2091
2091
+
'@jsr/mary__date-fns': 0.1.3
2092
2092
+
'@jsr/mary__ds-circular-buffer': 0.1.0
2093
2093
+
'@jsr/mary__ds-queue': 0.1.3
2094
2094
+
'@jsr/mary__mutex': 0.1.0
2095
2095
+
2043
2096
'@lezer/common@1.5.0': {}
2044
2097
2045
2098
'@lezer/highlight@1.2.3':
···
2418
2471
optionalDependencies:
2419
2472
picomatch: 4.0.3
2420
2473
2474
2474
+
fetch-blob@3.2.0:
2475
2475
+
dependencies:
2476
2476
+
node-domexception: 1.0.0
2477
2477
+
web-streams-polyfill: 3.3.3
2478
2478
+
optional: true
2479
2479
+
2421
2480
fflate@0.8.2: {}
2422
2481
2423
2482
fsevents@2.3.3:
···
2525
2584
nanoid@3.3.11: {}
2526
2585
2527
2586
nanoid@5.1.6: {}
2587
2587
+
2588
2588
+
native-file-system-adapter@3.0.1:
2589
2589
+
optionalDependencies:
2590
2590
+
fetch-blob: 3.2.0
2591
2591
+
2592
2592
+
node-domexception@1.0.0:
2593
2593
+
optional: true
2528
2594
2529
2595
node-gyp-build@4.8.4: {}
2530
2596
···
2710
2776
vite: 7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.19.2)
2711
2777
2712
2778
w3c-keyname@2.2.8: {}
2779
2779
+
2780
2780
+
web-streams-polyfill@3.3.3:
2781
2781
+
optional: true
2713
2782
2714
2783
yallist@3.1.1: {}
2715
2784
+3
-3
src/components/tooltip.tsx
···
2
2
import { isTouchDevice } from "../layout";
3
3
4
4
const Tooltip = (props: { text: string; children: JSX.Element }) => (
5
5
-
<div class="group/tooltip relative flex items-center">
5
5
+
<span class="group/tooltip relative inline-flex items-center">
6
6
{props.children}
7
7
<Show when={!isTouchDevice}>
8
8
<span
9
9
style={`transform: translate(-50%, 28px)`}
10
10
-
class={`dark:shadow-dark-700 dark:bg-dark-300 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-white p-1 text-center font-sans text-xs whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:text-neutral-200`}
10
10
+
class={`dark:shadow-dark-700 dark:bg-dark-300 pointer-events-none absolute left-[50%] z-20 hidden min-w-fit rounded border-[0.5px] border-neutral-300 bg-white p-1 text-center font-sans text-xs font-normal whitespace-nowrap text-neutral-900 shadow-md select-none group-hover/tooltip:inline first-letter:capitalize dark:border-neutral-600 dark:text-neutral-200`}
11
11
>
12
12
{props.text}
13
13
</span>
14
14
</Show>
15
15
-
</div>
15
15
+
</span>
16
16
);
17
17
18
18
export default Tooltip;
+5
-1
src/index.tsx
···
3
3
import { render } from "solid-js/web";
4
4
import { Layout } from "./layout.tsx";
5
5
import "./styles/index.css";
6
6
-
import { CarView } from "./views/car.tsx";
6
6
+
import { ExploreToolView } from "./views/car/explore.tsx";
7
7
+
import { CarView } from "./views/car/index.tsx";
8
8
+
import { UnpackToolView } from "./views/car/unpack.tsx";
7
9
import { CollectionView } from "./views/collection.tsx";
8
10
import { Home } from "./views/home.tsx";
9
11
import { LabelView } from "./views/labels.tsx";
···
20
22
<Route path={["/jetstream", "/firehose"]} component={StreamView} />
21
23
<Route path="/labels" component={LabelView} />
22
24
<Route path="/car" component={CarView} />
25
25
+
<Route path="/car/explore" component={ExploreToolView} />
26
26
+
<Route path="/car/unpack" component={UnpackToolView} />
23
27
<Route path="/settings" component={Settings} />
24
28
<Route path="/:pds" component={PdsView} />
25
29
<Route path="/:pds/:repo" component={RepoView} />
+1
-1
src/layout.tsx
···
159
159
<NavMenu href="/jetstream" label="Jetstream" icon="lucide--radio-tower" />
160
160
<NavMenu href="/firehose" label="Firehose" icon="lucide--antenna" />
161
161
<NavMenu href="/labels" label="Labels" icon="lucide--tag" />
162
162
-
<NavMenu href="/car" label="CAR explorer" icon="lucide--folder-archive" />
162
162
+
<NavMenu href="/car" label="CAR tools" icon="lucide--folder-archive" />
163
163
<NavMenu href="/settings" label="Settings" icon="lucide--settings" />
164
164
<MenuSeparator />
165
165
<NavMenu
+59
-170
src/views/car.tsx
src/views/car/explore.tsx
···
5
5
import * as TID from "@atcute/tid";
6
6
import { Title } from "@solidjs/meta";
7
7
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js";
8
8
-
import { Button } from "../components/button.jsx";
9
9
-
import { JSONValue, type JSONType } from "../components/json.jsx";
10
10
-
import { TextInput } from "../components/text-input.jsx";
11
11
-
import { isTouchDevice } from "../layout.jsx";
12
12
-
import { localDateFromTimestamp } from "../utils/date.js";
13
13
-
14
14
-
const isIOS =
15
15
-
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
16
16
-
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
17
17
-
18
18
-
// Convert CBOR-decoded objects to JSON-friendly format
19
19
-
const toJsonValue = (obj: unknown): JSONType => {
20
20
-
if (obj === null || obj === undefined) return null;
21
21
-
22
22
-
if (CID.isCidLink(obj)) {
23
23
-
return { $link: obj.$link };
24
24
-
}
25
25
-
26
26
-
if (
27
27
-
obj &&
28
28
-
typeof obj === "object" &&
29
29
-
"version" in obj &&
30
30
-
"codec" in obj &&
31
31
-
"digest" in obj &&
32
32
-
"bytes" in obj
33
33
-
) {
34
34
-
try {
35
35
-
return { $link: CID.toString(obj as CID.Cid) };
36
36
-
} catch {}
37
37
-
}
8
8
+
import { Button } from "../../components/button.jsx";
9
9
+
import { JSONValue } from "../../components/json.jsx";
10
10
+
import { TextInput } from "../../components/text-input.jsx";
11
11
+
import { isTouchDevice } from "../../layout.jsx";
12
12
+
import { localDateFromTimestamp } from "../../utils/date.js";
13
13
+
import {
14
14
+
type Archive,
15
15
+
type CollectionEntry,
16
16
+
type RecordEntry,
17
17
+
type View,
18
18
+
toJsonValue,
19
19
+
WelcomeView,
20
20
+
} from "./shared.jsx";
38
21
39
39
-
if (CBOR.isBytes(obj)) {
40
40
-
return { $bytes: obj.$bytes };
41
41
-
}
42
42
-
43
43
-
if (Array.isArray(obj)) {
44
44
-
return obj.map(toJsonValue);
45
45
-
}
46
46
-
47
47
-
if (typeof obj === "object") {
48
48
-
const result: Record<string, JSONType> = {};
49
49
-
for (const [key, value] of Object.entries(obj)) {
50
50
-
result[key] = toJsonValue(value);
51
51
-
}
52
52
-
return result;
53
53
-
}
54
54
-
55
55
-
return obj as JSONType;
56
56
-
};
57
57
-
58
58
-
interface Archive {
59
59
-
file: File;
60
60
-
did: string;
61
61
-
entries: CollectionEntry[];
62
62
-
}
63
63
-
64
64
-
interface CollectionEntry {
65
65
-
name: string;
66
66
-
entries: RecordEntry[];
67
67
-
}
68
68
-
69
69
-
interface RecordEntry {
70
70
-
key: string;
71
71
-
cid: string;
72
72
-
record: JSONType;
73
73
-
}
74
74
-
75
75
-
type View =
76
76
-
| { type: "repo" }
77
77
-
| { type: "collection"; collection: CollectionEntry }
78
78
-
| { type: "record"; collection: CollectionEntry; record: RecordEntry };
79
79
-
80
80
-
export const CarView = () => {
22
22
+
export const ExploreToolView = () => {
81
23
const [archive, setArchive] = createSignal<Archive | null>(null);
82
24
const [loading, setLoading] = createSignal(false);
83
25
const [error, setError] = createSignal<string>();
···
97
39
const rootCid = car.roots[0]?.$link;
98
40
if (rootCid) {
99
41
for (const entry of car) {
100
100
-
if (CID.toString(entry.cid) === rootCid) {
101
101
-
const commit = CBOR.decode(entry.bytes);
102
102
-
if (isCommit(commit)) {
103
103
-
did = commit.did;
42
42
+
try {
43
43
+
if (CID.toString(entry.cid) === rootCid) {
44
44
+
const commit = CBOR.decode(entry.bytes);
45
45
+
if (isCommit(commit)) {
46
46
+
did = commit.did;
47
47
+
}
48
48
+
break;
104
49
}
105
105
-
break;
50
50
+
} catch {
51
51
+
// Skip entries with invalid CIDs
106
52
}
107
53
}
108
54
}
···
118
64
const repo = fromStream(stream);
119
65
try {
120
66
for await (const entry of repo) {
121
121
-
let list = collections.get(entry.collection);
122
122
-
if (list === undefined) {
123
123
-
collections.set(entry.collection, (list = []));
124
124
-
result.entries.push({
125
125
-
name: entry.collection,
126
126
-
entries: list,
67
67
+
try {
68
68
+
let list = collections.get(entry.collection);
69
69
+
if (list === undefined) {
70
70
+
collections.set(entry.collection, (list = []));
71
71
+
result.entries.push({
72
72
+
name: entry.collection,
73
73
+
entries: list,
74
74
+
});
75
75
+
}
76
76
+
77
77
+
const record = toJsonValue(entry.record);
78
78
+
list.push({
79
79
+
key: entry.rkey,
80
80
+
cid: entry.cid.$link,
81
81
+
record,
127
82
});
83
83
+
} catch {
84
84
+
// Skip entries with invalid data
128
85
}
129
129
-
130
130
-
const record = toJsonValue(entry.record);
131
131
-
list.push({
132
132
-
key: entry.rkey,
133
133
-
cid: entry.cid.$link,
134
134
-
record,
135
135
-
});
136
86
}
137
87
} finally {
138
88
await repo.dispose();
···
176
126
177
127
return (
178
128
<>
179
179
-
<Title>CAR explorer - PDSls</Title>
180
180
-
<div class="flex w-full flex-col items-center">
181
181
-
<Show
182
182
-
when={archive()}
183
183
-
fallback={
184
184
-
<WelcomeView
185
185
-
loading={loading()}
186
186
-
error={error()}
187
187
-
onFileChange={handleFileChange}
188
188
-
onDrop={handleDrop}
189
189
-
onDragOver={handleDragOver}
190
190
-
/>
191
191
-
}
192
192
-
>
193
193
-
{(arch) => <ExploreView archive={arch()} view={view} setView={setView} onClose={reset} />}
194
194
-
</Show>
195
195
-
</div>
196
196
-
</>
197
197
-
);
198
198
-
};
199
199
-
200
200
-
const WelcomeView = (props: {
201
201
-
loading: boolean;
202
202
-
error?: string;
203
203
-
onFileChange: (e: Event) => void;
204
204
-
onDrop: (e: DragEvent) => void;
205
205
-
onDragOver: (e: DragEvent) => void;
206
206
-
}) => {
207
207
-
return (
208
208
-
<div class="flex w-full max-w-3xl flex-col gap-y-4 px-2">
209
209
-
<div class="flex flex-col gap-y-1">
210
210
-
<h1 class="text-lg font-semibold">CAR explorer</h1>
211
211
-
<p class="text-sm text-neutral-600 dark:text-neutral-400">
212
212
-
Upload a CAR (Content Addressable aRchive) file to explore its contents.
213
213
-
</p>
214
214
-
</div>
215
215
-
216
216
-
<div
217
217
-
class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500"
218
218
-
onDrop={props.onDrop}
219
219
-
onDragOver={props.onDragOver}
129
129
+
<Title>Explore archive - PDSls</Title>
130
130
+
<Show
131
131
+
when={archive()}
132
132
+
fallback={
133
133
+
<WelcomeView
134
134
+
title="Explore archive"
135
135
+
subtitle="Upload a CAR file to explore its contents."
136
136
+
loading={loading()}
137
137
+
error={error()}
138
138
+
onFileChange={handleFileChange}
139
139
+
onDrop={handleDrop}
140
140
+
onDragOver={handleDragOver}
141
141
+
/>
142
142
+
}
220
143
>
221
221
-
<Show
222
222
-
when={!props.loading}
223
223
-
fallback={
224
224
-
<div class="flex flex-col items-center gap-2">
225
225
-
<span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" />
226
226
-
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">
227
227
-
Reading CAR file...
228
228
-
</span>
229
229
-
</div>
230
230
-
}
231
231
-
>
232
232
-
<span class="iconify lucide--folder-archive text-3xl text-neutral-400" />
233
233
-
<div class="text-center">
234
234
-
<p class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
235
235
-
Drag and drop a CAR file here
236
236
-
</p>
237
237
-
<p class="text-xs text-neutral-500 dark:text-neutral-400">or</p>
238
238
-
</div>
239
239
-
<label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800">
240
240
-
<input
241
241
-
type="file"
242
242
-
accept={isIOS ? undefined : ".car,application/vnd.ipld.car"}
243
243
-
onChange={props.onFileChange}
244
244
-
class="hidden"
245
245
-
/>
246
246
-
<span class="iconify lucide--upload text-sm" />
247
247
-
Choose file
248
248
-
</label>
249
249
-
</Show>
250
250
-
</div>
251
251
-
252
252
-
<Show when={props.error}>
253
253
-
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
254
254
-
{props.error}
255
255
-
</div>
144
144
+
{(arch) => <ExploreView archive={arch()} view={view} setView={setView} onClose={reset} />}
256
145
</Show>
257
257
-
</div>
146
146
+
</>
258
147
);
259
148
};
260
149
···
411
300
return (
412
301
<div class="flex flex-col gap-3">
413
302
<div class="text-sm text-neutral-600 dark:text-neutral-400">
414
414
-
{props.archive.entries.length} collection{props.archive.entries.length > 1 ? "s" : ""}
303
303
+
{props.archive.entries.length} collection{props.archive.entries.length !== 1 ? "s" : ""}
415
304
<span class="text-neutral-400 dark:text-neutral-600"> · </span>
416
416
-
{totalRecords()} record{totalRecords() > 1 ? "s" : ""}
305
305
+
{totalRecords()} record{totalRecords() !== 1 ? "s" : ""}
417
306
</div>
418
307
419
308
<TextInput
···
527
416
return (
528
417
<div class="flex flex-col gap-3">
529
418
<span class="text-sm text-neutral-600 dark:text-neutral-400">
530
530
-
{filteredEntries().length} record{filteredEntries().length > 1 ? "s" : ""}
419
419
+
{filteredEntries().length} record{filteredEntries().length !== 1 ? "s" : ""}
531
420
{filter() && filteredEntries().length !== props.collection.entries.length && (
532
421
<span class="text-neutral-400 dark:text-neutral-500">
533
422
{" "}
+44
src/views/car/index.tsx
···
1
1
+
import { Title } from "@solidjs/meta";
2
2
+
import { A } from "@solidjs/router";
3
3
+
4
4
+
export const CarView = () => {
5
5
+
return (
6
6
+
<div class="flex w-full max-w-3xl flex-col gap-y-4 px-2">
7
7
+
<Title>CAR tools - PDSls</Title>
8
8
+
<div class="flex flex-col gap-y-1">
9
9
+
<h1 class="text-lg font-semibold">CAR tools</h1>
10
10
+
<p class="text-sm text-neutral-600 dark:text-neutral-400">
11
11
+
Tools for working with Content Addressable aRchive files.
12
12
+
</p>
13
13
+
</div>
14
14
+
15
15
+
<div class="flex flex-col gap-3">
16
16
+
<A
17
17
+
href="explore"
18
18
+
class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800"
19
19
+
>
20
20
+
<span class="iconify lucide--folder-search mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" />
21
21
+
<div class="flex flex-col gap-1">
22
22
+
<span class="font-medium">Explore archive</span>
23
23
+
<span class="text-sm text-neutral-600 dark:text-neutral-400">
24
24
+
Browse records inside an archive
25
25
+
</span>
26
26
+
</div>
27
27
+
</A>
28
28
+
29
29
+
<A
30
30
+
href="unpack"
31
31
+
class="dark:bg-dark-300 flex items-start gap-3 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-4 text-left transition-colors hover:border-neutral-400 hover:bg-neutral-100 dark:border-neutral-600 dark:hover:border-neutral-500 dark:hover:bg-neutral-800"
32
32
+
>
33
33
+
<span class="iconify lucide--file-archive mt-0.5 shrink-0 text-xl text-neutral-500 dark:text-neutral-400" />
34
34
+
<div class="flex flex-col gap-1">
35
35
+
<span class="font-medium">Unpack archive</span>
36
36
+
<span class="text-sm text-neutral-600 dark:text-neutral-400">
37
37
+
Extract all records from an archive into a ZIP file
38
38
+
</span>
39
39
+
</div>
40
40
+
</A>
41
41
+
</div>
42
42
+
</div>
43
43
+
);
44
44
+
};
+152
src/views/car/logger.tsx
···
1
1
+
import { For } from "solid-js";
2
2
+
import { createMutable } from "solid-js/store";
3
3
+
4
4
+
interface LogEntry {
5
5
+
type: "log" | "info" | "warn" | "error";
6
6
+
at: number;
7
7
+
msg: string;
8
8
+
}
9
9
+
10
10
+
interface PendingLogEntry {
11
11
+
msg: string;
12
12
+
}
13
13
+
14
14
+
export const createLogger = () => {
15
15
+
const pending = createMutable<PendingLogEntry[]>([]);
16
16
+
17
17
+
let backlog: LogEntry[] | undefined = [];
18
18
+
let push = (entry: LogEntry) => {
19
19
+
backlog!.push(entry);
20
20
+
};
21
21
+
22
22
+
return {
23
23
+
internal: {
24
24
+
get pending() {
25
25
+
return pending;
26
26
+
},
27
27
+
attach(fn: (entry: LogEntry) => void) {
28
28
+
if (backlog !== undefined) {
29
29
+
for (let idx = 0, len = backlog.length; idx < len; idx++) {
30
30
+
fn(backlog[idx]);
31
31
+
}
32
32
+
backlog = undefined;
33
33
+
}
34
34
+
push = fn;
35
35
+
},
36
36
+
},
37
37
+
log(msg: string) {
38
38
+
push({ type: "log", at: Date.now(), msg });
39
39
+
},
40
40
+
info(msg: string) {
41
41
+
push({ type: "info", at: Date.now(), msg });
42
42
+
},
43
43
+
warn(msg: string) {
44
44
+
push({ type: "warn", at: Date.now(), msg });
45
45
+
},
46
46
+
error(msg: string) {
47
47
+
push({ type: "error", at: Date.now(), msg });
48
48
+
},
49
49
+
progress(initialMsg: string, throttleMs = 500) {
50
50
+
pending.unshift({ msg: initialMsg });
51
51
+
52
52
+
let entry: PendingLogEntry | undefined = pending[0];
53
53
+
54
54
+
return {
55
55
+
update: throttle((msg: string) => {
56
56
+
if (entry !== undefined) {
57
57
+
entry.msg = msg;
58
58
+
}
59
59
+
}, throttleMs),
60
60
+
destroy() {
61
61
+
if (entry !== undefined) {
62
62
+
const index = pending.indexOf(entry);
63
63
+
pending.splice(index, 1);
64
64
+
entry = undefined;
65
65
+
}
66
66
+
},
67
67
+
[Symbol.dispose]() {
68
68
+
this.destroy();
69
69
+
},
70
70
+
};
71
71
+
},
72
72
+
};
73
73
+
};
74
74
+
75
75
+
export type Logger = ReturnType<typeof createLogger>;
76
76
+
77
77
+
const formatter = new Intl.DateTimeFormat("en-US", { timeStyle: "short", hour12: false });
78
78
+
79
79
+
export const LoggerView = (props: { logger: Logger }) => {
80
80
+
return (
81
81
+
<ul class="flex flex-col font-mono text-xs empty:hidden">
82
82
+
<For each={props.logger.internal.pending}>
83
83
+
{(entry) => (
84
84
+
<li class="flex gap-2 px-4 py-1 whitespace-pre-wrap">
85
85
+
<span class="shrink-0 font-medium whitespace-pre-wrap text-neutral-400">-----</span>
86
86
+
<span class="wrap-break-word">{entry.msg}</span>
87
87
+
</li>
88
88
+
)}
89
89
+
</For>
90
90
+
91
91
+
<div
92
92
+
ref={(node) => {
93
93
+
props.logger.internal.attach(({ type, at, msg }) => {
94
94
+
let ecn = `flex gap-2 whitespace-pre-wrap px-4 py-1`;
95
95
+
let tcn = `shrink-0 whitespace-pre-wrap font-medium`;
96
96
+
if (type === "log") {
97
97
+
tcn += ` text-neutral-500`;
98
98
+
} else if (type === "info") {
99
99
+
ecn += ` bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`;
100
100
+
tcn += ` text-blue-500`;
101
101
+
} else if (type === "warn") {
102
102
+
ecn += ` bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300`;
103
103
+
tcn += ` text-amber-500`;
104
104
+
} else if (type === "error") {
105
105
+
ecn += ` bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`;
106
106
+
tcn += ` text-red-500`;
107
107
+
}
108
108
+
109
109
+
const item = (
110
110
+
<li class={ecn}>
111
111
+
<span class={tcn}>{formatter.format(at)}</span>
112
112
+
<span class="wrap-break-word">{msg}</span>
113
113
+
</li>
114
114
+
);
115
115
+
116
116
+
if (item instanceof Node) {
117
117
+
node.after(item);
118
118
+
}
119
119
+
});
120
120
+
}}
121
121
+
/>
122
122
+
</ul>
123
123
+
);
124
124
+
};
125
125
+
126
126
+
const throttle = <T extends (...args: any[]) => void>(func: T, wait: number) => {
127
127
+
let timeout: ReturnType<typeof setTimeout> | null = null;
128
128
+
let lastArgs: Parameters<T> | null = null;
129
129
+
let lastCallTime = 0;
130
130
+
131
131
+
const invoke = () => {
132
132
+
func(...lastArgs!);
133
133
+
lastCallTime = Date.now();
134
134
+
timeout = null;
135
135
+
};
136
136
+
137
137
+
return (...args: Parameters<T>) => {
138
138
+
const now = Date.now();
139
139
+
const timeSinceLastCall = now - lastCallTime;
140
140
+
141
141
+
lastArgs = args;
142
142
+
143
143
+
if (timeSinceLastCall >= wait) {
144
144
+
if (timeout !== null) {
145
145
+
clearTimeout(timeout);
146
146
+
}
147
147
+
invoke();
148
148
+
} else if (timeout === null) {
149
149
+
timeout = setTimeout(invoke, wait - timeSinceLastCall);
150
150
+
}
151
151
+
};
152
152
+
};
+148
src/views/car/shared.tsx
···
1
1
+
import * as CBOR from "@atcute/cbor";
2
2
+
import * as CID from "@atcute/cid";
3
3
+
import { A } from "@solidjs/router";
4
4
+
import { Show } from "solid-js";
5
5
+
import { type JSONType } from "../../components/json.jsx";
6
6
+
7
7
+
export const isIOS =
8
8
+
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
9
9
+
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
10
10
+
11
11
+
// Convert CBOR-decoded objects to JSON-friendly format
12
12
+
export const toJsonValue = (obj: unknown): JSONType => {
13
13
+
if (obj === null || obj === undefined) return null;
14
14
+
15
15
+
if (CID.isCidLink(obj)) {
16
16
+
return { $link: obj.$link };
17
17
+
}
18
18
+
19
19
+
if (
20
20
+
obj &&
21
21
+
typeof obj === "object" &&
22
22
+
"version" in obj &&
23
23
+
"codec" in obj &&
24
24
+
"digest" in obj &&
25
25
+
"bytes" in obj
26
26
+
) {
27
27
+
try {
28
28
+
return { $link: CID.toString(obj as CID.Cid) };
29
29
+
} catch {}
30
30
+
}
31
31
+
32
32
+
if (CBOR.isBytes(obj)) {
33
33
+
return { $bytes: obj.$bytes };
34
34
+
}
35
35
+
36
36
+
if (Array.isArray(obj)) {
37
37
+
return obj.map(toJsonValue);
38
38
+
}
39
39
+
40
40
+
if (typeof obj === "object") {
41
41
+
const result: Record<string, JSONType> = {};
42
42
+
for (const [key, value] of Object.entries(obj)) {
43
43
+
result[key] = toJsonValue(value);
44
44
+
}
45
45
+
return result;
46
46
+
}
47
47
+
48
48
+
return obj as JSONType;
49
49
+
};
50
50
+
51
51
+
export interface Archive {
52
52
+
file: File;
53
53
+
did: string;
54
54
+
entries: CollectionEntry[];
55
55
+
}
56
56
+
57
57
+
export interface CollectionEntry {
58
58
+
name: string;
59
59
+
entries: RecordEntry[];
60
60
+
}
61
61
+
62
62
+
export interface RecordEntry {
63
63
+
key: string;
64
64
+
cid: string;
65
65
+
record: JSONType;
66
66
+
}
67
67
+
68
68
+
export type View =
69
69
+
| { type: "repo" }
70
70
+
| { type: "collection"; collection: CollectionEntry }
71
71
+
| { type: "record"; collection: CollectionEntry; record: RecordEntry };
72
72
+
73
73
+
export const WelcomeView = (props: {
74
74
+
title: string;
75
75
+
subtitle: string;
76
76
+
loading: boolean;
77
77
+
error?: string;
78
78
+
progress?: string;
79
79
+
onFileChange: (e: Event) => void;
80
80
+
onDrop: (e: DragEvent) => void;
81
81
+
onDragOver: (e: DragEvent) => void;
82
82
+
}) => {
83
83
+
return (
84
84
+
<div class="flex w-full max-w-3xl flex-col gap-y-4 px-2">
85
85
+
<div class="flex flex-col gap-y-1">
86
86
+
<div class="flex items-center gap-2 text-lg">
87
87
+
<A
88
88
+
href="/car"
89
89
+
class="flex size-7 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
90
90
+
>
91
91
+
<span class="iconify lucide--arrow-left" />
92
92
+
</A>
93
93
+
<h1 class="font-semibold">{props.title}</h1>
94
94
+
</div>
95
95
+
<p class="text-sm text-neutral-600 dark:text-neutral-400">{props.subtitle}</p>
96
96
+
</div>
97
97
+
98
98
+
<div
99
99
+
class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500"
100
100
+
onDrop={props.onDrop}
101
101
+
onDragOver={props.onDragOver}
102
102
+
>
103
103
+
<Show
104
104
+
when={!props.loading}
105
105
+
fallback={
106
106
+
<div class="flex flex-col items-center gap-2">
107
107
+
<span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" />
108
108
+
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">
109
109
+
{props.progress ?? "Reading CAR file..."}
110
110
+
</span>
111
111
+
</div>
112
112
+
}
113
113
+
>
114
114
+
<span class="iconify lucide--folder-archive text-3xl text-neutral-400" />
115
115
+
<div class="text-center">
116
116
+
<p class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
117
117
+
Drag and drop a CAR file here
118
118
+
</p>
119
119
+
<p class="text-xs text-neutral-500 dark:text-neutral-400">or</p>
120
120
+
</div>
121
121
+
<label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800">
122
122
+
<input
123
123
+
type="file"
124
124
+
accept={isIOS ? undefined : ".car,application/vnd.ipld.car"}
125
125
+
onChange={props.onFileChange}
126
126
+
class="hidden"
127
127
+
/>
128
128
+
<span class="iconify lucide--upload text-sm" />
129
129
+
Choose file
130
130
+
</label>
131
131
+
</Show>
132
132
+
</div>
133
133
+
134
134
+
<Show when={props.progress && !props.loading}>
135
135
+
<div class="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
136
136
+
<span class="iconify lucide--check shrink-0" />
137
137
+
{props.progress}
138
138
+
</div>
139
139
+
</Show>
140
140
+
141
141
+
<Show when={props.error}>
142
142
+
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
143
143
+
{props.error}
144
144
+
</div>
145
145
+
</Show>
146
146
+
</div>
147
147
+
);
148
148
+
};
+222
src/views/car/unpack.tsx
···
1
1
+
import { fromStream } from "@atcute/repo";
2
2
+
import { zip, type ZipEntry } from "@mary/zip";
3
3
+
import { Title } from "@solidjs/meta";
4
4
+
import { A } from "@solidjs/router";
5
5
+
import { FileSystemWritableFileStream, showSaveFilePicker } from "native-file-system-adapter";
6
6
+
import { createSignal, onCleanup, Show } from "solid-js";
7
7
+
import { createLogger, LoggerView } from "./logger.jsx";
8
8
+
import { isIOS, toJsonValue } from "./shared.jsx";
9
9
+
10
10
+
const INVALID_CHAR_RE = /[<>:"/\\|?*\x00-\x1F]/g;
11
11
+
const filenamify = (name: string) => {
12
12
+
return name.replace(INVALID_CHAR_RE, "~");
13
13
+
};
14
14
+
15
15
+
export const UnpackToolView = () => {
16
16
+
const logger = createLogger();
17
17
+
const [pending, setPending] = createSignal(false);
18
18
+
19
19
+
let abortController: AbortController | undefined;
20
20
+
21
21
+
onCleanup(() => {
22
22
+
abortController?.abort();
23
23
+
});
24
24
+
25
25
+
const unpackToZip = async (file: File) => {
26
26
+
abortController?.abort();
27
27
+
abortController = new AbortController();
28
28
+
const signal = abortController.signal;
29
29
+
30
30
+
setPending(true);
31
31
+
logger.log(`Starting extraction`);
32
32
+
33
33
+
let repo: Awaited<ReturnType<typeof fromStream>> | undefined;
34
34
+
35
35
+
const stream = file.stream();
36
36
+
repo = fromStream(stream);
37
37
+
38
38
+
try {
39
39
+
let count = 0;
40
40
+
let writable: FileSystemWritableFileStream | undefined;
41
41
+
42
42
+
// Create async generator that yields ZipEntry as we read from CAR
43
43
+
const entryGenerator = async function* (): AsyncGenerator<ZipEntry> {
44
44
+
using progress = logger.progress(`Unpacking records (0 entries)`);
45
45
+
46
46
+
for await (const entry of repo) {
47
47
+
if (signal.aborted) return;
48
48
+
49
49
+
// Prompt for save location on first record
50
50
+
if (writable === undefined) {
51
51
+
using _waiting = logger.progress(`Waiting for user...`);
52
52
+
53
53
+
const fd = await showSaveFilePicker({
54
54
+
suggestedName: `${file.name.replace(/\.car$/, "")}.zip`,
55
55
+
// @ts-expect-error: ponyfill doesn't have full typings
56
56
+
id: "car-unpack",
57
57
+
startIn: "downloads",
58
58
+
types: [
59
59
+
{
60
60
+
description: "ZIP archive",
61
61
+
accept: { "application/zip": [".zip"] },
62
62
+
},
63
63
+
],
64
64
+
}).catch((err) => {
65
65
+
if (err instanceof DOMException && err.name === "AbortError") {
66
66
+
logger.warn(`File picker was cancelled`);
67
67
+
} else {
68
68
+
console.warn(err);
69
69
+
logger.warn(`Something went wrong when opening the file picker`);
70
70
+
}
71
71
+
return undefined;
72
72
+
});
73
73
+
74
74
+
writable = await fd?.createWritable();
75
75
+
76
76
+
if (writable === undefined) {
77
77
+
return;
78
78
+
}
79
79
+
}
80
80
+
81
81
+
try {
82
82
+
const record = toJsonValue(entry.record);
83
83
+
const filename = `${entry.collection}/${filenamify(entry.rkey)}.json`;
84
84
+
const data = JSON.stringify(record, null, 2);
85
85
+
86
86
+
yield { filename, data, compress: "deflate" };
87
87
+
count++;
88
88
+
progress.update(`Unpacking records (${count} entries)`);
89
89
+
} catch {
90
90
+
// Skip entries with invalid data
91
91
+
}
92
92
+
}
93
93
+
};
94
94
+
95
95
+
// Stream entries directly to zip, then to file
96
96
+
let writeCount = 0;
97
97
+
for await (const chunk of zip(entryGenerator())) {
98
98
+
if (signal.aborted) {
99
99
+
await writable?.abort();
100
100
+
return;
101
101
+
}
102
102
+
if (writable === undefined) {
103
103
+
// User cancelled file picker
104
104
+
setPending(false);
105
105
+
return;
106
106
+
}
107
107
+
writeCount++;
108
108
+
if (writeCount % 100 !== 0) {
109
109
+
writable.write(chunk); // Fire and forget
110
110
+
} else {
111
111
+
await writable.write(chunk); // Await periodically to apply backpressure
112
112
+
}
113
113
+
}
114
114
+
115
115
+
if (signal.aborted) return;
116
116
+
117
117
+
if (writable === undefined) {
118
118
+
logger.warn(`CAR file has no records`);
119
119
+
setPending(false);
120
120
+
return;
121
121
+
}
122
122
+
123
123
+
logger.log(`${count} records extracted`);
124
124
+
125
125
+
{
126
126
+
using _progress = logger.progress(`Flushing writes...`);
127
127
+
await writable.close();
128
128
+
}
129
129
+
130
130
+
logger.log(`Finished!`);
131
131
+
} catch (err) {
132
132
+
if (signal.aborted) return;
133
133
+
console.error("Failed to unpack CAR file:", err);
134
134
+
logger.error(`Error: ${err}\nFile might be malformed, or might not be a CAR archive`);
135
135
+
} finally {
136
136
+
await repo?.dispose();
137
137
+
if (!signal.aborted) {
138
138
+
setPending(false);
139
139
+
}
140
140
+
}
141
141
+
};
142
142
+
143
143
+
const handleFileChange = (e: Event) => {
144
144
+
const input = e.target as HTMLInputElement;
145
145
+
const file = input.files?.[0];
146
146
+
if (file) {
147
147
+
unpackToZip(file);
148
148
+
}
149
149
+
input.value = "";
150
150
+
};
151
151
+
152
152
+
const handleDrop = (e: DragEvent) => {
153
153
+
e.preventDefault();
154
154
+
if (pending()) return;
155
155
+
const file = e.dataTransfer?.files?.[0];
156
156
+
if (file && (file.name.endsWith(".car") || file.type === "application/vnd.ipld.car")) {
157
157
+
unpackToZip(file);
158
158
+
}
159
159
+
};
160
160
+
161
161
+
const handleDragOver = (e: DragEvent) => {
162
162
+
e.preventDefault();
163
163
+
};
164
164
+
165
165
+
return (
166
166
+
<div class="flex w-full max-w-3xl flex-col gap-y-4 px-2">
167
167
+
<Title>Unpack archive - PDSls</Title>
168
168
+
<div class="flex flex-col gap-y-1">
169
169
+
<div class="flex items-center gap-2 text-lg">
170
170
+
<A
171
171
+
href="/car"
172
172
+
class="flex size-7 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
173
173
+
>
174
174
+
<span class="iconify lucide--arrow-left" />
175
175
+
</A>
176
176
+
<h1 class="font-semibold">Unpack archive</h1>
177
177
+
</div>
178
178
+
<p class="text-sm text-neutral-600 dark:text-neutral-400">
179
179
+
Upload a CAR file to extract all records into a ZIP archive.
180
180
+
</p>
181
181
+
</div>
182
182
+
183
183
+
<div
184
184
+
class="dark:bg-dark-300 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 p-8 transition-colors hover:border-neutral-400 dark:border-neutral-600 dark:hover:border-neutral-500"
185
185
+
onDrop={handleDrop}
186
186
+
onDragOver={handleDragOver}
187
187
+
>
188
188
+
<Show
189
189
+
when={!pending()}
190
190
+
fallback={
191
191
+
<div class="flex flex-col items-center gap-2">
192
192
+
<span class="iconify lucide--loader-circle animate-spin text-3xl text-neutral-400" />
193
193
+
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">
194
194
+
Processing...
195
195
+
</span>
196
196
+
</div>
197
197
+
}
198
198
+
>
199
199
+
<span class="iconify lucide--folder-archive text-3xl text-neutral-400" />
200
200
+
<div class="text-center">
201
201
+
<p class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
202
202
+
Drag and drop a CAR file here
203
203
+
</p>
204
204
+
<p class="text-xs text-neutral-500 dark:text-neutral-400">or</p>
205
205
+
</div>
206
206
+
<label class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-8 cursor-pointer items-center justify-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-3 py-1.5 text-sm shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800">
207
207
+
<input
208
208
+
type="file"
209
209
+
accept={isIOS ? undefined : ".car,application/vnd.ipld.car"}
210
210
+
onChange={handleFileChange}
211
211
+
class="hidden"
212
212
+
/>
213
213
+
<span class="iconify lucide--upload text-sm" />
214
214
+
Choose file
215
215
+
</label>
216
216
+
</Show>
217
217
+
</div>
218
218
+
219
219
+
<LoggerView logger={logger} />
220
220
+
</div>
221
221
+
);
222
222
+
};
-1
src/views/record.tsx
···
263
263
}
264
264
265
265
const lexiconDocs = Object.fromEntries(resolved);
266
266
-
console.log(lexiconDocs);
267
266
268
267
const validator = new RecordValidator(lexiconDocs, params.collection as Nsid);
269
268
validator.parse({