(wip) upload form

This commit is contained in:
Charlotte Som 2026-01-11 23:18:39 +00:00
parent 49f21d869c
commit ea8f41a64b
8 changed files with 421 additions and 15 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/.env.local

View file

@ -5,3 +5,9 @@ esbuild.build({
outDir: "./web/dist",
watch: Deno.args.includes("--watch"),
});
esbuild.build({
in: ["./client/upload.tsx"],
outDir: "./web/dist",
watch: Deno.args.includes("--watch"),
});

249
client/upload.tsx Normal file
View file

@ -0,0 +1,249 @@
import * as oauth from "@atcute/oauth-browser-client";
import {
CompositeDidDocumentResolver,
CompositeHandleResolver,
DohJsonHandleResolver,
LocalActorResolver,
PlcDidDocumentResolver,
WebDidDocumentResolver,
WellKnownHandleResolver,
} from "@atcute/identity-resolver";
// import type { Infer as InferLexicon } from "@char/lexicon.ts";
// import type { ATProtoUniverse } from "@char/lexicon.ts/atproto";
// type UploadBlob = InferLexicon<ATProtoUniverse, "com.atproto.repo.uploadBlob">;
type UploadBlob = any;
const didDocumentResolver = new CompositeDidDocumentResolver({
methods: {
plc: new PlcDidDocumentResolver(),
web: new WebDidDocumentResolver(),
},
});
oauth.configureOAuth({
metadata: {
client_id: true
? "http://localhost" +
"?redirect_uri=" +
encodeURIComponent("http://127.0.0.1:4080/upload/") +
"&scope=" +
encodeURIComponent("atproto transition:generic")
: "https://video.cerulea.blue/upload/client-metadata.json",
redirect_uri: true ? "http://127.0.0.1:4080/upload/" : "https://video.cerulea.blue/upload/",
},
identityResolver: new LocalActorResolver({
handleResolver: new CompositeHandleResolver({
methods: {
dns: new DohJsonHandleResolver({
dohUrl: "https://mozilla.cloudflare-dns.com/dns-query",
}),
http: new WellKnownHandleResolver(),
},
}),
didDocumentResolver,
}),
});
type Did = `did:${"web" | "plc"}:${string}`;
type ActorIdentifier = Did | `${string}.${string}`;
const login = async (identifier: ActorIdentifier) => {
const authUrl = await oauth.createAuthorizationUrl({
target: { type: "account", identifier: identifier },
scope: "atproto transition:generic",
});
await new Promise(resolve => setTimeout(resolve, 200));
globalThis.location.assign(authUrl);
};
const loginForm = (): HTMLFormElement => {
const handleField = (
<input
type="text"
name="handle"
title="Enter a valid handle or DID"
placeholder="my-handle.example.com"
pattern="(did:.*)|(.*\..*)"
required
/>
) as HTMLInputElement;
const form = (
<form>
{handleField}
<button type="submit">Login</button>
</form>
) as HTMLFormElement;
form.addEventListener("submit", e => {
e.preventDefault();
if (handleField.value) {
login(handleField.value as ActorIdentifier);
}
});
return form;
};
const auth = async (): Promise<oauth.OAuthUserAgent | undefined> => {
for (const sessionId of oauth.listStoredSessions()) {
try {
const session = await oauth.getSession(sessionId);
const agent = new oauth.OAuthUserAgent(session);
return agent;
} catch {
continue;
}
}
const params = new URLSearchParams(globalThis.location.hash.slice(1));
if (params.has("state") && params.has("code")) {
history.replaceState(null, "", location.pathname + location.search);
const { session } = await oauth.finalizeAuthorization(params);
const agent = new oauth.OAuthUserAgent(session);
return agent;
}
return undefined;
};
const uploadForm = (agent: oauth.OAuthUserAgent): HTMLFormElement => {
const uploadField = (<input type="file" accept="video/*" required />) as HTMLInputElement;
const preview = (<video controls />) as HTMLVideoElement;
preview.volume = 0.66;
const uploadButton = (<button type="submit">Upload</button>) as HTMLButtonElement;
const slugField = (
<input
type="text"
name="slug"
placeholder="slug (optional)"
title="Optional: Custom identifier for your video"
/>
) as HTMLInputElement;
const titleField = (
<input
type="text"
name="title"
placeholder="title (optional)"
title="Optional: Title for your video"
/>
) as HTMLInputElement;
const descriptionField = (
<textarea
name="description"
placeholder="description (optional)"
rows={4}
title="Optional: Description of your video"
/>
) as HTMLTextAreaElement;
const uploadStatus = <span style={{ display: "none" }}>Uploading</span>;
uploadField.addEventListener("change", e => {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
const file = target.files[0];
preview.src = URL.createObjectURL(file);
}
});
const uploadWidget = (
<div>
{uploadButton}
{uploadStatus}
</div>
);
const form = (
<form>
{uploadField}
{slugField}
{titleField}
{descriptionField}
<div id="player">{preview}</div>
{uploadWidget}
</form>
) as HTMLFormElement;
form.addEventListener("submit", async e => {
e.preventDefault();
const file = uploadField.files?.[0];
if (!file) return;
uploadButton.style.display = "none";
uploadStatus.style.display = "inline";
const uploadBlobRes: UploadBlob["output"] = await agent
.handle("/xrpc/com.atproto.repo.uploadBlob", {
method: "POST",
headers: { "content-type": file.type },
body: file,
})
.then(r => r.json());
const blobRef = uploadBlobRes.blob;
const cid = "$type" in blobRef ? blobRef.ref.$link : blobRef.cid;
console.log({ cid });
uploadStatus.textContent = "Writing record…";
const putRecordRes = await agent
.handle("/xrpc/com.atproto.repo.createRecord", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
repo: agent.sub,
collection: "blue.cerulea.video.video",
rkey: slugField.value || undefined,
record: {
$type: "blue.cerulea.video.video",
title: titleField.value || undefined,
description: descriptionField.value || undefined,
video: blobRef,
},
validate: false,
}),
})
.then(r => r.json());
const [repo, _collection, rkey] = putRecordRes.uri.substring("at://".length).split("/");
globalThis.location.replace(`/${repo}/video/${rkey}`);
});
return form;
};
const resolveHandle = async (did: string) => {
try {
const doc = await didDocumentResolver.resolve(did as Did);
return (
doc.alsoKnownAs?.find(it => it.startsWith("at://"))?.substring("at://".length) ?? did
);
} catch {
return did;
}
};
const main = async () => {
const mainElem = document.querySelector("main")!;
const agent = await auth();
if (!agent) {
mainElem.appendChild(loginForm());
return;
}
const handle = await resolveHandle(agent.session.info.sub);
mainElem.appendChild(
<p>
signed in as <strong>{handle}</strong>
</p>,
);
mainElem.appendChild(uploadForm(agent));
};
main();

View file

@ -5,8 +5,10 @@
"client:watch": "deno run -A ./_client_build.ts --watch"
},
"imports": {
"@atcute/client": "npm:@atcute/client@^4.2.1",
"@atcute/identity": "npm:@atcute/identity@^1.0.2",
"@atcute/identity-resolver": "npm:@atcute/identity-resolver@^1.1.3",
"@atcute/oauth-browser-client": "npm:@atcute/oauth-browser-client@^2.0.3",
"@char/aftercare": "jsr:@char/aftercare@^0.4.2",
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
"@std/fs": "jsr:@std/fs@^1.0.17",

View file

@ -32,8 +32,10 @@
"jsr:@std/path@^1.0.9": "1.1.0",
"jsr:@std/path@^1.1.0": "1.1.0",
"jsr:@std/streams@^1.0.9": "1.0.9",
"npm:@atcute/identity-resolver@^1.1.3": "1.1.3_@atcute+identity@1.0.2",
"npm:@atcute/identity@^1.0.2": "1.0.2",
"npm:@atcute/client@^4.2.1": "4.2.1",
"npm:@atcute/identity-resolver@^1.1.3": "1.2.2_@atcute+identity@1.1.3",
"npm:@atcute/identity@^1.0.2": "1.1.3",
"npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3",
"npm:@zod/mini@^4.0.0-beta.20250505T195954": "4.0.0-beta.20250505T195954",
"npm:esbuild@0.24": "0.24.2",
"npm:urlpattern-polyfill@10": "10.0.0"
@ -164,8 +166,15 @@
}
},
"npm": {
"@atcute/identity-resolver@1.1.3_@atcute+identity@1.0.2": {
"integrity": "sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==",
"@atcute/client@4.2.1": {
"integrity": "sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==",
"dependencies": [
"@atcute/identity",
"@atcute/lexicons"
]
},
"@atcute/identity-resolver@1.2.2_@atcute+identity@1.1.3": {
"integrity": "sha512-eUh/UH4bFvuXS0X7epYCeJC/kj4rbBXfSRumLEH4smMVwNOgTo7cL/0Srty+P/qVPoZEyXdfEbS0PHJyzoXmHw==",
"dependencies": [
"@atcute/identity",
"@atcute/lexicons",
@ -173,27 +182,56 @@
"@badrap/valita"
]
},
"@atcute/identity@1.0.2": {
"integrity": "sha512-SrDPHuEarEHj9bx7NfYn7DYG6kIgJIMRU581iOCIaVaiZ1WhE9D8QxTxeYG/rbGNSa85E891ECp1sQcKiBN0kg==",
"@atcute/identity@1.1.3": {
"integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==",
"dependencies": [
"@atcute/lexicons",
"@badrap/valita"
]
},
"@atcute/lexicons@1.0.4": {
"integrity": "sha512-VyGJuGKAIeE+71UT9aSMJJdvfxfXsdsGMG9acv9rnGT7enVy4TD5XoYQy7TCHZ4YpxXzuHkqjyAqBz95c4WkRg==",
"@atcute/lexicons@1.2.6": {
"integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==",
"dependencies": [
"@atcute/uint8array",
"@atcute/util-text",
"@standard-schema/spec",
"esm-env"
]
},
"@atcute/util-fetch@1.0.1": {
"integrity": "sha512-Clc0E/5ufyGBVfYBUwWNlHONlZCoblSr4Ho50l1LhmRPGB1Wu/AQ9Sz+rsBg7fdaW/auve8ulmwhRhnX2cGRow==",
"@atcute/multibase@1.1.6": {
"integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==",
"dependencies": [
"@atcute/uint8array"
]
},
"@atcute/oauth-browser-client@2.0.3_@atcute+identity@1.1.3": {
"integrity": "sha512-rzUjwhjE4LRRKdQnCFQag/zXRZMEAB1hhBoLfnoQuHwWbmDUCL7fzwC3jRhDPp3om8XaYNDj8a/iqRip0wRqoQ==",
"dependencies": [
"@atcute/client",
"@atcute/identity-resolver",
"@atcute/lexicons",
"@atcute/multibase",
"@atcute/uint8array",
"nanoid"
]
},
"@atcute/uint8array@1.0.6": {
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
},
"@atcute/util-fetch@1.0.5": {
"integrity": "sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==",
"dependencies": [
"@badrap/valita"
]
},
"@badrap/valita@0.4.5": {
"integrity": "sha512-4QwGbuhh/JesHRQj79mO/l37PvJj4l/tlAu7+S1n4h47qwaNpZ0WDvIwUGLYUsdi9uQ5UPpiG9wb1Wm3XUFBUQ=="
"@atcute/util-text@0.0.1": {
"integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==",
"dependencies": [
"unicode-segmenter"
]
},
"@badrap/valita@0.4.6": {
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
},
"@esbuild/aix-ppc64@0.24.2": {
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
@ -320,6 +358,9 @@
"os": ["win32"],
"cpu": ["x64"]
},
"@standard-schema/spec@1.1.0": {
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"@zod/core@0.11.6": {
"integrity": "sha512-03Bv82fFSfjDAvMfdHHdGSS6SOJs0iCcJlWJv1kJHRtoTT02hZpyip/2Lk6oo4l4FtjuwTrsEQTwg/LD8I7dJA=="
},
@ -327,7 +368,8 @@
"integrity": "sha512-ioybPtU4w4TqwHvJv0gkAiYNaBkZ/BaGHBpK7viCIRSE8BiiZucVZ8vS0YE04Qy1R120nAnFy1d+tD9ByMO0yw==",
"dependencies": [
"@zod/core"
]
],
"deprecated": true
},
"esbuild@0.24.2": {
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
@ -364,6 +406,13 @@
"esm-env@1.2.2": {
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
},
"nanoid@5.1.6": {
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"bin": true
},
"unicode-segmenter@0.14.5": {
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="
},
"urlpattern-polyfill@10.0.0": {
"integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="
}
@ -375,8 +424,10 @@
"jsr:@std/fs@^1.0.17",
"jsr:@std/http@^1.0.17",
"jsr:@std/path@^1.1.0",
"npm:@atcute/client@^4.2.1",
"npm:@atcute/identity-resolver@^1.1.3",
"npm:@atcute/identity@^1.0.2",
"npm:@atcute/oauth-browser-client@^2.0.3",
"npm:@zod/mini@^4.0.0-beta.20250505T195954"
]
}

View file

@ -12,8 +12,7 @@
--color-text: var(--color-fg);
--alpha-text: 0.9;
--font-sans:
Inter, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", sans-serif;
--font-sans: Inter, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", sans-serif;
background-color: rgb(var(--color-bg) / 1);
color: rgb(var(--color-fg) / var(--alpha-text));
@ -42,9 +41,86 @@ main {
#player {
video {
width: 100%;
max-height: 80vh;
background: black;
}
.description {
white-space: pre-wrap;
}
}
form {
display: flex;
flex-direction: column;
gap: 1em;
}
:is(input, button, textarea) {
appearance: none;
outline: none;
font-size: 1rem;
margin: 0;
padding: 0.75em;
border: 1px solid rgb(var(--color-fg) / 0.2);
border-radius: 8px;
transition-property: color, border-color, background-color;
font-family: var(--font-sans);
}
input:is([type="text"], [type="url"], [type="search"], [type="file"]),
textarea {
background-color: rgb(var(--color-bg) / 0.5);
color: rgb(var(--color-fg) / var(--alpha-text));
&::placeholder {
color: rgb(var(--color-fg) / 0.4);
}
&:focus {
border-color: rgb(var(--color-accent) / 1);
color: rgb(var(--color-fg) / 0.95);
}
&:focus::placeholder {
color: rgb(var(--color-fg) / 0.6);
}
}
textarea {
resize: vertical;
}
button,
input[type="file"] {
display: inline-block;
background-color: transparent;
color: rgb(var(--color-accent) / var(--alpha-text));
cursor: pointer;
&:hover {
border-color: rgb(var(--color-accent) / 0.5);
background-color: rgb(var(--color-accent) / 0.1);
}
}
input[type="file"] {
color: unset;
&::file-selector-button {
appearance: none;
outline: none;
padding: 0;
margin-right: 1rem;
background: none;
border: none;
border-radius: 0;
color: rgb(var(--color-accent) / var(--alpha-text));
cursor: pointer;
font-weight: normal;
font-family: inherit;
font-size: inherit;
}
&::file-selector-button:hover {
background: none;
}
}

View file

@ -0,0 +1,12 @@
{
"client_id": "https://video.cerulea.blue/upload/client-metadata.json",
"client_name": "Cerulea Video",
"client_uri": "https://video.cerulea.blue",
"redirect_uris": ["https://video.cerulea.blue/upload/"],
"scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}

9
web/upload/index.html Normal file
View file

@ -0,0 +1,9 @@
<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>upload a video | video.cerulea.blue</title>
<link rel="stylesheet" href="/css/styles.css" />
<main></main>
<script src="/dist/upload.js" type="module"></script>