テーブルのソート

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,66 +1,35 @@
import type { PlaylistData } from "../../types/playlist"; import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
export function renderPlaylistTable(data: PlaylistData): HTMLElement { type SortKey = "index" | "title" | "channel" | "duration";
const wrapper = document.createElement("div"); type SortDir = "asc" | "desc";
wrapper.className = "ytpf-wrapper";
// Header bar const columns: { label: string; cls: string; key: SortKey }[] = [
const header = document.createElement("div"); { label: "#", cls: "ytpf-col-index", key: "index" },
header.className = "ytpf-header"; { label: "Title", cls: "ytpf-col-title", key: "title" },
{ label: "Channel", cls: "ytpf-col-channel", key: "channel" },
const titleSpan = document.createElement("span"); { label: "Duration", cls: "ytpf-col-duration", key: "duration" },
titleSpan.className = "ytpf-header-title";
titleSpan.textContent = `${data.metadata.title}${data.extractedCount} videos`;
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);
// 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 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" },
]; ];
for (const col of columns) {
const th = document.createElement("th"); function compareFn(key: SortKey, dir: SortDir) {
th.className = `ytpf-th ${col.cls}`; const m = dir === "asc" ? 1 : -1;
th.textContent = col.label; return (a: PlaylistVideo, b: PlaylistVideo): number => {
headRow.appendChild(th); 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;
}
};
} }
thead.appendChild(headRow);
table.appendChild(thead);
// Tbody function buildRow(video: PlaylistVideo, playlistId: string): HTMLTableRowElement {
const tbody = document.createElement("tbody");
const playlistId = data.metadata.playlistId;
for (const video of data.videos) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "ytpf-tr"; tr.className = "ytpf-tr";
if (!video.isPlayable) { if (!video.isPlayable) tr.classList.add("ytpf-tr--unplayable");
tr.classList.add("ytpf-tr--unplayable");
}
// Index // Index
const tdIndex = document.createElement("td"); const tdIndex = document.createElement("td");
@ -106,15 +75,105 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement {
} }
tr.appendChild(tdDuration); tr.appendChild(tdDuration);
// Status return tr;
const tdStatus = document.createElement("td");
tdStatus.className = "ytpf-td ytpf-col-status";
tdStatus.textContent = video.isPlayable ? "\u2713" : "\u2717";
tr.appendChild(tdStatus);
tbody.appendChild(tr);
} }
export function renderPlaylistTable(data: PlaylistData): HTMLElement {
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} videos`;
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);
// 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 renderRows() {
tbody.textContent = "";
for (const video of videos) {
tbody.appendChild(buildRow(video, playlistId));
}
}
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);