Files
bitvid/js/ui/promptDialog.js
T
thePR0M3TH3ANandClaude Opus 4.8 73a99358a2 playlists(#37): profile-modal "Playlists" tab — rename / delete / reorder / remove
A central management surface, a sibling tab to "My Videos" (gated by
FEATURE_PLAYLISTS — hidden when off).

- components/profile-modal.html: "Playlists" nav item + #profilePanePlaylists.
- js/ui/profileModal/ProfilePlaylistsController.js: lists the user's playlists
  (incl. empty) with a collapsible row each — Rename, Delete, and an expand to a
  video sublist with ↑/↓ reorder and Remove. Fetches the referenced creators'
  videos so the sublist shows real titles/thumbnails. Edits are optimistic and
  re-sync on failure; publishes via the facade; dispatches
  bitvid:playlists-changed so the channel section + this pane stay in sync.
- profileModalController.js: instantiate + bind nav/pane + cache DOM + register
  listeners + selectPane("playlists") refresh; hide the tab when the flag is off.
- promptDialog.js: showTextPrompt (plain text prompt) for rename.
- playlistFacade.js: deletePlaylist — NIP-09 kind-5 (a + e refs) plus an empty
  republish so kind-5-ignoring readers also drop it.
- tailwind.source.css: .playlist-row* styles (token-only).

Lint + build clean; profile-modal-controller tests 26/26.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 15:16:06 -04:00

262 lines
8.9 KiB
JavaScript

// Promise-based password/PIN prompt rendered with the site's modal styling (sibling of
// confirmDialog). Used to collect the passphrase that unlocks a stored nsec key — e.g.
// when switching to a saved nsec account. Resolves the entered string, or null if the
// user cancels. Builds its own `bv-modal modal-always-on-top` overlay so it stacks above
// other modals (and is typable thanks to the stacked-modal focus fix).
// When `collectRemember` is set, an opt-in "keep unlocked" checkbox is shown and
// the prompt resolves `{ passphrase, remember }` instead of a bare string (cancel
// still resolves null). Callers that don't pass `collectRemember` keep the legacy
// string resolution.
export function showPasswordPrompt(message, options = {}) {
const {
title = "",
confirmLabel = "Unlock",
cancelLabel = "Cancel",
placeholder = "PIN / passphrase",
collectRemember = false,
rememberLabel = "Keep me unlocked on this device",
rememberHint =
"Stored unencrypted on this device until you clear site data. Anyone with access to this browser can use it. Leave off to re-enter after closing the tab.",
rememberDefault = false,
} = options || {};
if (typeof document === "undefined" || !document.body) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"bv-modal modal-always-on-top items-start justify-center md:items-center";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const backdrop = document.createElement("div");
backdrop.className = "bv-modal-backdrop";
overlay.appendChild(backdrop);
const sheet = document.createElement("div");
sheet.className = "modal-sheet w-full max-w-md flex flex-col";
sheet.tabIndex = -1;
overlay.appendChild(sheet);
if (title) {
const header = document.createElement("div");
header.className = "modal-header";
const heading = document.createElement("h2");
heading.className = "text-lg font-bold text-text";
heading.textContent = title;
header.appendChild(heading);
sheet.appendChild(header);
}
const body = document.createElement("div");
body.className = "p-5 space-y-3";
if (message) {
const text = document.createElement("p");
text.className = "text-sm text-muted-strong whitespace-pre-line";
text.textContent = String(message);
body.appendChild(text);
}
const input = document.createElement("input");
input.type = "password";
input.className = "input w-full";
input.placeholder = placeholder;
input.autocomplete = "off";
input.setAttribute("aria-label", placeholder);
body.appendChild(input);
let rememberCheckbox = null;
if (collectRemember) {
const rememberWrap = document.createElement("label");
rememberWrap.className = "flex items-start gap-2 cursor-pointer";
rememberCheckbox = document.createElement("input");
rememberCheckbox.type = "checkbox";
rememberCheckbox.className = "mt-1";
rememberCheckbox.checked = Boolean(rememberDefault);
const rememberText = document.createElement("span");
rememberText.className = "text-sm text-muted-strong";
const rememberTitle = document.createElement("span");
rememberTitle.className = "block text-text";
rememberTitle.textContent = rememberLabel;
rememberText.appendChild(rememberTitle);
if (rememberHint) {
const hint = document.createElement("span");
hint.className = "block text-xs text-status-danger";
hint.textContent = rememberHint;
rememberText.appendChild(hint);
}
rememberWrap.appendChild(rememberCheckbox);
rememberWrap.appendChild(rememberText);
body.appendChild(rememberWrap);
}
sheet.appendChild(body);
const footer = document.createElement("div");
footer.className =
"flex items-center justify-end gap-3 border-t border-border/60 p-4";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn-ghost focus-ring";
cancelBtn.textContent = cancelLabel;
const confirmBtn = document.createElement("button");
confirmBtn.type = "button";
confirmBtn.className = "btn focus-ring";
confirmBtn.textContent = confirmLabel;
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
sheet.appendChild(footer);
let settled = false;
const cleanup = (result) => {
if (settled) {
return;
}
settled = true;
document.removeEventListener("keydown", onKeydown, true);
overlay.remove();
resolve(result);
};
const submit = () =>
cleanup(
collectRemember
? {
passphrase: input.value,
remember: Boolean(rememberCheckbox && rememberCheckbox.checked),
}
: input.value,
);
function onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
cleanup(null);
}
}
cancelBtn.addEventListener("click", () => cleanup(null));
backdrop.addEventListener("click", () => cleanup(null));
confirmBtn.addEventListener("click", submit);
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
submit();
}
});
document.addEventListener("keydown", onKeydown, true);
document.body.appendChild(overlay);
input.focus();
});
}
// A plain single-line text prompt (sibling of showPasswordPrompt) — used for
// things like renaming a playlist. Resolves the trimmed string, or null on
// cancel / empty. Prefill with `value`.
export function showTextPrompt(message, options = {}) {
const {
title = "",
confirmLabel = "Save",
cancelLabel = "Cancel",
placeholder = "",
value = "",
maxLength = 200,
} = options || {};
if (typeof document === "undefined" || !document.body) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"bv-modal modal-always-on-top items-start justify-center md:items-center";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const backdrop = document.createElement("div");
backdrop.className = "bv-modal-backdrop";
overlay.appendChild(backdrop);
const sheet = document.createElement("div");
sheet.className = "modal-sheet w-full max-w-md flex flex-col";
sheet.tabIndex = -1;
overlay.appendChild(sheet);
if (title) {
const header = document.createElement("div");
header.className = "modal-header";
const heading = document.createElement("h2");
heading.className = "text-lg font-bold text-text";
heading.textContent = title;
header.appendChild(heading);
sheet.appendChild(header);
}
const body = document.createElement("div");
body.className = "p-5 flex flex-col gap-3";
if (message) {
const text = document.createElement("p");
text.className = "text-sm text-muted-strong";
text.textContent = String(message);
body.appendChild(text);
}
const input = document.createElement("input");
input.type = "text";
input.className = "form-input";
input.placeholder = placeholder;
input.value = value;
input.maxLength = maxLength;
body.appendChild(input);
sheet.appendChild(body);
const footer = document.createElement("div");
footer.className =
"flex items-center justify-end gap-3 border-t border-border/60 p-4";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn-ghost focus-ring";
cancelBtn.textContent = cancelLabel;
const okBtn = document.createElement("button");
okBtn.type = "button";
okBtn.className = "btn focus-ring";
okBtn.textContent = confirmLabel;
footer.appendChild(cancelBtn);
footer.appendChild(okBtn);
sheet.appendChild(footer);
let settled = false;
const cleanup = (result) => {
if (settled) {
return;
}
settled = true;
document.removeEventListener("keydown", onKeydown, true);
overlay.remove();
resolve(result);
};
const submit = () => cleanup(input.value.trim() || null);
function onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
cleanup(null);
} else if (event.key === "Enter" && document.activeElement === input) {
event.preventDefault();
submit();
}
}
cancelBtn.addEventListener("click", () => cleanup(null));
backdrop.addEventListener("click", () => cleanup(null));
okBtn.addEventListener("click", submit);
document.addEventListener("keydown", onKeydown, true);
document.body.appendChild(overlay);
input.focus();
input.select();
});
}
export default showPasswordPrompt;