From 503f5a6e4447136a5fecd520926fc1a24c015cb0 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Apr 2026 22:31:55 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=96=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E3=82=BD=E3=83=BC=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/ui/styles.ts | 28 ++++- src/content/ui/table-renderer.ts | 191 ++++++++++++++++++++----------- 2 files changed, 151 insertions(+), 68 deletions(-) diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts index 6aea973..32340b8 100644 --- a/src/content/ui/styles.ts +++ b/src/content/ui/styles.ts @@ -67,6 +67,28 @@ const CSS = ` white-space: nowrap; } +.ytpf-th--sortable { + cursor: pointer; + user-select: none; +} + +.ytpf-th--sortable:hover { + color: var(--yt-spec-text-primary); +} + +.ytpf-th--sortable::after { + content: ""; + margin-left: 4px; +} + +.ytpf-th[data-sort-dir="asc"]::after { + content: " \\25B2"; +} + +.ytpf-th[data-sort-dir="desc"]::after { + content: " \\25BC"; +} + .ytpf-td { padding: 6px 12px; border-bottom: 1px solid var(--yt-spec-10-percent-layer); @@ -84,12 +106,14 @@ const CSS = ` .ytpf-col-title { width: auto; } .ytpf-col-channel { width: 180px; } .ytpf-col-duration { width: 80px; font-family: "Roboto Mono", monospace; } -.ytpf-col-status { width: 60px; text-align: center; } - .ytpf-tr--unplayable { opacity: 0.45; } +.ytpf-tr--unplayable .ytpf-link { + text-decoration: line-through; +} + .ytpf-link { color: var(--yt-spec-text-primary); text-decoration: none; diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index b74d5e9..5c42705 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -1,4 +1,82 @@ -import type { PlaylistData } from "../../types/playlist"; +import type { PlaylistData, PlaylistVideo } from "../../types/playlist"; + +type SortKey = "index" | "title" | "channel" | "duration"; +type SortDir = "asc" | "desc"; + +const columns: { label: string; cls: string; key: SortKey }[] = [ + { label: "#", cls: "ytpf-col-index", key: "index" }, + { label: "Title", cls: "ytpf-col-title", key: "title" }, + { label: "Channel", cls: "ytpf-col-channel", key: "channel" }, + { label: "Duration", cls: "ytpf-col-duration", key: "duration" }, +]; + +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; + } + }; +} + +function buildRow(video: PlaylistVideo, playlistId: string): 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 = "LIVE"; + tdDuration.appendChild(badge); + } else { + tdDuration.textContent = video.durationText ?? "--"; + } + tr.appendChild(tdDuration); + + return tr; +} export function renderPlaylistTable(data: PlaylistData): HTMLElement { const wrapper = document.createElement("div"); @@ -35,17 +113,14 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { // Thead const thead = document.createElement("thead"); const headRow = document.createElement("tr"); - const columns = [ - { label: "#", cls: "ytpf-col-index" }, - { label: "Title", cls: "ytpf-col-title" }, - { label: "Channel", cls: "ytpf-col-channel" }, - { label: "Duration", cls: "ytpf-col-duration" }, - { label: "Status", cls: "ytpf-col-status" }, - ]; + const thElements: HTMLTableCellElement[] = []; + for (const col of columns) { const th = document.createElement("th"); - th.className = `ytpf-th ${col.cls}`; + 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); @@ -54,67 +129,51 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { // Tbody const tbody = document.createElement("tbody"); const playlistId = data.metadata.playlistId; + const videos = [...data.videos]; - for (const video of data.videos) { - const tr = document.createElement("tr"); - tr.className = "ytpf-tr"; - if (!video.isPlayable) { - tr.classList.add("ytpf-tr--unplayable"); + function renderRows() { + tbody.textContent = ""; + for (const video of videos) { + tbody.appendChild(buildRow(video, playlistId)); } - - // 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 = "LIVE"; - tdDuration.appendChild(badge); - } else { - tdDuration.textContent = video.durationText ?? "--"; - } - tr.appendChild(tdDuration); - - // Status - const tdStatus = document.createElement("td"); - tdStatus.className = "ytpf-td ytpf-col-status"; - tdStatus.textContent = video.isPlayable ? "\u2713" : "\u2717"; - tr.appendChild(tdStatus); - - tbody.appendChild(tr); } + 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);