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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-06 15:16:06 -04:00
1 parent 2a543c3ba9
commit 73a99358a2
6 files changed
+777

No files matched your search

+83
View File
@@ -211,6 +211,28 @@
</svg>
My Videos
</button>
<button
id="profileNavPlaylists"
type="button"
class="btn-ghost focus-ring w-full justify-start gap-2 px-3 py-2 text-xs font-medium"
aria-controls="profilePanePlaylists"
aria-selected="false"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h11M4 10h11M4 14h7m3 5l6-3.5L14 12v7z"
/>
</svg>
Playlists
</button>
<button
id="profileNavHashtags"
type="button"
@@ -1135,6 +1157,67 @@
</div>
</section>
<section
id="profilePanePlaylists"
class="profile-pane hidden space-y-4"
role="tabpanel"
aria-labelledby="profileNavPlaylists"
>
<p class="text-sm text-muted">
Manage your playlists — rename them, reorder or remove videos, or
delete a playlist. Add videos to a playlist from any video’s ⋯
menu.
</p>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">Playlists</h3>
<button
id="profilePlaylistsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh playlists"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
></path>
<path d="M3 3v5h5"></path>
<path
d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
></path>
<path d="M16 21h5v-5"></path>
</svg>
</button>
</div>
<p
id="profilePlaylistsLoading"
class="text-sm text-muted hidden"
role="status"
aria-live="polite"
>
Loading your playlists…
</p>
<p id="profilePlaylistsEmpty" class="text-sm text-muted hidden">
You haven’t made any playlists yet. Open any video’s ⋯ menu and
choose “Add to playlist” to start one.
</p>
<ul
id="profilePlaylistsList"
class="grid-stack"
data-orientation="vertical"
></ul>
</section>
<section
id="profilePaneHashtags"
class="profile-pane hidden space-y-6"
+92
View File
@@ -3063,6 +3063,98 @@
overflow: hidden;
}
/* #37: profile modal "Playlists" management pane — collapsible rows. */
.playlist-row {
border: var(--border-width-hairline) solid var(--color-border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
background-color: var(--color-page-alt);
}
.playlist-row + .playlist-row {
margin-block-start: var(--space-2xs);
}
.playlist-row__head {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
cursor: pointer;
}
.playlist-row__chevron svg {
color: var(--color-text-muted);
transition: transform 0.15s ease-out;
}
.playlist-row__chevron[data-open="true"] svg {
transform: rotate(90deg);
}
.playlist-row__meta {
flex: 1 1 auto;
min-inline-size: 0;
}
.playlist-row__title {
margin: 0;
font-weight: 600;
color: var(--color-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-row__count {
margin: 0;
font-size: var(--font-size-2xs);
color: var(--color-text-muted);
}
.playlist-row__actions {
display: flex;
gap: var(--space-2xs);
flex-shrink: 0;
}
.playlist-row__videos {
border-block-start: var(--border-width-hairline) solid
var(--color-border-subtle);
padding: var(--space-2xs) var(--space-sm) var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-2xs);
}
.playlist-row__empty {
padding-block: var(--space-2xs);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.playlist-video {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.playlist-video__title {
flex: 1 1 auto;
min-inline-size: 0;
font-size: var(--font-size-sm);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-video__controls {
display: flex;
gap: var(--space-3xs);
flex-shrink: 0;
}
/* #43: always-visible active-identity pill. Rendered after the header in
flow and pulled up with a negative block-start margin so it sits half-on /
half-off the header divider — every pane shows who you're acting as. */
+50
View File
@@ -149,6 +149,56 @@ export async function publishPlaylist(playlist, { client = nostrClient } = {}) {
return result?.signedEvent || null;
}
/**
* Delete a playlist: publishes a NIP-09 deletion (kind 5) referencing the
* playlist's addressable coordinate (`a`) and, when known, its event id (`e`),
* so relays that honor deletions drop it. Also republishes the playlist as an
* empty replaceable event so readers that ignore kind 5 still see it as empty
* (and empty playlists are dropped from listings).
* @param {Object} playlist parsed playlist ({ pubkey, id, address, eventId })
* @param {{ client?: any }} [opts]
* @returns {Promise<Object|null>} the signed deletion event
*/
export async function deletePlaylist(playlist, { client = nostrClient } = {}) {
const pubkey =
typeof playlist?.pubkey === "string" ? playlist.pubkey.trim().toLowerCase() : "";
const id = typeof playlist?.id === "string" ? playlist.id.trim() : "";
if (!pubkey || !id) {
return null;
}
const address =
typeof playlist.address === "string" && playlist.address
? playlist.address
: `${PLAYLIST_KIND}:${pubkey}:${id}`;
const tags = [["a", address]];
if (typeof playlist.eventId === "string" && playlist.eventId) {
tags.push(["e", playlist.eventId]);
}
const deletion = {
kind: 5,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "playlist deleted",
};
// Best-effort: replace with an empty playlist so kind-5-ignoring readers drop it.
try {
await publishPlaylist(
{ pubkey, id, title: playlist.title || "", items: [] },
{ client },
);
} catch (error) {
devLogger.warn("[playlists] Failed to empty playlist during delete:", error);
}
const result = await client.signAndPublishEvent(deletion, {
context: "playlist-delete",
});
return result?.signedEvent || null;
}
export default {
fetchCreatorPlaylists,
fetchPlaylist,
@@ -0,0 +1,430 @@
// js/ui/profileModal/ProfilePlaylistsController.js
//
// The profile modal's "Playlists" pane (#37) — a central place to manage your
// playlists: rename, delete (NIP-09), and expand one to reorder (↑/↓) or remove
// its videos. Mirrors MyVideosController's shape (constructor(mainController) +
// cacheDomReferences/registerEventListeners + a populate/refresh that runs when
// the pane is selected). All writes go through the playlist facade (active
// signer); edits are optimistic and re-sync on failure. Gated by FEATURE_PLAYLISTS.
import { nostrClient } from "../../nostrClientFacade.js";
import {
fetchCreatorPlaylists,
publishPlaylist,
deletePlaylist,
} from "../../playlists/playlistFacade.js";
import {
reorderPlaylistItems,
removeVideoFromPlaylist,
} from "../../playlists/playlistService.js";
import { buildVideoAddressPointer } from "../../utils/videoPointer.js";
import { showConfirm } from "../confirmDialog.js";
import { showTextPrompt } from "../promptDialog.js";
import { FEATURE_PLAYLISTS } from "../../constants.js";
import { devLogger } from "../../utils/logger.js";
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) {
node.className = className;
}
if (text != null) {
node.textContent = text;
}
return node;
}
function iconButton(label, action, dataset, svgPath) {
const btn = el("button", "btn-ghost btn-icon");
btn.type = "button";
btn.dataset.size = "sm";
btn.setAttribute("aria-label", label);
btn.dataset.plAction = action;
Object.entries(dataset || {}).forEach(([k, v]) => {
btn.dataset[k] = String(v);
});
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("class", "h-4 w-4");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", svgPath);
svg.appendChild(path);
btn.appendChild(svg);
return btn;
}
const ICONS = {
up: "M12 19V5M5 12l7-7 7 7",
down: "M12 5v14M5 12l7 7 7-7",
remove: "M6 6l12 12M18 6L6 18",
rename: "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z",
trash: "M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6",
chevron: "M9 18l6-6-6-6",
};
export class ProfilePlaylistsController {
constructor(mainController) {
this.mainController = mainController;
this.listEl = null;
this.loadingEl = null;
this.emptyEl = null;
this.refreshBtn = null;
this.pubkey = "";
this.playlists = [];
this.expanded = new Set();
this.loading = false;
this._onChanged = () => {
void this.refresh();
};
}
cacheDomReferences() {
this.listEl = document.getElementById("profilePlaylistsList") || null;
this.loadingEl = document.getElementById("profilePlaylistsLoading") || null;
this.emptyEl = document.getElementById("profilePlaylistsEmpty") || null;
this.refreshBtn =
document.getElementById("profilePlaylistsRefreshBtn") || null;
}
registerEventListeners() {
if (this.refreshBtn instanceof HTMLElement) {
this.refreshBtn.addEventListener("click", () => {
void this.refresh();
});
}
if (this.listEl instanceof HTMLElement) {
this.listEl.addEventListener("click", (event) =>
this.handleListClick(event),
);
}
if (typeof document !== "undefined") {
document.addEventListener("bitvid:playlists-changed", this._onChanged);
}
}
setLoading(value) {
this.loading = value;
if (this.loadingEl) {
this.loadingEl.classList.toggle("hidden", !value);
}
}
async refresh() {
if (!FEATURE_PLAYLISTS || !this.listEl) {
return;
}
const active = this.mainController.getActivePubkey?.() || "";
this.pubkey = typeof active === "string" ? active.trim().toLowerCase() : "";
if (!this.pubkey) {
this.playlists = [];
this.render();
return;
}
this.setLoading(true);
let playlists = [];
try {
playlists = await fetchCreatorPlaylists(this.pubkey, {
includeEmpty: true,
});
} catch (error) {
devLogger.warn("[playlists] Failed to load playlists for pane:", error);
}
this.playlists = playlists;
// Fetch the referenced creators' videos so the expanded sublists can show
// real titles/thumbnails (cache may be cold in the modal).
const authors = new Set();
for (const playlist of playlists) {
for (const item of playlist.items) {
if (item.type === "a") {
const author = item.value.split(":")[1];
if (author) {
authors.add(author);
}
}
}
}
const nostrService = this.mainController.services?.nostrService;
if (authors.size && typeof nostrService?.fetchVideosByAuthors === "function") {
try {
await nostrService.fetchVideosByAuthors([...authors]);
} catch (error) {
// best effort — fall back to coordinate labels
}
}
this.setLoading(false);
this.render();
}
// coord -> { title, thumbnail } from the app's active videos.
buildVideoMap() {
const map = new Map();
let active = [];
try {
active = nostrClient.getActiveVideos() || [];
} catch (error) {
active = [];
}
for (const video of active) {
const coordinate = buildVideoAddressPointer(video);
if (coordinate && !map.has(coordinate)) {
map.set(coordinate, {
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
});
}
}
return map;
}
render() {
if (!this.listEl) {
return;
}
this.listEl.replaceChildren();
if (!this.playlists.length) {
if (this.emptyEl) {
this.emptyEl.classList.remove("hidden");
}
return;
}
if (this.emptyEl) {
this.emptyEl.classList.add("hidden");
}
const videoMap = this.buildVideoMap();
for (const playlist of this.playlists) {
this.listEl.appendChild(this.renderRow(playlist, videoMap));
}
}
renderRow(playlist, videoMap) {
const li = el("li", "playlist-row");
const head = el("div", "playlist-row__head");
head.dataset.plAction = "toggle";
head.dataset.plId = playlist.id;
const chevron = el("span", "playlist-row__chevron");
if (this.expanded.has(playlist.id)) {
chevron.dataset.open = "true";
}
const chevSvg = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
chevSvg.setAttribute("viewBox", "0 0 24 24");
chevSvg.setAttribute("fill", "none");
chevSvg.setAttribute("stroke", "currentColor");
chevSvg.setAttribute("stroke-width", "2");
chevSvg.setAttribute("class", "h-4 w-4");
chevSvg.setAttribute("aria-hidden", "true");
const chevPath = document.createElementNS(
"http://www.w3.org/2000/svg",
"path",
);
chevPath.setAttribute("d", ICONS.chevron);
chevSvg.appendChild(chevPath);
chevron.appendChild(chevSvg);
head.appendChild(chevron);
const meta = el("div", "playlist-row__meta");
meta.appendChild(el("p", "playlist-row__title", playlist.title));
const n = playlist.items.length;
meta.appendChild(
el("p", "playlist-row__count", `${n} ${n === 1 ? "video" : "videos"}`),
);
head.appendChild(meta);
const actions = el("div", "playlist-row__actions");
actions.appendChild(
iconButton("Rename playlist", "rename", { plId: playlist.id }, ICONS.rename),
);
actions.appendChild(
iconButton("Delete playlist", "delete", { plId: playlist.id }, ICONS.trash),
);
head.appendChild(actions);
li.appendChild(head);
if (this.expanded.has(playlist.id)) {
li.appendChild(this.renderVideoList(playlist, videoMap));
}
return li;
}
renderVideoList(playlist, videoMap) {
const wrap = el("ul", "playlist-row__videos");
if (!playlist.items.length) {
wrap.appendChild(el("li", "playlist-row__empty", "This playlist is empty."));
return wrap;
}
playlist.items.forEach((item, index) => {
const row = el("li", "playlist-video");
const info = videoMap.get(item.value);
const dtag = item.value.split(":")[2] || item.value;
const label = info?.title || dtag;
const title = el("span", "playlist-video__title", label);
row.appendChild(title);
const controls = el("div", "playlist-video__controls");
const up = iconButton(
"Move up",
"move-up",
{ plId: playlist.id, plIndex: index },
ICONS.up,
);
if (index === 0) {
up.disabled = true;
}
const down = iconButton(
"Move down",
"move-down",
{ plId: playlist.id, plIndex: index },
ICONS.down,
);
if (index === playlist.items.length - 1) {
down.disabled = true;
}
const remove = iconButton(
"Remove from playlist",
"remove",
{ plId: playlist.id, plCoord: item.value },
ICONS.remove,
);
controls.appendChild(up);
controls.appendChild(down);
controls.appendChild(remove);
row.appendChild(controls);
wrap.appendChild(row);
});
return wrap;
}
handleListClick(event) {
const target =
event.target instanceof Element
? event.target.closest("[data-pl-action]")
: null;
if (!target) {
return;
}
const action = target.dataset.plAction;
const id = target.dataset.plId;
const playlist = this.playlists.find((p) => p.id === id);
if (!playlist) {
return;
}
if (action === "toggle") {
this.toggleExpand(id);
} else if (action === "rename") {
void this.rename(playlist);
} else if (action === "delete") {
void this.deletePlaylistRow(playlist);
} else if (action === "move-up") {
void this.moveVideo(playlist, Number(target.dataset.plIndex), -1);
} else if (action === "move-down") {
void this.moveVideo(playlist, Number(target.dataset.plIndex), 1);
} else if (action === "remove") {
void this.removeVideo(playlist, target.dataset.plCoord);
}
}
toggleExpand(id) {
if (this.expanded.has(id)) {
this.expanded.delete(id);
} else {
this.expanded.add(id);
}
this.render();
}
async rename(playlist) {
const name = await showTextPrompt("Give this playlist a new name.", {
title: "Rename playlist",
value: playlist.title,
confirmLabel: "Save",
placeholder: "Playlist name",
});
if (!name || name === playlist.title) {
return;
}
await this.publishUpdate({ ...playlist, title: name }, "Playlist renamed.");
}
async deletePlaylistRow(playlist) {
const ok = await showConfirm(
`Delete “${playlist.title}”? This removes the playlist for everyone. It can’t be undone.`,
{ title: "Delete playlist", confirmLabel: "Delete", danger: true },
);
if (!ok) {
return;
}
this.playlists = this.playlists.filter((p) => p.id !== playlist.id);
this.expanded.delete(playlist.id);
this.render();
try {
await deletePlaylist(playlist);
this.mainController.showSuccess?.("Playlist deleted.");
this.emitChanged();
} catch (error) {
devLogger.warn("[playlists] Delete failed:", error);
this.mainController.showError?.("Couldn’t delete the playlist.");
void this.refresh();
}
}
async moveVideo(playlist, index, direction) {
if (!Number.isInteger(index)) {
return;
}
const items = reorderPlaylistItems(playlist.items, index, index + direction);
await this.publishUpdate({ ...playlist, items }, null);
}
async removeVideo(playlist, coordinate) {
const items = removeVideoFromPlaylist(playlist.items, coordinate);
await this.publishUpdate({ ...playlist, items }, "Removed from playlist.");
}
async publishUpdate(updated, successMessage) {
const index = this.playlists.findIndex((p) => p.id === updated.id);
if (index >= 0) {
this.playlists[index] = { ...this.playlists[index], ...updated };
}
this.render();
try {
await publishPlaylist({ pubkey: this.pubkey, ...updated });
if (successMessage) {
this.mainController.showSuccess?.(successMessage);
}
this.emitChanged();
} catch (error) {
devLogger.warn("[playlists] Update failed:", error);
this.mainController.showError?.("Couldn’t save the change.");
void this.refresh();
}
}
emitChanged() {
try {
document.dispatchEvent(
new CustomEvent("bitvid:playlists-changed", {
detail: { pubkey: this.pubkey },
}),
);
} catch (error) {
// best effort
}
}
}
export default ProfilePlaylistsController;
+15
View File
@@ -45,6 +45,8 @@ import { DMSettingsModalController } from "./dm/DMSettingsModalController.js";
import { ProfileWalletController } from "./profileModal/ProfileWalletController.js";
import { ProfileStorageController } from "./profileModal/ProfileStorageController.js";
import { MyVideosController } from "./profileModal/MyVideosController.js";
import { ProfilePlaylistsController } from "./profileModal/ProfilePlaylistsController.js";
import { FEATURE_PLAYLISTS } from "../constants.js";
import { ProfileDirectMessageController } from "./profileModal/ProfileDirectMessageController.js";
import { ProfileRelayController } from "./profileModal/ProfileRelayController.js";
import { ProfileHashtagController } from "./profileModal/ProfileHashtagController.js";
@@ -530,6 +532,7 @@ export class ProfileModalController {
this.walletController = new ProfileWalletController(this);
this.storageController = new ProfileStorageController(this);
this.myVideosController = new MyVideosController(this);
this.playlistsController = new ProfilePlaylistsController(this);
this.mobileViewState = "menu";
this.lastMobileViewState = "menu";
this.setActivePane(this.getActivePane());
@@ -696,6 +699,12 @@ export class ProfileModalController {
this.navButtons.storage = document.getElementById("profileNavStorage") || null;
this.navButtons.myvideos =
document.getElementById("profileNavMyVideos") || null;
this.navButtons.playlists =
document.getElementById("profileNavPlaylists") || null;
// Hide the Playlists tab entirely while the feature is off.
if (!FEATURE_PLAYLISTS && this.navButtons.playlists) {
this.navButtons.playlists.classList.add("hidden");
}
this.navButtons.hashtags =
document.getElementById("profileNavHashtags") || null;
this.navButtons.subscriptions =
@@ -721,6 +730,8 @@ export class ProfileModalController {
this.panes.storage = document.getElementById("profilePaneStorage") || null;
this.panes.myvideos =
document.getElementById("profilePaneMyVideos") || null;
this.panes.playlists =
document.getElementById("profilePanePlaylists") || null;
this.panes.hashtags = document.getElementById("profilePaneHashtags") || null;
this.panes.subscriptions =
document.getElementById("profilePaneSubscriptions") || null;
@@ -816,6 +827,7 @@ export class ProfileModalController {
this.walletController.cacheDomReferences();
this.storageController.cacheDomReferences();
this.myVideosController.cacheDomReferences();
this.playlistsController.cacheDomReferences();
if (this.dmController.pendingMessagesRender) {
const { messages, actorPubkey } = this.dmController.pendingMessagesRender;
@@ -1976,6 +1988,7 @@ export class ProfileModalController {
this.walletController.registerEventListeners();
this.storageController.registerEventListeners();
this.myVideosController.registerEventListeners();
this.playlistsController.registerEventListeners();
this.adminController.registerEventListeners();
this.moderationController.registerEventListeners();
@@ -3413,6 +3426,8 @@ export class ProfileModalController {
this.storageController.populateStoragePane();
} else if (target === "myvideos") {
void this.myVideosController.populate({ forceFetch: true });
} else if (target === "playlists") {
void this.playlistsController.refresh();
} else if (target === "hashtags") {
this.hashtagController.populateHashtagPreferences();
if (activeHex && this.services.hashtagPreferences) {
+107
View File
@@ -151,4 +151,111 @@ export function showPasswordPrompt(message, options = {}) {
});
}
// 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;