import { html, render } from "lit-html";
import * as URI from "fast-uri";
import { facetFromURI } from "~/common/facets/utils.js";
import facetsData from "~/_data/facets.json" with { type: "json" };
import { loadAtProtoRecord } from "~/common/loader.js";
import { saveFacet } from "./crud.js";
////////////////////////////////////////////
// DIALOG
////////////////////////////////////////////
// Track whether the user has manually edited the name/kind/description
// fields, so the auto-fill triggered by URI changes doesn't stomp on their
// input.
let nameEdited = false;
let kindEdited = false;
let descriptionEdited = false;
/**
* Best-effort auto-fill of the name, kind and description fields based on the
* entered URI.
*
* - `diffuse://` URIs are looked up in the bundled facet catalogue (offline).
* - `at://` URIs resolve the stored record; `name`, `kind` and `description`
* are taken from the record when present.
* - `https://` URIs use the document's `
` and meta description, falling
* back to a name derived from the URL path when the document can't be
* fetched.
*
* Fields the user has already edited are left alone. Failures are silent.
*
* @param {HTMLInputElement | null} uriEl
* @param {HTMLInputElement | null} nameEl
* @param {HTMLSelectElement | null} kindEl
* @param {HTMLTextAreaElement | null} descriptionEl
*/
async function inferFromURI(uriEl, nameEl, kindEl, descriptionEl) {
const uri = uriEl?.value.trim();
if (!uri) return;
const scheme = URI.parse(uri).scheme;
if (scheme === "diffuse") {
const entry = facetsData.find(
(c) => c.url === uri.replace(/^diffuse:\/\//, ""),
);
if (!entry) return;
if (!nameEdited && nameEl && entry.title) nameEl.value = entry.title;
if (!descriptionEdited && descriptionEl && entry.desc) {
descriptionEl.value = entry.desc;
}
const kind = entry.kind === "prelude" ? "prelude" : "interactive";
if (!kindEdited && kindEl && kindEl.value !== kind) kindEl.value = kind;
return;
}
if (scheme === "at") {
try {
const value = await loadAtProtoRecord(uri);
// A newer URI may have been entered while resolving — don't apply stale
// results over it.
if (uriEl?.value.trim() !== uri) return;
if (!nameEdited && nameEl && typeof value.name === "string" && value.name) {
nameEl.value = value.name;
}
if (
!descriptionEdited &&
descriptionEl &&
typeof value.description === "string" &&
value.description
) {
descriptionEl.value = value.description;
}
if (
!kindEdited &&
kindEl &&
(value.kind === "interactive" || value.kind === "prelude") &&
kindEl.value !== value.kind
) {
kindEl.value = value.kind;
}
} catch {
// Unresolvable URI — leave the fields as-is.
}
return;
}
if (scheme === "http" || scheme === "https") {
const fallback = nameFromURL(uri);
try {
const res = await fetch(uri);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const doc = new DOMParser().parseFromString(
await res.text(),
"text/html",
);
// A newer URI may have been entered while fetching — don't apply stale
// results over it.
if (uriEl?.value.trim() !== uri) return;
const title = doc.querySelector("title")?.textContent?.trim();
if (title && !nameEdited && nameEl) nameEl.value = title;
const meta = doc.querySelector('meta[name="description"]')
?.getAttribute("content")
?? doc.querySelector('meta[property="og:description"]')
?.getAttribute("content");
if (meta?.trim() && !descriptionEdited && descriptionEl) {
descriptionEl.value = meta.trim();
}
} catch {
// Network/CORS failure — fall back to a name derived from the URL.
if (!nameEdited && nameEl && fallback) nameEl.value = fallback;
}
}
}
/**
* Derives a display name from an HTTP(S) URL's path, used when the document
* itself can't be fetched. E.g. `https://example.com/facets/foo-bar/index.html`
* becomes `Foo Bar`.
*
* @param {string} url
* @returns {string | undefined}
*/
function nameFromURL(url) {
try {
const { pathname } = new URL(url);
const segments = pathname.replace(/\/+$/, "").split("/").filter(Boolean);
let last = segments.at(-1) ?? "";
if (last === "index.html" || last === "index.htm") {
last = segments.at(-2) ?? "";
}
const name = last
.replace(/\.(html?|tile)$/, "")
.split(/[-_]+/)
.filter(Boolean)
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(" ");
return name || undefined;
} catch {
return undefined;
}
}
export function openAddFromURIModal() {
let dialog = /** @type {HTMLDialogElement | null} */ (
document.getElementById("add-from-uri-dialog")
);
if (!dialog) {
dialog = /** @type {HTMLDialogElement} */ (
document.createElement("dialog")
);
dialog.id = "add-from-uri-dialog";
dialog.style.cssText =
"position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0;";
render(
html`
`,
dialog,
);
document.body.appendChild(dialog);
const nameEl = /** @type {HTMLInputElement | null} */ (
dialog.querySelector("#add-uri-name")
);
const kindEl = /** @type {HTMLSelectElement | null} */ (
dialog.querySelector("#add-uri-kind")
);
const uriEl = /** @type {HTMLInputElement | null} */ (
dialog.querySelector("#add-uri-uri")
);
const descriptionEl = /** @type {HTMLTextAreaElement | null} */ (
dialog.querySelector("#add-uri-description")
);
nameEl?.addEventListener("input", () => {
nameEdited = true;
});
kindEl?.addEventListener("change", () => {
kindEdited = true;
});
descriptionEl?.addEventListener("input", () => {
descriptionEdited = true;
});
/** @type {ReturnType | undefined} */
let fillTimer;
uriEl?.addEventListener("input", () => {
clearTimeout(fillTimer);
fillTimer = setTimeout(() => {
void inferFromURI(uriEl, nameEl, kindEl, descriptionEl);
}, 500);
});
dialog.querySelector("#add-uri-cancel")?.addEventListener("click", () => {
/** @type {HTMLDialogElement} */ (dialog).close();
});
dialog.querySelector("#add-from-uri-form")?.addEventListener(
"submit",
async (e) => {
e.preventDefault();
const name = nameEl?.value.trim() ?? "";
const kind = kindEl?.value ?? "interactive";
const uri = uriEl?.value.trim() ?? "";
const description = descriptionEl?.value.trim() || undefined;
if (!name || !uri) return;
const facet = await facetFromURI({ kind, name, uri, description }, {
fetchHTML: false,
});
await saveFacet(facet);
/** @type {HTMLDialogElement} */ (dialog).close();
},
);
}
const nameEl = /** @type {HTMLInputElement} */ (
dialog.querySelector("#add-uri-name")
);
const kindEl = /** @type {HTMLSelectElement} */ (
dialog.querySelector("#add-uri-kind")
);
const uriEl = /** @type {HTMLInputElement} */ (
dialog.querySelector("#add-uri-uri")
);
const descriptionEl = /** @type {HTMLTextAreaElement} */ (
dialog.querySelector("#add-uri-description")
);
if (nameEl) nameEl.value = "";
if (kindEl) kindEl.value = "interactive";
if (uriEl) uriEl.value = "";
if (descriptionEl) descriptionEl.value = "";
nameEdited = false;
kindEdited = false;
descriptionEdited = false;
dialog.showModal();
}