mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
2a543c3ba9
commit
73a99358a2
6 files changed
+777
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user