テーブルのソート

This commit is contained in:
Keisuke Hirata 2026-04-08 22:31:55 +09:00
parent fa47675790
commit 503f5a6e44
2 changed files with 151 additions and 68 deletions

View File

@ -67,6 +67,28 @@ const CSS = `
white-space: nowrap; 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 { .ytpf-td {
padding: 6px 12px; padding: 6px 12px;
border-bottom: 1px solid var(--yt-spec-10-percent-layer); border-bottom: 1px solid var(--yt-spec-10-percent-layer);
@ -84,12 +106,14 @@ const CSS = `
.ytpf-col-title { width: auto; } .ytpf-col-title { width: auto; }
.ytpf-col-channel { width: 180px; } .ytpf-col-channel { width: 180px; }
.ytpf-col-duration { width: 80px; font-family: "Roboto Mono", monospace; } .ytpf-col-duration { width: 80px; font-family: "Roboto Mono", monospace; }
.ytpf-col-status { width: 60px; text-align: center; }
.ytpf-tr--unplayable { .ytpf-tr--unplayable {
opacity: 0.45; opacity: 0.45;
} }
.ytpf-tr--unplayable .ytpf-link {
text-decoration: line-through;
}
.ytpf-link { .ytpf-link {
color: var(--yt-spec-text-primary); color: var(--yt-spec-text-primary);
text-decoration: none; text-decoration: none;

View File

@ -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 { export function renderPlaylistTable(data: PlaylistData): HTMLElement {
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
@ -35,17 +113,14 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
// Thead // Thead
const thead = document.createElement("thead"); const thead = document.createElement("thead");
const headRow = document.createElement("tr"); const headRow = document.createElement("tr");
const columns = [ const thElements: HTMLTableCellElement[] = [];
{ 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" },
];
for (const col of columns) { for (const col of columns) {
const th = document.createElement("th"); 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; th.textContent = col.label;
thElements.push(th);
headRow.appendChild(th); headRow.appendChild(th);
} }
thead.appendChild(headRow); thead.appendChild(headRow);
@ -54,67 +129,51 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
// Tbody // Tbody
const tbody = document.createElement("tbody"); const tbody = document.createElement("tbody");
const playlistId = data.metadata.playlistId; const playlistId = data.metadata.playlistId;
const videos = [...data.videos];
for (const video of data.videos) { function renderRows() {
const tr = document.createElement("tr"); tbody.textContent = "";
tr.className = "ytpf-tr"; for (const video of videos) {
if (!video.isPlayable) { tbody.appendChild(buildRow(video, playlistId));
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);
// 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<HTMLTableCellElement>(
"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); table.appendChild(tbody);
body.appendChild(table); body.appendChild(table);
wrapper.appendChild(body); wrapper.appendChild(body);