From 005fb72a4850be89579425aee4f16d83608e2068 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Apr 2026 01:45:18 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=B9=E3=82=BF=E3=82=A4=E3=83=AB=E3=81=AE?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E3=81=A8=E9=85=8D=E5=B8=83=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E3=83=AA=E3=83=97=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + package.json | 1 + scripts/zip.mjs | 133 ++++++++++++++++++ src/content/ui/styles.ts | 226 +++++++++++++++++++++++++------ src/content/ui/table-renderer.ts | 206 ++++++++++++++++++++++++---- 5 files changed, 499 insertions(+), 68 deletions(-) create mode 100644 scripts/zip.mjs diff --git a/.gitignore b/.gitignore index b947077..aa69ab9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +artifacts/ diff --git a/package.json b/package.json index cf60718..25f4782 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "node esbuild.config.mjs", + "zip": "npm run build && node scripts/zip.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/scripts/zip.mjs b/scripts/zip.mjs new file mode 100644 index 0000000..3abe661 --- /dev/null +++ b/scripts/zip.mjs @@ -0,0 +1,133 @@ +import { createWriteStream, readdirSync, statSync, readFileSync, mkdirSync } from "fs"; +import { join, relative } from "path"; +import { createDeflateRaw } from "zlib"; + +const pkg = JSON.parse(readFileSync("package.json", "utf8")); +const version = pkg.version; + +mkdirSync("artifacts", { recursive: true }); + +for (const browser of ["chrome", "firefox"]) { + const distDir = `dist/${browser}`; + const outPath = `artifacts/yt-playlist-features-${version}-${browser}.zip`; + await writeZip(distDir, outPath); + console.log(`Created ${outPath}`); +} + +/** + * Create a ZIP file from a directory. + * Pure Node.js implementation — no external dependencies. + */ +async function writeZip(dir, outPath) { + const files = collectFiles(dir); + const stream = createWriteStream(outPath); + const entries = []; + + let offset = 0; + + for (const filePath of files) { + const data = readFileSync(filePath); + const name = relative(dir, filePath).replace(/\\/g, "/"); + const nameBytes = Buffer.from(name, "utf8"); + const compressed = await deflate(data); + const crc = crc32(data); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); // signature + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0, 6); // flags + localHeader.writeUInt16LE(8, 8); // compression: deflate + localHeader.writeUInt16LE(0, 10); // mod time + localHeader.writeUInt16LE(0, 12); // mod date + localHeader.writeUInt32LE(crc, 14); + localHeader.writeUInt32LE(compressed.length, 18); + localHeader.writeUInt32LE(data.length, 22); + localHeader.writeUInt16LE(nameBytes.length, 26); + localHeader.writeUInt16LE(0, 28); // extra field length + + const localHeaderOffset = offset; + stream.write(localHeader); + stream.write(nameBytes); + stream.write(compressed); + offset += localHeader.length + nameBytes.length + compressed.length; + + entries.push({ nameBytes, crc, compressed, data, localHeaderOffset }); + } + + const centralStart = offset; + + for (const entry of entries) { + const cdHeader = Buffer.alloc(46); + cdHeader.writeUInt32LE(0x02014b50, 0); // signature + cdHeader.writeUInt16LE(20, 4); // version made by + cdHeader.writeUInt16LE(20, 6); // version needed + cdHeader.writeUInt16LE(0, 8); // flags + cdHeader.writeUInt16LE(8, 10); // compression + cdHeader.writeUInt16LE(0, 12); // mod time + cdHeader.writeUInt16LE(0, 14); // mod date + cdHeader.writeUInt32LE(entry.crc, 16); + cdHeader.writeUInt32LE(entry.compressed.length, 20); + cdHeader.writeUInt32LE(entry.data.length, 24); + cdHeader.writeUInt16LE(entry.nameBytes.length, 28); + cdHeader.writeUInt16LE(0, 30); // extra field length + cdHeader.writeUInt16LE(0, 32); // comment length + cdHeader.writeUInt16LE(0, 34); // disk start + cdHeader.writeUInt16LE(0, 36); // internal attrs + cdHeader.writeUInt32LE(0, 38); // external attrs + cdHeader.writeUInt32LE(entry.localHeaderOffset, 42); + + stream.write(cdHeader); + stream.write(entry.nameBytes); + offset += cdHeader.length + entry.nameBytes.length; + } + + const centralSize = offset - centralStart; + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); // signature + eocd.writeUInt16LE(0, 4); // disk number + eocd.writeUInt16LE(0, 6); // central dir disk + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralSize, 12); + eocd.writeUInt32LE(centralStart, 16); + eocd.writeUInt16LE(0, 20); // comment length + + stream.write(eocd); + await new Promise((resolve) => stream.end(resolve)); +} + +function collectFiles(dir) { + const results = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...collectFiles(full)); + } else { + results.push(full); + } + } + return results.sort(); +} + +function deflate(data) { + return new Promise((resolve, reject) => { + const chunks = []; + const deflater = createDeflateRaw(); + deflater.on("data", (chunk) => chunks.push(chunk)); + deflater.on("end", () => resolve(Buffer.concat(chunks))); + deflater.on("error", reject); + deflater.end(data); + }); +} + +function crc32(buf) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/src/content/ui/styles.ts b/src/content/ui/styles.ts index 7a9155e..a194046 100644 --- a/src/content/ui/styles.ts +++ b/src/content/ui/styles.ts @@ -3,13 +3,13 @@ const STYLE_ID = "ytpf-styles"; const CSS = ` .ytpf-wrapper { margin: 16px 0; - border: 1px solid var(--yt-spec-10-percent-layer); - border-radius: 12px; - overflow: hidden; font-family: "Roboto", "Arial", sans-serif; font-size: 13px; - color: var(--yt-spec-text-primary); - background: var(--yt-spec-base-background); + color: var(--yt-spec-text-primary, #0f0f0f); +} + +html[dark] .ytpf-wrapper { + color: #f1f1f1; } .ytpf-header { @@ -17,8 +17,6 @@ const CSS = ` align-items: center; justify-content: space-between; padding: 12px 16px; - background: var(--yt-spec-badge-chip-background); - border-bottom: 1px solid var(--yt-spec-10-percent-layer); cursor: pointer; user-select: none; } @@ -30,80 +28,205 @@ const CSS = ` .ytpf-header-meta { font-size: 12px; - color: var(--yt-spec-text-secondary); + color: var(--yt-spec-text-secondary, #606060); +} + +html[dark] .ytpf-header-meta { + color: #aaa; } .ytpf-toggle { font-size: 12px; - color: var(--yt-spec-text-secondary); + color: var(--yt-spec-text-secondary, #606060); transition: transform 0.2s; } +html[dark] .ytpf-toggle { + color: #aaa; +} + .ytpf-toggle--collapsed { transform: rotate(-90deg); } .ytpf-filters { display: flex; + align-items: flex-start; gap: 8px; padding: 8px 16px; - border-bottom: 1px solid var(--yt-spec-10-percent-layer); - background: var(--yt-spec-base-background); flex-wrap: wrap; } -.ytpf-filter-input, -.ytpf-filter-select { - padding: 6px 10px; - border: 1px solid var(--yt-spec-10-percent-layer); - border-radius: 8px; - background: var(--yt-spec-badge-chip-background); - color: var(--yt-spec-text-primary); - font-size: 13px; - font-family: "Roboto", "Arial", sans-serif; - outline: none; -} - .ytpf-filter-input { flex: 1; min-width: 120px; + height: 32px; + box-sizing: border-box; + padding: 0 10px; + border: 1px solid var(--yt-spec-10-percent-layer, rgba(0,0,0,0.2)); + border-radius: 8px; + background: transparent; + color: var(--yt-spec-text-primary, #0f0f0f); + font-size: 13px; + font-family: "Roboto", "Arial", sans-serif; + outline: none; } -.ytpf-filter-select { - cursor: pointer; - min-width: 140px; +html[dark] .ytpf-filter-input { + border-color: rgba(255,255,255,0.2); + color: #f1f1f1; } -.ytpf-filter-input:focus, -.ytpf-filter-select:focus { - border-color: var(--yt-spec-call-to-action); +.ytpf-filter-input:focus { + border-color: var(--yt-spec-call-to-action, #065fd4); +} + +html[dark] .ytpf-filter-input:focus { + border-color: #3ea6ff; } .ytpf-filter-input::placeholder { - color: var(--yt-spec-text-secondary); + color: var(--yt-spec-text-secondary, #606060); } -.ytpf-filter-select option { - background: var(--yt-spec-base-background, #fff); +html[dark] .ytpf-filter-input::placeholder { + color: #aaa; +} + +.ytpf-tag-input { + position: relative; + flex: 1; + min-width: 160px; + min-height: 32px; + box-sizing: border-box; + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 3px 8px; + border: 1px solid var(--yt-spec-10-percent-layer, rgba(0,0,0,0.2)); + border-radius: 8px; + background: transparent; + align-items: center; + cursor: text; +} + +html[dark] .ytpf-tag-input { + border-color: rgba(255,255,255,0.2); +} + +.ytpf-tag-input:focus-within { + border-color: var(--yt-spec-call-to-action, #065fd4); +} + +html[dark] .ytpf-tag-input:focus-within { + border-color: #3ea6ff; +} + +.ytpf-tag { + display: inline-flex; + align-items: center; + gap: 4px; + height: 24px; + box-sizing: border-box; + padding: 0 8px; + border-radius: 4px; + font-size: 12px; + line-height: 24px; + white-space: nowrap; +} + +.ytpf-tag-remove { + cursor: pointer; + font-size: 14px; + line-height: 1; + opacity: 0.6; +} + +.ytpf-tag-remove:hover { + opacity: 1; +} + +.ytpf-tag-text-input { + border: none; + outline: none; + background: transparent; color: var(--yt-spec-text-primary, #0f0f0f); + font-size: 13px; + font-family: "Roboto", "Arial", sans-serif; + flex: 1; + min-width: 30px; + height: 24px; + padding: 0; } -html[dark] .ytpf-filter-select option { - background: #282828; +html[dark] .ytpf-tag-text-input { color: #f1f1f1; } +.ytpf-tag-text-input::placeholder { + color: var(--yt-spec-text-secondary, #606060); +} + +html[dark] .ytpf-tag-text-input::placeholder { + color: #aaa; +} + +.ytpf-autocomplete { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: #fff; + border: 1px solid rgba(0,0,0,0.1); + border-radius: 8px; + max-height: 200px; + overflow-y: auto; + z-index: 2000; + box-shadow: 0 4px 8px rgba(0,0,0,0.2); +} + +html[dark] .ytpf-autocomplete { + background: #282828; + border-color: rgba(255,255,255,0.1); +} + +.ytpf-autocomplete-item { + padding: 6px 12px; + cursor: pointer; + font-size: 13px; + color: #0f0f0f; +} + +html[dark] .ytpf-autocomplete-item { + color: #f1f1f1; +} + +.ytpf-autocomplete-item:hover, +.ytpf-autocomplete-item--active { + background: #f2f2f2; +} + +html[dark] .ytpf-autocomplete-item:hover, +html[dark] .ytpf-autocomplete-item--active { + background: #3e3e3e; +} + .ytpf-filters--hidden { display: none; } .ytpf-filter-count { font-size: 12px; - color: var(--yt-spec-text-secondary); + color: var(--yt-spec-text-secondary, #606060); align-self: center; white-space: nowrap; } +html[dark] .ytpf-filter-count { + color: #aaa; +} + .ytpf-body { overflow-x: auto; } @@ -123,18 +246,27 @@ html[dark] .ytpf-filter-select option { padding: 8px 12px; font-weight: 500; font-size: 12px; - color: var(--yt-spec-text-secondary); - border-bottom: 1px solid var(--yt-spec-10-percent-layer); + color: var(--yt-spec-text-secondary, #606060); + border-bottom: 1px solid var(--yt-spec-10-percent-layer, rgba(0,0,0,0.1)); white-space: nowrap; } +html[dark] .ytpf-th { + color: #aaa; + border-bottom-color: rgba(255,255,255,0.1); +} + .ytpf-th--sortable { cursor: pointer; user-select: none; } .ytpf-th--sortable:hover { - color: var(--yt-spec-text-primary); + color: var(--yt-spec-text-primary, #0f0f0f); +} + +html[dark] .ytpf-th--sortable:hover { + color: #f1f1f1; } .ytpf-th--sortable::after { @@ -152,15 +284,23 @@ html[dark] .ytpf-filter-select option { .ytpf-td { padding: 6px 12px; - border-bottom: 1px solid var(--yt-spec-10-percent-layer); + border-bottom: 1px solid var(--yt-spec-10-percent-layer, rgba(0,0,0,0.1)); vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +html[dark] .ytpf-td { + border-bottom-color: rgba(255,255,255,0.1); +} + .ytpf-tr:hover { - background: var(--yt-spec-badge-chip-background); + background: var(--yt-spec-badge-chip-background, #f2f2f2); +} + +html[dark] .ytpf-tr:hover { + background: #272727; } .ytpf-col-index { width: 48px; text-align: right; } @@ -178,10 +318,14 @@ html[dark] .ytpf-filter-select option { } .ytpf-link { - color: var(--yt-spec-text-primary); + color: var(--yt-spec-text-primary, #0f0f0f); text-decoration: none; } +html[dark] .ytpf-link { + color: #f1f1f1; +} + .ytpf-link:hover { text-decoration: underline; } diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index 8591cd8..0170927 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -19,6 +19,172 @@ const allColumns: Column[] = [ { label: "Votes", 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 => { @@ -146,35 +312,23 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { const titleInput = document.createElement("input"); titleInput.className = "ytpf-filter-input"; titleInput.type = "text"; + titleInput.name = "ytpf-title-filter"; titleInput.placeholder = "タイトル検索..."; filters.appendChild(titleInput); - const channelInput = document.createElement("input"); - channelInput.className = "ytpf-filter-input"; - channelInput.type = "text"; - channelInput.placeholder = "チャンネル検索..."; - filters.appendChild(channelInput); + // Channel tag input + const channelNames = [...new Set(data.videos.map((v) => v.channel.name))].sort(); + const channelTagInput = createTagInput("チャンネル...", channelNames, () => applyFilters()); + filters.appendChild(channelTagInput.container); + // Added-by tag input (collab only) + let addedByTagInput: TagInput | null = null; if (isCollab) { - const addedBySelect = document.createElement("select"); - addedBySelect.className = "ytpf-filter-select"; - const allOption = document.createElement("option"); - allOption.value = ""; - allOption.textContent = "追加者: すべて"; - addedBySelect.appendChild(allOption); - const addedByNames = [...new Set( data.videos.map((v) => v.addedBy).filter((n): n is string => n != null), )].sort(); - for (const name of addedByNames) { - const opt = document.createElement("option"); - opt.value = name; - opt.textContent = name; - addedBySelect.appendChild(opt); - } - filters.appendChild(addedBySelect); - - addedBySelect.addEventListener("change", applyFilters); + addedByTagInput = createTagInput("追加者...", addedByNames, () => applyFilters()); + filters.appendChild(addedByTagInput.container); } const filterCount = document.createElement("span"); @@ -213,14 +367,13 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { function getFilteredVideos(): PlaylistVideo[] { const titleQuery = titleInput.value.toLowerCase(); - const channelQuery = channelInput.value.toLowerCase(); - const addedBySelect = filters.querySelector(".ytpf-filter-select"); - const addedByFilter = addedBySelect?.value ?? ""; + const channelTags = channelTagInput.getTags(); + const addedByTags = addedByTagInput?.getTags() ?? []; return videos.filter((v) => { if (titleQuery && !v.title.toLowerCase().includes(titleQuery)) return false; - if (channelQuery && !v.channel.name.toLowerCase().includes(channelQuery)) return false; - if (addedByFilter && v.addedBy !== addedByFilter) 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; }); } @@ -241,7 +394,6 @@ export function renderPlaylistTable(data: PlaylistData): HTMLElement { } titleInput.addEventListener("input", applyFilters); - channelInput.addEventListener("input", applyFilters); renderRows();