import type { PlaylistData, PlaylistVideo } from "../../types/playlist"; import { t } from "./i18n"; type SortKey = "index" | "title" | "channel" | "duration" | "addedBy" | "votes"; type SortDir = "asc" | "desc"; interface Column { label: string; cls: string; key: SortKey; collab?: boolean; // only shown for collaborative playlists } function getAllColumns(): Column[] { return [ { label: t("colIndex"), cls: "ytpf-col-index", key: "index" }, { label: t("colTitle"), cls: "ytpf-col-title", key: "title" }, { label: t("colChannel"), cls: "ytpf-col-channel", key: "channel" }, { label: t("colDuration"), cls: "ytpf-col-duration", key: "duration" }, { label: t("colAddedBy"), cls: "ytpf-col-addedby", key: "addedBy", collab: true }, { label: t("colVotes"), cls: "ytpf-col-votes", key: "votes", collab: true }, ]; } interface TagInput { container: HTMLElement; getTags(): string[]; } function createTagInput( placeholder: string, candidates: string[], onChange: () => void, ): TagInput { const tags: string[] = []; const container = document.createElement("div"); container.className = "ytpf-tag-input"; const inputId = `ytpf-tag-${placeholder.replace(/[^a-zA-Z]/g, "")}`; const input = document.createElement("input"); input.className = "ytpf-tag-text-input"; input.type = "text"; input.name = inputId; input.placeholder = placeholder; function updatePlaceholder() { input.placeholder = tags.length > 0 ? "" : placeholder; } const dropdown = document.createElement("div"); dropdown.className = "ytpf-autocomplete"; dropdown.style.display = "none"; let activeIndex = -1; function tagHue(name: string): number { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } return ((hash % 360) + 360) % 360; } function addTag(name: string) { if (tags.includes(name)) return; tags.push(name); const hue = tagHue(name); const isDark = document.documentElement.hasAttribute("dark"); const tag = document.createElement("span"); tag.className = "ytpf-tag"; tag.dataset.value = name; tag.style.background = isDark ? `hsl(${hue} 40% 25%)` : `hsl(${hue} 55% 90%)`; tag.style.color = isDark ? `hsl(${hue} 60% 80%)` : `hsl(${hue} 60% 30%)`; const label = document.createElement("span"); label.textContent = name; const remove = document.createElement("span"); remove.className = "ytpf-tag-remove"; remove.textContent = "\u00d7"; remove.addEventListener("click", (e) => { e.stopPropagation(); const idx = tags.indexOf(name); if (idx !== -1) tags.splice(idx, 1); tag.remove(); updatePlaceholder(); onChange(); }); tag.append(label, remove); container.insertBefore(tag, input); input.value = ""; updatePlaceholder(); showDropdown(""); input.focus(); onChange(); } function showDropdown(filter: string) { const query = filter.toLowerCase(); const matches = candidates.filter( (c) => !tags.includes(c) && c.toLowerCase().includes(query), ); if (matches.length === 0) { hideDropdown(); return; } dropdown.textContent = ""; activeIndex = -1; for (const name of matches.slice(0, 20)) { const item = document.createElement("div"); item.className = "ytpf-autocomplete-item"; item.textContent = name; item.addEventListener("mousedown", (e) => { e.preventDefault(); addTag(name); }); dropdown.appendChild(item); } dropdown.style.display = ""; } function hideDropdown() { dropdown.style.display = "none"; activeIndex = -1; } function updateActive() { const items = dropdown.querySelectorAll(".ytpf-autocomplete-item"); items.forEach((el, i) => { el.classList.toggle("ytpf-autocomplete-item--active", i === activeIndex); }); if (activeIndex >= 0 && items[activeIndex]) { items[activeIndex].scrollIntoView({ block: "nearest" }); } } input.addEventListener("input", () => { showDropdown(input.value.trim()); }); input.addEventListener("focus", () => { showDropdown(input.value.trim()); }); input.addEventListener("keydown", (e) => { const items = dropdown.querySelectorAll(".ytpf-autocomplete-item"); if (e.key === "ArrowDown") { e.preventDefault(); if (items.length > 0) { activeIndex = Math.min(activeIndex + 1, items.length - 1); updateActive(); } } else if (e.key === "ArrowUp") { e.preventDefault(); if (items.length > 0) { activeIndex = Math.max(activeIndex - 1, 0); updateActive(); } } else if (e.key === "Enter") { e.preventDefault(); if (activeIndex >= 0 && items[activeIndex]) { addTag(items[activeIndex].textContent!); } else if (input.value.trim() && items.length > 0) { addTag(items[0].textContent!); } } else if (e.key === "Backspace" && !input.value && tags.length > 0) { const last = tags.pop()!; container.querySelector(`.ytpf-tag[data-value="${CSS.escape(last)}"]`)?.remove(); updatePlaceholder(); onChange(); } }); input.addEventListener("blur", () => { setTimeout(hideDropdown, 150); }); container.addEventListener("click", () => input.focus()); container.append(input, dropdown); return { container, getTags: () => [...tags] }; } function compareFn(key: SortKey, dir: SortDir) { const m = dir === "asc" ? 1 : -1; return (a: PlaylistVideo, b: PlaylistVideo): number => { switch (key) { case "index": return (a.index - b.index) * m; case "title": return a.title.localeCompare(b.title) * m; case "channel": return a.channel.name.localeCompare(b.channel.name) * m; case "duration": return ((a.durationSeconds ?? -1) - (b.durationSeconds ?? -1)) * m; case "addedBy": return (a.addedBy ?? "").localeCompare(b.addedBy ?? "") * m; case "votes": return ((a.voteCount ?? 0) - (b.voteCount ?? 0)) * m; } }; } function buildRow( video: PlaylistVideo, playlistId: string, isCollab: boolean, ): HTMLTableRowElement { const tr = document.createElement("tr"); tr.className = "ytpf-tr"; if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable"); // Index const tdIndex = document.createElement("td"); tdIndex.className = "ytpf-td ytpf-col-index"; tdIndex.textContent = String(video.index + 1); tr.appendChild(tdIndex); // Title const tdTitle = document.createElement("td"); tdTitle.className = "ytpf-td ytpf-col-title"; const titleLink = document.createElement("a"); titleLink.className = "ytpf-link"; titleLink.href = `/watch?v=${video.videoId}&list=${playlistId}`; titleLink.textContent = video.title; titleLink.title = video.title; tdTitle.appendChild(titleLink); tr.appendChild(tdTitle); // Channel const tdChannel = document.createElement("td"); tdChannel.className = "ytpf-td ytpf-col-channel"; if (video.channel.url) { const chLink = document.createElement("a"); chLink.className = "ytpf-link"; chLink.href = video.channel.url; chLink.textContent = video.channel.name; tdChannel.appendChild(chLink); } else { tdChannel.textContent = video.channel.name; } tr.appendChild(tdChannel); // Duration const tdDuration = document.createElement("td"); tdDuration.className = "ytpf-td ytpf-col-duration"; if (video.isLive) { const badge = document.createElement("span"); badge.className = "ytpf-live"; badge.textContent = t("badgeLive"); tdDuration.appendChild(badge); } else { tdDuration.textContent = video.durationText ?? "--"; } tr.appendChild(tdDuration); if (isCollab) { // Added by const tdAddedBy = document.createElement("td"); tdAddedBy.className = "ytpf-td ytpf-col-addedby"; tdAddedBy.textContent = video.addedBy ?? "--"; tr.appendChild(tdAddedBy); // Votes const tdVotes = document.createElement("td"); tdVotes.className = "ytpf-td ytpf-col-votes"; tdVotes.textContent = video.voteCount != null ? String(video.voteCount) : "--"; tr.appendChild(tdVotes); } return tr; } export function renderPlaylistTable(data: PlaylistData): HTMLElement { const isCollab = data.videos.some( (v) => v.addedBy != null || v.voteCount != null, ); const columns = getAllColumns().filter((c) => !c.collab || isCollab); const wrapper = document.createElement("div"); wrapper.className = "ytpf-wrapper"; // Header bar const header = document.createElement("div"); header.className = "ytpf-header"; const titleSpan = document.createElement("span"); titleSpan.className = "ytpf-header-title"; titleSpan.textContent = `${data.metadata.title} — ${data.extractedCount} ${t("headerVideos")}`; const metaSpan = document.createElement("span"); metaSpan.className = "ytpf-header-meta"; if (data.metadata.totalDurationText) { metaSpan.textContent = data.metadata.totalDurationText; } const toggle = document.createElement("span"); toggle.className = "ytpf-toggle"; toggle.textContent = "\u25BC"; header.append(titleSpan, metaSpan, toggle); wrapper.appendChild(header); // Filter bar const filters = document.createElement("div"); filters.className = "ytpf-filters"; const titleInput = document.createElement("input"); titleInput.className = "ytpf-filter-input"; titleInput.type = "text"; titleInput.name = "ytpf-title-filter"; titleInput.placeholder = t("filterTitle"); filters.appendChild(titleInput); // Channel tag input const channelNames = [...new Set(data.videos.map((v) => v.channel.name))].sort(); const channelTagInput = createTagInput(t("filterChannel"), channelNames, () => applyFilters()); filters.appendChild(channelTagInput.container); // Added-by tag input (collab only) let addedByTagInput: TagInput | null = null; if (isCollab) { const addedByNames = [...new Set( data.videos.map((v) => v.addedBy).filter((n): n is string => n != null), )].sort(); addedByTagInput = createTagInput(t("filterAddedBy"), addedByNames, () => applyFilters()); filters.appendChild(addedByTagInput.container); } const filterCount = document.createElement("span"); filterCount.className = "ytpf-filter-count"; filters.appendChild(filterCount); wrapper.appendChild(filters); // Body (table container) const body = document.createElement("div"); body.className = "ytpf-body"; const table = document.createElement("table"); table.className = "ytpf-table"; // Thead const thead = document.createElement("thead"); const headRow = document.createElement("tr"); const thElements: HTMLTableCellElement[] = []; for (const col of columns) { const th = document.createElement("th"); th.className = `ytpf-th ytpf-th--sortable ${col.cls}`; th.dataset.sortKey = col.key; th.textContent = col.label; thElements.push(th); headRow.appendChild(th); } thead.appendChild(headRow); table.appendChild(thead); // Tbody const tbody = document.createElement("tbody"); const playlistId = data.metadata.playlistId; const videos = [...data.videos]; function getFilteredVideos(): PlaylistVideo[] { const titleQuery = titleInput.value.toLowerCase(); const channelTags = channelTagInput.getTags(); const addedByTags = addedByTagInput?.getTags() ?? []; return videos.filter((v) => { if (titleQuery && !v.title.toLowerCase().includes(titleQuery)) return false; if (channelTags.length > 0 && !channelTags.includes(v.channel.name)) return false; if (addedByTags.length > 0 && (!v.addedBy || !addedByTags.includes(v.addedBy))) return false; return true; }); } function renderRows() { const filtered = getFilteredVideos(); tbody.textContent = ""; for (const video of filtered) { tbody.appendChild(buildRow(video, playlistId, isCollab)); } filterCount.textContent = filtered.length < videos.length ? `${filtered.length} / ${videos.length}` : ""; } function applyFilters() { renderRows(); } titleInput.addEventListener("input", applyFilters); renderRows(); // Sort state let sortKey: SortKey | null = null; let sortDir: SortDir = "asc"; function updateSortIndicators() { for (const th of thElements) { const key = th.dataset.sortKey; if (key === sortKey) { th.dataset.sortDir = sortDir; } else { delete th.dataset.sortDir; } } } headRow.addEventListener("click", (e) => { const th = (e.target as HTMLElement).closest( "th[data-sort-key]", ); if (!th) return; const key = th.dataset.sortKey as SortKey; if (sortKey === key) { sortDir = sortDir === "asc" ? "desc" : "asc"; } else { sortKey = key; sortDir = "asc"; } videos.sort(compareFn(sortKey, sortDir)); updateSortIndicators(); renderRows(); }); table.appendChild(tbody); body.appendChild(table); wrapper.appendChild(body); // Toggle collapse header.addEventListener("click", () => { body.classList.toggle("ytpf-body--hidden"); filters.classList.toggle("ytpf-filters--hidden"); toggle.classList.toggle("ytpf-toggle--collapsed"); }); return wrapper; }