video.cerulea.blue/client/upload.tsx
2026-01-11 23:18:39 +00:00

249 lines
6.6 KiB
TypeScript

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();