From 0e1a36f7b35324f3f1c04885661cf582ecd4403a Mon Sep 17 00:00:00 2001 From: kyeunga25 <49793173+kyeunga25@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:23:58 +0800 Subject: [PATCH] (feat): add compact local song navigation --- CHANGELOG.md | 6 ++ README.md | 4 +- docs/ARCHITECTURE.md | 5 +- docs/PROJECT_PLAN.md | 1 + docs/QA_V1_47_SONG_NAVIGATION.md | 25 +++++++ package-lock.json | 4 +- package.json | 2 +- src/components/theme/ThemeCard.astro | 3 +- src/components/theme/ThemeNavigation.astro | 73 ++++++++++++++++++++ src/pages/anime/[slug].astro | 2 + tests/e2e/phase1.spec.ts | 80 ++++++++++++++++++++++ 11 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 docs/QA_V1_47_SONG_NAVIGATION.md create mode 100644 src/components/theme/ThemeNavigation.astro diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a169af..343f870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable public product changes are recorded here. Release notes contain only product scope and reproducible validation; platform identifiers and private operational details are excluded. +## v1.47.0 — 作品頁歌曲導覽 + +- 超過 4 筆歌曲的作品頁新增可收合的頁內導覽,按 OP/ED 分組,完整顯示曲名與演唱者並直接跳到對應版本。 +- 長清單限制展開高度,保留鍵盤捲動、選歌焦點、瀏覽器返回、無 JavaScript 與離線操作;目前指向的歌曲以邊框提示。 +- 全域導覽、目錄資料與靜態 API 保持不變。 + ## v1.46.0 — 2019 春季《強襲魔女 501部隊出動!》 - 收錄 2019 年 TV 短篇、1 首 OP 與 12 個輪替 ED 演唱版本,保留各自角色、聲優、CD 日期及歌曲用途。 diff --git a/README.md b/README.md index f4491bc..ee0b6f4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Anisonary 是以 Astro + strict TypeScript 建立的動畫歌曲目錄,按季 This repository contains the completed static product: season directory, anime detail pages, traceable OP/ED credits and links, per-song source ledgers, source-attributed media, local-only cross-season search, privacy-bounded offline reading, a GitHub correction flow, and deployment through Cloudflare Workers Static Assets. The default catalogue covers twenty-nine reviewed snapshots across 2019–2026, with 1,948 unique titles and 4,320 known OP/ED records. The 2019 spring snapshot contains thirty-one TV titles, including television shorts and a TV recut; summer contains 38 TV titles and four web series. Both quarters are still being expanded, and 2025 fall is not yet included. Fictional Mock Data remains test-only. -目前 source 版本:**v1.46.0**。新增《強襲魔女 501部隊出動!》2019 年 TV 版、1 首 OP、12 個輪替 ED 演唱版本與 2 筆官方影片 metadata。目錄共有 29 個季度、1,948 套作品與 4,320 筆 OP/ED;春季為 31 套作品、91 筆歌曲,春、夏季仍在補充,2025 秋季尚未收錄。 +目前 source 版本:**v1.47.0**。歌曲較多的作品頁提供可收合的 OP/ED 導覽,按曲名與演唱者直接選擇版本;鍵盤、無 JavaScript 與離線閱讀均可使用。目錄共有 29 個季度、1,948 套作品與 4,320 筆 OP/ED;春季為 31 套作品、91 筆歌曲,春、夏季仍在補充,2025 秋季尚未收錄。 導覽延續固定五個入口與年代、年份、季度目錄;搜尋可按動畫、歌曲、創作者、年份、季度及 OP/ED 篩選,每頁顯示 12 套作品。搜尋完全在瀏覽器內進行,YouTube 只於明確操作後連線。網站與同源靜態 JSON API 共用已審閱資料,無 application backend 或 database binding。依賴安全 gate 繼續鎖定 `fast-uri` 3.1.6 並稽核開發依賴。 @@ -12,7 +12,7 @@ Production build 會從最終 HTML 自動產生 hash-based Content Security Poli | 可用性 / Availability | 成熟度 / Maturity | 證據 / Evidence | | -------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 公開靜態目錄 / Public static catalogue | Source 版本為 `v1.46.0`;正式發布狀態以對應 Release 驗收摘要為準 | [入口網站 / Live](https://anisonary.k-y.cc) · [版本與驗收 / Releases](https://github.com/kyeunga25/anisonary/releases) · [資料來源 / Sources](docs/DATA_SOURCES.md) · [安全政策 / Security](SECURITY.md) · [授權 / Licence](LICENSING.md) · [版權 / Copyright](COPYRIGHT.md) | +| 公開靜態目錄 / Public static catalogue | Source 版本為 `v1.47.0`;正式發布狀態以對應 Release 驗收摘要為準 | [入口網站 / Live](https://anisonary.k-y.cc) · [版本與驗收 / Releases](https://github.com/kyeunga25/anisonary/releases) · [資料來源 / Sources](docs/DATA_SOURCES.md) · [安全政策 / Security](SECURITY.md) · [授權 / Licence](LICENSING.md) · [版權 / Copyright](COPYRIGHT.md) | ## 技術棧|Technology stack diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46010c8..4c2c52f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,9 +31,9 @@ Astro output ──► generated CSP ──► bounded service worker ──► | `src/data/curated-season-registry.ts` | 集中登記季度顯示順序、季度索引與擁有者模組,並在載入時拒絕重複、缺失或跨季度漂移 | | `src/data/curated-seeds.ts` | 保留既有匯入介面及作品順序的相容聚合層 | | `src/data/curated-theme-sources//.ts` | 按作品 owner 季度分檔的逐歌曲公開來源 ledger;只保存未加入核對日期的來源 seed | -| `src/data/curated-theme-sources.ts` | 合併二十七季來源 ledger,拒絕錯誤 owner、重複 key、非 HTTPS、URL credentials、未知語言/角色及私人核對欄位 | +| `src/data/curated-theme-sources.ts` | 合併已登記季度的來源 ledger,拒絕錯誤 owner、重複 key、非 HTTPS、URL credentials、未知語言/角色及私人核對欄位 | | `src/data/curated-theme-videos//.ts` | 按作品 owner 季度分檔的已審閱官方影片 metadata;保留影片種類、頻道、官方狀態及嵌入設定 | -| `src/data/curated-theme-videos.ts` | 合併二十七季影片 override,拒絕錯誤 owner、重複 key、無效或同曲重複的 YouTube ID、空欄位及未知欄位 | +| `src/data/curated-theme-videos.ts` | 合併已登記季度的影片 override,拒絕錯誤 owner、重複 key、無效或同曲重複的 YouTube ID、空欄位及未知欄位 | | `src/data/curated-data.ts` | 其餘少量可審核 override 與公開 record 派生 | | `src/types/public-api.ts` | HTML 與 static API 共用的公開契約 | | `src/data/api-provider.ts` | 外部 JSON 的 fail-closed parser 與 request boundary | @@ -44,6 +44,7 @@ Astro output ──► generated CSP ──► bounded service worker ──► | `src/pages/catalog/` | 固定入口下的年代目錄與年份靜態頁 | | `src/scripts/catalog-search.ts` | 本機搜尋、每頁最多 12 套作品的 DOM、歌曲 anchor 導航與署名入口條件 | | `src/utils/creator-search.ts` | 只使用同源網址片段的署名搜尋連結,與輸入共用 80 UTF-16 單位上限 | +| `src/components/theme/ThemeNavigation.astro` | 由作品現有歌曲派生的 OP/ED 頁內導覽;超過 4 筆時使用原生收合區塊與固定歌曲 anchor,不新增腳本或資料來源 | | `src/pages/api/v1/` | build-time 靜態 JSON routes | | `scripts/generate-security-headers.mjs` | 由 build output 產生 hash-based CSP | | `scripts/generate-service-worker.mjs` | 由 build output 產生同源、無 runtime write 的離線清單 | diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 98d6646..461d83d 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -79,6 +79,7 @@ - **v1.44.1 source**:縮短作品頁資料、來源連結與歌曲分區間距,保留海報尺寸、文字層級及全部連結;目錄、搜尋與 API 不變,發布證據見對應 GitHub Release。 - **v1.45.0 source**:歌曲署名直接開啟本機創作者文字搜尋,保留完整名稱及既有字數上限;入口條件使用不會送出的網址片段,手動編輯時清除,資料、API 與全域導覽數量不變;發布證據見對應 GitHub Release。 - **v1.46.0 source**:2019 春季增加《強襲魔女 501部隊出動!》TV 短篇、13 筆 OP/ED 與 2 筆官方影片 metadata;12 個片尾演唱版本各自保留,作曲署名衝突欄位暫缺,春季共 31 套作品與 91 筆歌曲;發布證據見對應 GitHub Release。 +- **v1.47.0 source**:歌曲超過 4 筆的作品新增可收合 OP/ED 導覽,完整列出曲名與演唱者,保留原有深連結、鍵盤焦點及無 JavaScript/離線閱讀;長清單高度受限,資料、API 與全域導覽數量不變;發布證據見對應 GitHub Release。 - 正式網站使用 custom domain;非正式 Cloudflare hostname 不在公開文檔記錄; - 私有 backend、資料庫、crawler、帳戶設定、憑證與內部規則不屬於本公開 repository。 diff --git a/docs/QA_V1_47_SONG_NAVIGATION.md b/docs/QA_V1_47_SONG_NAVIGATION.md new file mode 100644 index 0000000..6804295 --- /dev/null +++ b/docs/QA_V1_47_SONG_NAVIGATION.md @@ -0,0 +1,25 @@ +# v1.47.0|作品頁歌曲導覽 QA + +核對日期:2026-09-07。本文件記錄 source 範圍與本機驗證;正式部署須於固定版本合併後獨立驗收。 + +## 介面範圍 + +- 超過 4 筆歌曲的作品頁新增原生可收合導覽;目前涵蓋 140 套作品,最長清單為 25 筆。按 OP/ED 分組,完整顯示曲名與演唱者,沿用既有歌曲深連結。 +- 導覽預設收合,展開高度限制為 26rem 或畫面高度 55% 的較小值;手機單欄、桌面雙欄,長名稱完整換行,清單可使用原生捲動與鍵盤。 +- 選歌後將焦點移到該歌曲卡片,顯示目標邊框;下一個 Tab 進入歌曲的連結。瀏覽器返回保留同頁導覽展開狀態,關閉 JavaScript 及離線頁面仍可選歌。 +- 0–4 筆歌曲不顯示額外導覽。全域選單數量、來源、曲名、credits、影片 metadata 與 API 不變;沒有新增客戶端腳本、第三方請求、runtime backend 或儲存項目。 + +## 本機驗收 + +- `npm run catalog:check`:228 項通過。 +- `npm run check`:公開邊界、Lint、331 項單元測試、建置、Cloudflare dry-run 與 42 項 Playwright 測試通過;最終瀏覽器測試沒有重試。 +- 完整依賴與正式依賴稽核均為 0 項漏洞;`fast-uri` 保持 3.1.6,既有安全回歸通過。 +- 1,978 份靜態 API 與 v1.46.0 source 逐位元相同。逐頁核對全部 1,948 套作品:140 套的 1,125 個導覽連結與已審閱歌曲次序相同,所有目標可聚焦,沒有重複 DOM ID。 +- 新增 E2E 涵蓋 1280、960、390、320px 的收合、展開、OP/ED 與演唱者辨識、選歌、焦點、Tab、瀏覽器返回及小清單空缺;另以 390px、停用 JavaScript 核對最長 25 筆清單的最後歌曲。 +- 既有離線測試增加原生歌曲導覽與 ED #12 跳轉;搜尋、資料來源展開、影片明確同意、404、錯誤狀態與鍵盤導覽回歸通過。 +- Chromium 檢查兩套長清單作品、四種寬度及 390px 無 JavaScript,合共 30 個收合/展開/目標畫面狀態;頁面身份、主標題、焦點及高度符合預期,沒有水平溢出、框架錯誤、console error 或操作前的 iframe/YouTube 請求。另人工檢視桌面、手機與手機深色模式截圖。 +- 建置產生 1,992 份 HTML、3,983 份公開產物;Cloudflare dry-run 沒有 runtime bindings。 + +## 發布界線 + +本機與分支建置結果不代表正式網站已更新。發布時仍須核對合併 SHA、CI、Cloudflare 實際版本與流量,以及 live routes、headers、API 與產物內容。本切片未驗證實體觸控裝置、Safari、Firefox、Lighthouse/Core Web Vitals 或實際 YouTube 播放。 diff --git a/package-lock.json b/package-lock.json index 1c2da14..7970680 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "anisonary", - "version": "1.46.0", + "version": "1.47.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "anisonary", - "version": "1.46.0", + "version": "1.47.0", "license": "SEE LICENSE IN LICENSING.md", "dependencies": { "@astrojs/sitemap": "^3.7.3", diff --git a/package.json b/package.json index 16f4944..aecb53f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anisonary", - "version": "1.46.0", + "version": "1.47.0", "private": true, "license": "SEE LICENSE IN LICENSING.md", "type": "module", diff --git a/src/components/theme/ThemeCard.astro b/src/components/theme/ThemeCard.astro index ea791df..222356f 100644 --- a/src/components/theme/ThemeCard.astro +++ b/src/components/theme/ThemeCard.astro @@ -37,7 +37,7 @@ const sourceRoleLabels = { } as const; --- -
+
{theme.type}{theme.sequence} @@ -112,6 +112,7 @@ const sourceRoleLabels = { diff --git a/src/pages/anime/[slug].astro b/src/pages/anime/[slug].astro index 8cd3cfd..cfb8026 100644 --- a/src/pages/anime/[slug].astro +++ b/src/pages/anime/[slug].astro @@ -5,6 +5,7 @@ import MockNotice from "@/components/common/MockNotice.astro"; import PosterImage from "@/components/common/PosterImage.astro"; import ThemeCard from "@/components/theme/ThemeCard.astro"; import ThemeEmptyState from "@/components/theme/ThemeEmptyState.astro"; +import ThemeNavigation from "@/components/theme/ThemeNavigation.astro"; import { getDataProvider, shouldRequireApiData } from "@/data/provider"; import { loadCatalogSearchData } from "@/data/page-data"; import BaseLayout from "@/layouts/BaseLayout.astro"; @@ -123,6 +124,7 @@ catalogCorrectionUrl.searchParams.set("title", `資料修正:${anime.titleJa}`

主題曲

+ {anime.themes.length > 0 ? (
{anime.themes.map((theme) => )}
) : ( diff --git a/tests/e2e/phase1.spec.ts b/tests/e2e/phase1.spec.ts index 310d051..4ef3831 100644 --- a/tests/e2e/phase1.spec.ts +++ b/tests/e2e/phase1.spec.ts @@ -810,6 +810,7 @@ test("public catalogue remains readable offline without caching personal input", }); expect(registrationScope).toBe(`${e2eOrigin}/`); await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); + await page.goto("/anime/strike-witches-501-takeoff-2019/"); await page.goto("/anime/gonjiro-2019/"); const cachedUrls = await page.evaluate(async () => { @@ -839,6 +840,12 @@ test("public catalogue remains readable offline without caching personal input", await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.getByRole("searchbox", { name: "搜尋動畫、歌曲或創作者" })).toHaveValue("pal@pop"); + await page.goto("/anime/strike-witches-501-takeoff-2019/", { waitUntil: "domcontentloaded" }); + await page.locator(".theme-navigation summary").click(); + await page.getByRole("navigation", { name: "本作品歌曲" }).locator('a[href="#theme-strike-witches-501-takeoff-2019-ed-12"]').click(); + await expect(page.locator("#theme-strike-witches-501-takeoff-2019-ed-12")).toBeFocused(); + await expect(page.locator("iframe")).toHaveCount(0); + await page.goto("/not-cached-while-offline/", { waitUntil: "domcontentloaded" }); await expect(page.getByRole("heading", { name: "目前沒有網絡連線。" })).toBeVisible(); await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex, follow"); @@ -1674,6 +1681,79 @@ test("anime details keep full-width poster-free headings and readable metadata a } }); +test("long song lists provide compact local navigation with keyboard, history and correct ending targets", async ({ page }) => { + const slug = "strike-witches-501-takeoff-2019"; + const mediaRequests: string[] = []; + page.on("request", (item) => { + if (/youtube|ytimg|googlevideo/.test(new URL(item.url()).hostname)) mediaRequests.push(item.url()); + }); + for (const width of [1280, 960, 390, 320]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto(`/anime/${slug}/`); + const disclosure = page.locator(".theme-navigation"); + const summary = disclosure.locator("summary"); + const navigation = page.getByRole("navigation", { name: "本作品歌曲" }); + await expect(disclosure).not.toHaveAttribute("open"); + await expect(summary).toContainText("1 首 OP · 12 首 ED"); + await expect(navigation).toBeHidden(); + await summary.focus(); + await page.keyboard.press("Enter"); + await expect(navigation.getByRole("link")).toHaveCount(13); + const panel = await navigation.boundingBox(); + expect(panel!.height).toBeLessThanOrEqual(844 * 0.55 + 1); + const groupLink = navigation.locator(`a[href="#theme-${slug}-ed-12"]`); + await expect(groupLink).toContainText("Treasure of life #12"); + await expect(groupLink).toContainText("第501統合戦闘航空団"); + await groupLink.focus(); + await page.keyboard.press("Enter"); + const group = page.locator(`#theme-${slug}-ed-12`); + await expect(group).toBeFocused(); + await expect(group).toBeInViewport({ ratio: 0.3 }); + await expect(page.locator(":target")).toHaveAttribute("id", `theme-${slug}-ed-12`); + await page.keyboard.press("Tab"); + expect(await group.evaluate((element) => element.contains(document.activeElement))).toBe(true); + await page.goBack(); + await expect(disclosure).toHaveAttribute("open"); + await navigation.locator(`a[href="#theme-${slug}-ed-2"]`).click(); + await expect(page.locator(`#theme-${slug}-ed-2`)).toBeFocused(); + await expect(page.locator(`#theme-${slug}-ed-2 .theme-card__artist`)).toHaveText("エーリカ・ハルトマン(CV:野川さくら)"); + await expect(page.locator("iframe")).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + } + expect(mediaRequests).toEqual([]); + for (const slug of ["gonjiro-2019", "kengan-ashura-part-2", "aoi-hane-mitsuketa"]) { + await page.goto(`/anime/${slug}/`); + await expect(page.locator(".theme-navigation")).toHaveCount(0); + } +}); + +test("the largest song list keeps every title reachable with native disclosure and no JavaScript", async ({ browser }) => { + const context = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 844 } }); + try { + const page = await context.newPage(); + await page.goto(`${e2eOrigin}/anime/bessatsu-olympia-kyklos/`); + const disclosure = page.locator(".theme-navigation"); + await expect(disclosure).not.toHaveAttribute("open"); + await disclosure.locator("summary").focus(); + await page.keyboard.press("Enter"); + const navigation = page.getByRole("navigation", { name: "本作品歌曲" }); + await expect(navigation.getByRole("link")).toHaveCount(25); + expect(await navigation.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true); + const lastLink = navigation.getByRole("link").last(); + const target = page.locator((await lastLink.getAttribute("href"))!); + await lastLink.focus(); + await expect(lastLink).toBeInViewport(); + await page.keyboard.press("Enter"); + await expect(target).toBeFocused(); + await expect(target).toBeInViewport({ ratio: 0.3 }); + await expect(page.locator(".theme-card")).toHaveCount(25); + await expect(page.locator("iframe")).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + } finally { + await context.close(); + } +}); + test("501 Takeoff creator search reaches the correct solo ending and keeps the group preview separate across devices", async ({ page, browser, request }) => { const slug = "strike-witches-501-takeoff-2019"; const mediaRequests: string[] = [];