···11+import * as URI from "fast-uri";
22+import { Client, ok, simpleFetchHandler } from "@atcute/client";
33+import {
44+ CompositeDidDocumentResolver,
55+ LocalActorResolver,
66+ PlcDidDocumentResolver,
77+ WebDidDocumentResolver,
88+ XrpcHandleResolver,
99+} from "@atcute/identity-resolver";
1010+1111+import * as CID from "@common/cid.js";
1212+import { effect } from "@common/signal.js";
1313+1414+/**
1515+ * @import {SignalReader} from "@common/signal.d.ts"
1616+ */
1717+1818+/**
1919+ * @typedef {{ html?: string; uri?: string; cid?: string; id: string; name: string; $type: string }} LoadableItem
2020+ */
2121+2222+/**
2323+ * @typedef {object} LoaderConfig
2424+ * @property {string} $type - The atproto $type
2525+ * @property {string} label - Human-readable label for error messages (e.g. "Facet", "Theme")
2626+ * @property {() => { collection: SignalReader<LoadableItem[]>; state: SignalReader<"loading" | "loaded" | "sleeping"> }} source - The collection source
2727+ * @property {(item: LoadableItem) => void} render - Renders the loaded item
2828+ */
2929+3030+/**
3131+ * Sets up the full loader effect: reads URL params, resolves the item
3232+ * from the collection or creates a temporary one, ensures HTML is loaded,
3333+ * and calls the render callback.
3434+ *
3535+ * @param {LoaderConfig} config
3636+ */
3737+export function createLoader(config) {
3838+ const docUrl = new URL(document.location.href);
3939+4040+ const id = docUrl.searchParams.get("id");
4141+ const cid = docUrl.searchParams.get("cid");
4242+ const name = docUrl.searchParams.get("name");
4343+ const uri = docUrl.searchParams.get("uri");
4444+ const path = docUrl.searchParams.get("path");
4545+4646+ const containerNull = document.querySelector("#container");
4747+ if (!containerNull) throw new Error("Container not found");
4848+4949+ const container = /** @type {HTMLDivElement} */ (containerNull);
5050+5151+ /** @type {string | null} */
5252+ let loadedCid = null;
5353+5454+ /** @type {string | null} */
5555+ let loader = null;
5656+5757+ effect(async () => {
5858+ /** @type {LoadableItem | undefined} */
5959+ let item = undefined;
6060+6161+ if (path) {
6262+ item = {
6363+ $type: config.$type,
6464+ id: crypto.randomUUID(),
6565+ name: "temporary",
6666+ uri: `diffuse://${path}`,
6767+ };
6868+6969+ loader = "path";
7070+ } else if (uri) {
7171+ item = {
7272+ $type: config.$type,
7373+ id: crypto.randomUUID(),
7474+ name: "temporary",
7575+ uri,
7676+ };
7777+7878+ loader = "uri";
7979+ } else {
8080+ const source = config.source();
8181+ const collection = source.collection();
8282+ console.log(source.state(), collection);
8383+ if (source.state() !== "loaded") return;
8484+8585+ if (id) {
8686+ item = collection.find((c) => c.id === id);
8787+ loader = "id";
8888+ } else if (cid) {
8989+ item = collection.find((c) => c.cid === cid);
9090+ loader = "cid";
9191+ } else if (name) {
9292+ item = collection.find((c) => c.name === name);
9393+ loader = "name";
9494+ }
9595+ }
9696+9797+ if (!loader) {
9898+ return renderError(container, "No loader specified");
9999+ } else if (!item) {
100100+ return renderError(container, `${config.label} not found`);
101101+ }
102102+103103+ // Make sure HTML is loaded when a URI is specified
104104+ await ensureHTML(item).catch((err) => {
105105+ renderError(container, `Failed to load URI: ${item.uri}`, {
106106+ context: err,
107107+ throw: true,
108108+ });
109109+ });
110110+111111+ if (item.cid === loadedCid) return;
112112+113113+ loadedCid = item.cid ?? null;
114114+ config.render(item);
115115+ });
116116+}
117117+118118+/**
119119+ * @param {string} uri
120120+ */
121121+export async function loadURI(uri) {
122122+ const u = URI.parse(uri);
123123+ console.log(u);
124124+125125+ switch (u.scheme) {
126126+ case "at":
127127+ return atprotoLoader(uri);
128128+ case "diffuse":
129129+ return httpLoader(uri.replace(/^diffuse:\/\//, ""));
130130+ case "http":
131131+ case "https":
132132+ return httpLoader(uri);
133133+ default:
134134+ throw new Error(`Unsupported scheme: ${u.scheme}`);
135135+ }
136136+}
137137+138138+/**
139139+ * Ensures the item has HTML loaded. If it has a URI but no HTML,
140140+ * fetches the HTML and computes the CID.
141141+ *
142142+ * @template {{ html?: string; uri?: string; cid?: string }} T
143143+ * @param {T} item
144144+ * @returns {Promise<T>}
145145+ */
146146+export async function ensureHTML(item) {
147147+ if (!item.html && item.uri) {
148148+ const html = await loadURI(item.uri);
149149+ const cid = await CID.create(0x55, new TextEncoder().encode(html));
150150+151151+ item.html = html;
152152+ item.cid = cid;
153153+ }
154154+155155+ return item;
156156+}
157157+158158+/**
159159+ * @param {HTMLElement} container
160160+ * @param {string} error
161161+ * @param {{ context?: Error; throw?: boolean }} [options]
162162+ */
163163+export function renderError(container, error, options) {
164164+ container.innerHTML = `
165165+ <div class="diffuse">
166166+ <div class="flex">
167167+ <i class="ph-fill ph-warning"></i>
168168+ <span>${error}</span>
169169+ </div>
170170+ </div>
171171+ `;
172172+173173+ if (options?.throw) {
174174+ throw options.context ?? new Error(error);
175175+ }
176176+}
177177+178178+////////////////////////////////////////////
179179+// 🛠️ | LOADERS
180180+////////////////////////////////////////////
181181+182182+/**
183183+ * @param {string} uri
184184+ */
185185+async function atprotoLoader(uri) {
186186+ const parts = uri.replace(/at:\/\//, "").split("/");
187187+ const [repo, collection, rkey] = parts;
188188+189189+ const resolver = new LocalActorResolver({
190190+ handleResolver: new XrpcHandleResolver({
191191+ serviceUrl: "https://public.api.bsky.app",
192192+ }),
193193+ didDocumentResolver: new CompositeDidDocumentResolver({
194194+ methods: {
195195+ plc: new PlcDidDocumentResolver(),
196196+ web: new WebDidDocumentResolver(),
197197+ },
198198+ }),
199199+ });
200200+201201+ const identity = await resolver.resolve(
202202+ /** @type {import("@atcute/lexicons/syntax").ActorIdentifier} */ (repo),
203203+ );
204204+205205+ const rpc = new Client({
206206+ handler: simpleFetchHandler({ service: identity.pds }),
207207+ });
208208+209209+ /** @type {any} */
210210+ const { value } = await ok(
211211+ /** @type {any} */ (rpc).get("com.atproto.repo.getRecord", {
212212+ params: { repo: identity.did, collection, rkey },
213213+ }),
214214+ );
215215+216216+ return value.html ?? "";
217217+}
218218+219219+/**
220220+ * @param {string} url
221221+ */
222222+async function httpLoader(url) {
223223+ return fetch(url).then((res) => res.text());
224224+}
+6-8
src/facets/index.vto
···5050 Make a list of what previously played in the queue.
5151---
52525353-{{ function facetUrl(facetPath) }}facets/l/?url={{encodeURIComponent(facetPath)}}{{ /function }}
5454-5553<header>
5654 <div>
5755 <div>
···10199 <li>
102100 <p>
103101 <i class="ph-fill ph-plus"></i>
104104- <strong><a href="{{ facetUrl('themes/webamp/configurators/input/facet.html.txt') }}">Add</a></strong> audio from various places on the web and your device.
102102+ <strong><a href="{{ ('themes/webamp/configurators/input/facet.html.txt') |> facetURL }}">Add</a></strong> audio from various places on the web and your device.
105103 </p>
106104 </li>
107105 <li>
108106 <p>
109107 <i class="ph-fill ph-queue"></i>
110110- <strong><a href="{{ facetUrl('themes/webamp/browser/facet.html.txt') }}">Browse</a></strong> your collection to put something into the queue.
108108+ <strong><a href="{{ ('themes/webamp/browser/facet.html.txt') |> facetURL }}">Browse</a></strong> your collection to put something into the queue.
111109 </p>
112110 </li>
113111 <li>
114112 <p>
115113 <i class="ph-fill ph-queue"></i>
116116- <strong><a href="{{ facetUrl('facets/tools/auto-queue.html.txt') }}">Automate</a></strong> adding items to the queue, for infinite playback or listening to a playlist.
114114+ <strong><a href="{{ ('facets/tools/auto-queue.html.txt') |> facetURL }}">Automate</a></strong> adding items to the queue, for infinite playback or listening to a playlist.
117115 </p>
118116 </li>
119117 <li>
120118 <p>
121119 <i class="ph-fill ph-music-note"></i>
122122- <strong><a href="{{ facetUrl('themes/blur/artwork-controller/facet.html.txt') }}">Play</a></strong> queued songs.
120120+ <strong><a href="{{ ('themes/blur/artwork-controller/facet.html.txt') |> facetURL }}">Play</a></strong> queued songs.
123121 </p>
124122 </li>
125123 <li>
126124 <p>
127125 <i class="ph-fill ph-person"></i>
128128- <strong><a href="{{ facetUrl('themes/webamp/configurators/output/facet.html.txt') }}">Manage</a></strong> your user data, sync with your other devices or other people.
126126+ <strong><a href="{{ ('themes/webamp/configurators/output/facet.html.txt') |> facetURL }}">Manage</a></strong> your user data, sync with your other devices or other people.
129127 </p>
130128 </li>
131129 </ol>
···137135 To use these facets, simply open whichever ones provide the functionality that you're looking for at a given moment. You can browse existing ones here and create one below.
138136 </p>
139137 <p>
140140- For example, say you want to play music; two options would be: (1) <a href="{{ facetUrl('themes/webamp/browser/facet.html.txt') }}">browse</a> for a specific song and add it to the queue, or (2) <a href="{{ facetUrl('facets/tools/auto-queue.html.txt') }}">automatically</a> add a bunch of shuffled songs to the queue. Next, you need a way to play the items you added to the queue. That's where a <a href="{{ facetUrl('themes/blur/artwork-controller/facet.html.txt') }}">controller</a> could be used.
138138+ For example, say you want to play music; two options would be: (1) <a href="{{ ('themes/webamp/browser/facet.html.txt') |> facetURL }}">browse</a> for a specific song and add it to the queue, or (2) <a href="{{ ('facets/tools/auto-queue.html.txt') |> facetURL }}">automatically</a> add a bunch of shuffled songs to the queue. Next, you need a way to play the items you added to the queue. That's where a <a href="{{ ('themes/blur/artwork-controller/facet.html.txt') |> facetURL }}">controller</a> could be used.
141139 </p>
142140 <p>
143141 <em>You might ask, why can't I do all of this in just one window? That's what <a href="themes/">themes</a> are for, if you need something more streamlined. If you however want a customised experience, or prefer certain interfaces for certain things, that's what facets are for.</em>