+1
package.json
+1
package.json
+18
pnpm-lock.yaml
+18
pnpm-lock.yaml
···
11
'@atcute/client':
12
specifier: ^2.0.4
13
version: 2.0.4
14
'@solidjs/router':
15
specifier: ^0.15.1
16
version: 0.15.1(solid-js@1.9.3)
···
60
61
'@atcute/client@2.0.4':
62
resolution: {integrity: sha512-bKA6KEOmrdhU2CDRNp13M4WyKN0EdrVLKJffzPo62ANSTMacz5hRJhmvQYwuo7BZSGIoDql4sH+QR6Xbk3DERg==}
63
64
'@babel/code-frame@7.26.2':
65
resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
···
914
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
915
hasBin: true
916
917
node-fetch-native@1.6.4:
918
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
919
···
1203
'@antfu/utils@0.7.10': {}
1204
1205
'@atcute/client@2.0.4': {}
1206
1207
'@babel/code-frame@7.26.2':
1208
dependencies:
···
2030
ms@2.1.3: {}
2031
2032
nanoid@3.3.7: {}
2033
2034
node-fetch-native@1.6.4: {}
2035
···
11
'@atcute/client':
12
specifier: ^2.0.4
13
version: 2.0.4
14
+
'@atcute/oauth-browser-client':
15
+
specifier: ^1.0.5
16
+
version: 1.0.5
17
'@solidjs/router':
18
specifier: ^0.15.1
19
version: 0.15.1(solid-js@1.9.3)
···
63
64
'@atcute/client@2.0.4':
65
resolution: {integrity: sha512-bKA6KEOmrdhU2CDRNp13M4WyKN0EdrVLKJffzPo62ANSTMacz5hRJhmvQYwuo7BZSGIoDql4sH+QR6Xbk3DERg==}
66
+
67
+
'@atcute/oauth-browser-client@1.0.5':
68
+
resolution: {integrity: sha512-UUs2WFMh22rXOapRM848WfWtvgaxV/ji0tEupFrrBYe2i+/UlwhXcphlqdwm43LBsFtMWtV1Xsy2zmnItf0Akg==}
69
70
'@babel/code-frame@7.26.2':
71
resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
···
920
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
921
hasBin: true
922
923
+
nanoid@5.0.8:
924
+
resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==}
925
+
engines: {node: ^18 || >=20}
926
+
hasBin: true
927
+
928
node-fetch-native@1.6.4:
929
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
930
···
1214
'@antfu/utils@0.7.10': {}
1215
1216
'@atcute/client@2.0.4': {}
1217
+
1218
+
'@atcute/oauth-browser-client@1.0.5':
1219
+
dependencies:
1220
+
'@atcute/client': 2.0.4
1221
+
nanoid: 5.0.8
1222
1223
'@babel/code-frame@7.26.2':
1224
dependencies:
···
2046
ms@2.1.3: {}
2047
2048
nanoid@3.3.7: {}
2049
+
2050
+
nanoid@5.0.8: {}
2051
2052
node-fetch-native@1.6.4: {}
2053
+12
public/client-metadata.json
+12
public/client-metadata.json
···
···
1
+
{
2
+
"client_id": "https://pdsls.dev/client-metadata.json",
3
+
"client_name": "pdsls",
4
+
"client_uri": "https://pdsls.dev",
5
+
"redirect_uris": ["https://pdsls.dev/"],
6
+
"scope": "atproto transition:generic",
7
+
"grant_types": ["authorization_code", "refresh_token"],
8
+
"response_types": ["code"],
9
+
"token_endpoint_auth_method": "none",
10
+
"application_type": "web",
11
+
"dpop_bound_access_tokens": true
12
+
}
+92
-5
src/App.tsx
src/main.tsx
+92
-5
src/App.tsx
src/main.tsx
···
1
-
import { createSignal, onMount, For, Show, type Component } from "solid-js";
2
import { CredentialManager, XRPC } from "@atcute/client";
3
import {
4
ComAtprotoRepoDescribeRepo,
···
15
useLocation,
16
useParams,
17
} from "@solidjs/router";
18
-
import { JSONValue } from "./lib/json.jsx";
19
import {
20
AiFillGithub,
21
Bluesky,
···
23
BsClipboardCheck,
24
TbMoonStar,
25
TbSun,
26
-
} from "./lib/svg.jsx";
27
import { authenticate_post } from "public-transport";
28
29
let rpc = new XRPC({
30
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
···
109
const RecordView: Component = () => {
110
const params = useParams();
111
const [record, setRecord] = createSignal<ComAtprotoRepoGetRecord.Output>();
112
113
onMount(async () => {
114
setNotice("Loading...");
115
setPDS(params.pds);
116
let pds =
···
131
}
132
});
133
134
const getRecord = query(
135
(repo: string, collection: string, rkey: string) =>
136
rpc.get("com.atproto.repo.getRecord", {
···
139
"getRecord",
140
);
141
142
return (
143
<Show when={record()}>
144
<div class="overflow-y-auto pl-4">
145
<JSONValue data={record() as any} repo={record()!.uri.split("/")[2]} />
146
</div>
···
387
setNotice("");
388
389
return (
390
-
<div class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100">
391
<div class="mb-2 flex w-[20rem] items-center">
392
-
<div class="basis-1/3">
393
<div
394
class="w-fit cursor-pointer"
395
onclick={() => {
···
404
<TbMoonStar class="size-6" />
405
: <TbSun class="size-6" />}
406
</div>
407
</div>
408
<div class="basis-1/3 text-center font-mono text-xl font-bold">
409
<A href="/" class="hover:underline">
···
422
</a>
423
</div>
424
</div>
425
<div class="mb-5 flex max-w-full flex-col items-center text-pretty lg:max-w-screen-lg">
426
<form
427
class="flex flex-col items-center gap-y-1"
···
1
+
import {
2
+
createSignal,
3
+
onMount,
4
+
For,
5
+
Show,
6
+
type Component,
7
+
onCleanup,
8
+
createEffect,
9
+
} from "solid-js";
10
import { CredentialManager, XRPC } from "@atcute/client";
11
import {
12
ComAtprotoRepoDescribeRepo,
···
23
useLocation,
24
useParams,
25
} from "@solidjs/router";
26
+
import { JSONValue } from "./components/json.jsx";
27
import {
28
AiFillGithub,
29
Bluesky,
···
31
BsClipboardCheck,
32
TbMoonStar,
33
TbSun,
34
+
} from "./components/svg.jsx";
35
import { authenticate_post } from "public-transport";
36
+
import { agent, loginState, LoginStatus } from "./components/login.jsx";
37
38
let rpc = new XRPC({
39
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
···
118
const RecordView: Component = () => {
119
const params = useParams();
120
const [record, setRecord] = createSignal<ComAtprotoRepoGetRecord.Output>();
121
+
const [modal, setModal] = createSignal<HTMLDialogElement>();
122
+
const [open, setOpen] = createSignal(false);
123
+
124
+
let clickEvent = (event: MouseEvent) => {
125
+
if (modal() && event.target == modal()) setOpen(false);
126
+
};
127
+
let keyEvent = (event: KeyboardEvent) => {
128
+
if (modal() && event.key == "Escape") setOpen(false);
129
+
};
130
131
onMount(async () => {
132
+
window.addEventListener("click", clickEvent);
133
+
window.addEventListener("keydown", keyEvent);
134
setNotice("Loading...");
135
setPDS(params.pds);
136
let pds =
···
151
}
152
});
153
154
+
onCleanup(() => {
155
+
window.removeEventListener("click", clickEvent);
156
+
window.removeEventListener("keydown", keyEvent);
157
+
});
158
+
159
const getRecord = query(
160
(repo: string, collection: string, rkey: string) =>
161
rpc.get("com.atproto.repo.getRecord", {
···
164
"getRecord",
165
);
166
167
+
const deleteRecord = action(async () => {
168
+
rpc = new XRPC({ handler: agent });
169
+
rpc.call("com.atproto.repo.deleteRecord", {
170
+
data: {
171
+
repo: params.repo,
172
+
collection: params.collection,
173
+
rkey: params.rkey,
174
+
},
175
+
});
176
+
throw redirect(`/at/${params.repo}/${params.collection}`);
177
+
});
178
+
179
+
createEffect(() => {
180
+
if (open()) document.body.style.overflow = "hidden";
181
+
else document.body.style.overflow = "auto";
182
+
});
183
+
184
return (
185
<Show when={record()}>
186
+
<Show when={loginState() && agent.sub === params.repo}>
187
+
<div class="flex w-full justify-center">
188
+
<Show when={open()}>
189
+
<dialog
190
+
ref={setModal}
191
+
class="fixed left-0 top-0 z-[2] flex h-screen w-screen items-center justify-center bg-transparent font-sans"
192
+
>
193
+
<div class="dark:bg-dark-400 rounded-md border border-slate-900 bg-slate-100 p-4 text-slate-900 dark:border-slate-100 dark:text-slate-100">
194
+
<h3 class="text-lg font-bold">Delete this record?</h3>
195
+
<form action={deleteRecord} method="post">
196
+
<div class="mt-2 inline-flex gap-2">
197
+
<button
198
+
onclick={() => setOpen(false)}
199
+
class="dark:bg-dark-900 dark:hover:bg-dark-800 rounded-lg bg-white px-2.5 py-1.5 text-sm font-bold hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-slate-700 dark:focus:ring-slate-300"
200
+
>
201
+
Cancel
202
+
</button>
203
+
<button
204
+
type="submit"
205
+
class="rounded-lg bg-red-500 px-2.5 py-1.5 text-sm font-bold text-slate-100 hover:bg-red-400 focus:outline-none focus:ring-2 focus:ring-slate-700 dark:bg-red-600 dark:hover:bg-red-500 dark:focus:ring-slate-300"
206
+
>
207
+
Delete
208
+
</button>
209
+
</div>
210
+
</form>
211
+
</div>
212
+
</dialog>
213
+
</Show>
214
+
<button
215
+
onclick={() => setOpen(true)}
216
+
class="rounded-lg bg-red-500 px-2.5 py-1.5 font-sans text-sm font-bold text-slate-100 hover:bg-red-400 focus:outline-none focus:ring-2 focus:ring-slate-700 dark:bg-red-600 dark:hover:bg-red-500 dark:focus:ring-slate-300"
217
+
>
218
+
Delete
219
+
</button>
220
+
</div>
221
+
</Show>
222
<div class="overflow-y-auto pl-4">
223
<JSONValue data={record() as any} repo={record()!.uri.split("/")[2]} />
224
</div>
···
465
setNotice("");
466
467
return (
468
+
<div
469
+
id="main"
470
+
class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100"
471
+
>
472
<div class="mb-2 flex w-[20rem] items-center">
473
+
<div class="flex basis-1/3 gap-x-2">
474
<div
475
class="w-fit cursor-pointer"
476
onclick={() => {
···
485
<TbMoonStar class="size-6" />
486
: <TbSun class="size-6" />}
487
</div>
488
+
<Show when={!loginState()}>
489
+
<div>
490
+
<A href="/login">Login</A>
491
+
</div>
492
+
</Show>
493
</div>
494
<div class="basis-1/3 text-center font-mono text-xl font-bold">
495
<A href="/" class="hover:underline">
···
508
</a>
509
</div>
510
</div>
511
+
<LoginStatus />
512
<div class="mb-5 flex max-w-full flex-col items-center text-pretty lg:max-w-screen-lg">
513
<form
514
class="flex flex-col items-center gap-y-1"
+159
src/components/login.tsx
+159
src/components/login.tsx
···
···
1
+
import { createSignal, onMount, Show, type Component } from "solid-js";
2
+
import {
3
+
configureOAuth,
4
+
createAuthorizationUrl,
5
+
finalizeAuthorization,
6
+
getSession,
7
+
OAuthUserAgent,
8
+
resolveFromIdentity,
9
+
type Session,
10
+
} from "@atcute/oauth-browser-client";
11
+
import { At } from "@atcute/client/lexicons";
12
+
13
+
configureOAuth({
14
+
metadata: {
15
+
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
16
+
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
17
+
},
18
+
});
19
+
20
+
const [loginState, setLoginState] = createSignal(false);
21
+
const [notice, setNotice] = createSignal("");
22
+
const [handle, setHandle] = createSignal("");
23
+
let agent: OAuthUserAgent;
24
+
25
+
const resolveDid = async (did: string) => {
26
+
const res = await fetch(
27
+
did.startsWith("did:web") ?
28
+
`https://${did.split(":")[2]}/.well-known/did.json`
29
+
: "https://plc.directory/" + did,
30
+
);
31
+
32
+
return res
33
+
.json()
34
+
.then((doc) => {
35
+
for (const alias of doc.alsoKnownAs) {
36
+
if (alias.includes("at://")) {
37
+
return alias.split("//")[1];
38
+
}
39
+
}
40
+
})
41
+
.catch(() => "");
42
+
};
43
+
44
+
const Login: Component = () => {
45
+
const [loginInput, setLoginInput] = createSignal("");
46
+
47
+
const loginBsky = async (handle: string) => {
48
+
try {
49
+
setNotice(`Resolving your identity...`);
50
+
const resolved = await resolveFromIdentity(handle);
51
+
52
+
setNotice(`Contacting your data server...`);
53
+
const authUrl = await createAuthorizationUrl({
54
+
scope: import.meta.env.VITE_OAUTH_SCOPE,
55
+
...resolved,
56
+
});
57
+
58
+
setNotice(`Redirecting...`);
59
+
await new Promise((resolve) => setTimeout(resolve, 250));
60
+
61
+
location.assign(authUrl);
62
+
} catch {
63
+
setNotice("Error during OAuth login");
64
+
}
65
+
};
66
+
67
+
return (
68
+
<div class="mt-2 font-sans">
69
+
<form class="flex flex-col" onsubmit={(e) => e.preventDefault()}>
70
+
<div class="w-full">
71
+
<label for="handle" class="ml-0.5 text-sm">
72
+
Handle
73
+
</label>
74
+
</div>
75
+
<div class="flex gap-x-2">
76
+
<input
77
+
type="text"
78
+
id="handle"
79
+
placeholder="user.bsky.social"
80
+
class="dark:bg-dark-100 rounded-lg border border-gray-400 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-gray-300"
81
+
onInput={(e) => setLoginInput(e.currentTarget.value)}
82
+
/>
83
+
<button
84
+
onclick={() => loginBsky(loginInput())}
85
+
class="dark:bg-dark-700 dark:hover:bg-dark-800 rounded-lg border border-gray-400 bg-white px-2.5 py-1.5 text-sm font-bold hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-gray-300"
86
+
>
87
+
Login
88
+
</button>
89
+
</div>
90
+
</form>
91
+
<Show when={notice()}>
92
+
<div class="mt-2">{notice()}</div>
93
+
</Show>
94
+
</div>
95
+
);
96
+
};
97
+
98
+
const LoginStatus: Component = () => {
99
+
onMount(async () => {
100
+
setNotice("Loading...");
101
+
102
+
const init = async (): Promise<Session | undefined> => {
103
+
const params = new URLSearchParams(location.hash.slice(1));
104
+
105
+
if (params.has("state") && (params.has("code") || params.has("error"))) {
106
+
history.replaceState(null, "", location.pathname + location.search);
107
+
108
+
const session = await finalizeAuthorization(params);
109
+
const did = session.info.sub;
110
+
111
+
localStorage.setItem("lastSignedIn", did);
112
+
return session;
113
+
} else {
114
+
const lastSignedIn = localStorage.getItem("lastSignedIn");
115
+
116
+
if (lastSignedIn) {
117
+
try {
118
+
return await getSession(lastSignedIn as At.DID);
119
+
} catch (err) {
120
+
localStorage.removeItem("lastSignedIn");
121
+
throw err;
122
+
}
123
+
}
124
+
}
125
+
};
126
+
127
+
const session = await init().catch(() => {});
128
+
129
+
if (session) {
130
+
agent = new OAuthUserAgent(session);
131
+
setHandle(await resolveDid(agent.sub));
132
+
setLoginState(true);
133
+
}
134
+
135
+
setNotice("");
136
+
});
137
+
138
+
const logoutBsky = async () => {
139
+
setLoginState(false);
140
+
await agent.signOut();
141
+
};
142
+
143
+
return (
144
+
<Show when={loginState() && handle()}>
145
+
<div>
146
+
Logged in as @{handle()}
147
+
<a
148
+
href=""
149
+
class="ml-2 text-red-500 dark:text-red-400"
150
+
onclick={() => logoutBsky()}
151
+
>
152
+
Logout
153
+
</a>
154
+
</div>
155
+
</Show>
156
+
);
157
+
};
158
+
159
+
export { Login, LoginStatus, loginState, agent };
src/index.css
src/styles/index.css
src/index.css
src/styles/index.css
+5
-3
src/index.tsx
+5
-3
src/index.tsx
···
1
/* @refresh reload */
2
import { render } from "solid-js/web";
3
import "virtual:uno.css";
4
-
import "./tailwind-compat.css";
5
-
import "./index.css";
6
import { Route, Router } from "@solidjs/router";
7
import {
8
Layout,
···
11
RecordView,
12
RepoView,
13
Home,
14
-
} from "./App.tsx";
15
16
render(
17
() => (
18
<Router root={Layout}>
19
<Route path="/" component={Home} />
20
<Route path="/:pds" component={PdsView} />
21
<Route path="/:pds/:repo" component={RepoView} />
22
<Route path="/:pds/:repo/:collection" component={CollectionView} />
···
1
/* @refresh reload */
2
import { render } from "solid-js/web";
3
import "virtual:uno.css";
4
+
import "./styles/tailwind-compat.css";
5
+
import "./styles/index.css";
6
import { Route, Router } from "@solidjs/router";
7
import {
8
Layout,
···
11
RecordView,
12
RepoView,
13
Home,
14
+
} from "./main.tsx";
15
+
import { Login } from "./components/login.tsx";
16
17
render(
18
() => (
19
<Router root={Layout}>
20
<Route path="/" component={Home} />
21
+
<Route path="/login" component={Login} />
22
<Route path="/:pds" component={PdsView} />
23
<Route path="/:pds/:repo" component={RepoView} />
24
<Route path="/:pds/:repo/:collection" component={CollectionView} />
src/lib/json.tsx
src/components/json.tsx
src/lib/json.tsx
src/components/json.tsx
src/lib/svg.tsx
src/components/svg.tsx
src/lib/svg.tsx
src/components/svg.tsx
src/lib/video-player.tsx
src/components/video-player.tsx
src/lib/video-player.tsx
src/components/video-player.tsx
src/tailwind-compat.css
src/styles/tailwind-compat.css
src/tailwind-compat.css
src/styles/tailwind-compat.css
+14
src/vite-env.d.ts
+14
src/vite-env.d.ts
···
···
1
+
/// <reference types="vite/client" />
2
+
/// <reference types="@atcute/bluesky/lexicons" />
3
+
4
+
interface ImportMetaEnv {
5
+
readonly VITE_DEV_SERVER_PORT?: string;
6
+
readonly VITE_CLIENT_URI: string;
7
+
readonly VITE_OAUTH_CLIENT_ID: string;
8
+
readonly VITE_OAUTH_REDIRECT_URL: string;
9
+
readonly VITE_OAUTH_SCOPE: string;
10
+
}
11
+
12
+
interface ImportMeta {
13
+
readonly env: ImportMetaEnv;
14
+
}
+33
-1
vite.config.ts
+33
-1
vite.config.ts
···
2
import solidPlugin from "vite-plugin-solid";
3
import wasm from "vite-plugin-wasm";
4
import UnoCSS from "unocss/vite";
5
6
const SERVER_HOST = "127.0.0.1";
7
const SERVER_PORT = 13213;
8
9
export default defineConfig({
10
-
plugins: [UnoCSS(), wasm(), solidPlugin()],
11
server: {
12
host: SERVER_HOST,
13
port: SERVER_PORT,
···
2
import solidPlugin from "vite-plugin-solid";
3
import wasm from "vite-plugin-wasm";
4
import UnoCSS from "unocss/vite";
5
+
import metadata from "./public/client-metadata.json";
6
7
const SERVER_HOST = "127.0.0.1";
8
const SERVER_PORT = 13213;
9
10
export default defineConfig({
11
+
plugins: [
12
+
UnoCSS(),
13
+
wasm(),
14
+
solidPlugin(),
15
+
// Injects OAuth-related variables
16
+
{
17
+
name: "oauth",
18
+
config(_conf, { command }) {
19
+
if (command === "build") {
20
+
process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id;
21
+
process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0];
22
+
} else {
23
+
const redirectUri = ((): string => {
24
+
const url = new URL(metadata.redirect_uris[0]);
25
+
return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`;
26
+
})();
27
+
28
+
const clientId =
29
+
`http://localhost` +
30
+
`?redirect_uri=${encodeURIComponent(redirectUri)}` +
31
+
`&scope=${encodeURIComponent(metadata.scope)}`;
32
+
33
+
process.env.VITE_DEV_SERVER_PORT = "" + SERVER_PORT;
34
+
process.env.VITE_OAUTH_CLIENT_ID = clientId;
35
+
process.env.VITE_OAUTH_REDIRECT_URL = redirectUri;
36
+
}
37
+
38
+
process.env.VITE_CLIENT_URI = metadata.client_uri;
39
+
process.env.VITE_OAUTH_SCOPE = metadata.scope;
40
+
},
41
+
},
42
+
],
43
server: {
44
host: SERVER_HOST,
45
port: SERVER_PORT,