diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..090799c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -c ' *)" + ] + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6e815f8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5503 +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d10c1b0 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,23 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "預覽網站", + "detail": "本機 http://127.0.0.1:5501(等同雙擊 preview.bat)", + "type": "shell", + "command": "${workspaceFolder}\\preview.bat", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": false + }, + "runOptions": { + "instanceLimit": 1 + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e163350 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,103 @@ +# 水晶男孩推廣部 — AI 工作上下文 + +> 這個檔案讓 AI 在任何裝置(家/學校)都能快速恢復上下文。 +> **每次對話做了重要決定,請叫我更新這裡,然後 git commit。** + +--- + +## 專案簡介 + +水晶男孩(Sechs Kies)推廣粉絲網站。靜態 HTML + PHP + 原生 CSS/JS,無框架。 +站主(Noopy)2016 年入坑,藝廊式創作風格。 + +--- + +## 規範速查(詳細見 `developer-rules/rules/`) + +| 規範 | 檔案 | +|------|------| +| 改檔行為守則(必問再改) | `agent-must-ask.md` | +| 變更邊界(只改被點名的) | `ai-change-boundaries.md` | +| CSS 顏色 Token | `color-tokens.md` | +| 版面視覺預設 | `design-defaults.md` | +| Git commit 規範 | `git-commit-required.md` | +| 導覽邏輯(portal-nav) | `portal-nav-rules.md` | +| 文案語調 | `site-voice.md` | + +**最重要的三條:** +1. 沒說「執行」→ 不改檔 +2. 只改被點名的那一頁/區塊 +3. 改完必附 commit 指令(禁止在 sandbox 跑 git) + +--- + +## 架構重點 + +- **全站導覽**:`assets/js/portal-nav.js`(`COMING_SOON_PAGES` 控制開放) +- **全站 CSS**:`assets/css/style.css`(定義所有 color token) +- **首頁**:`index.html` +- **站主索引**(自用):`extra/site-guide.html`(`data-portal-nav-all-open`,不對外) +- **內頁 header class**:`inner-page-header`(禁改回舊名) + +### variety/ 頁面 + +| 檔案 | 頁面標題 | 說明 | +|------|----------|------| +| `variety/bangxiao.html` | 世紀級寶藏 / 爆笑養老院 | 甜蜜負擔計量器;六人卡片選取 + jQuery 計算;CTA → `totoga2/ebook.html` | +| `variety/variety-shows.html` | 全員發瘋 | 綜藝節目存檔表;tabs + 搜尋;已對齊 albums.html 扁平結構 | +| `variety/NJTW/NJTW5.html` | 新西遊記5 | 各自暴走專頁 | + +--- + +## portal-nav 操作守則(血淚版) + +> 每次新增或修改導覽,必須先讀這裡再動手。 + +1. **先查 `extra/site-guide.html` 確認層級**,再動 `portal-nav.js`。 + site-guide 的 depth 數字 = nav 的層級:depth-1 是第一層 `
  • `,depth-2 是子選單項目,depth-3 是 subgroup 裡的巢狀項目。 + +2. **`COMING_SOON_PAGES` 只放檔名**(不含路徑)。 + `normalizePageKey` 只取 href 最後一段,所以 `variety/bangxiao.html` → 比對 `bangxiao.html`。 + +3. **直接連結不需要 subgroup**。 + 子選單內只要 `
  • ` 就夠;`portal-nav__subgroup` 只在該項目還有巢狀子項目時才用(例如「傳奇的轉折」有時間軸、電子書等四項)。 + +4. **site-guide.html 要同步更新**,否則站主索引頁會顯示舊名稱。 + +5. **commit 前網站不會更新**。本地端強制重整用 `Ctrl+Shift+R`;GitHub Pages 需 `git push` 後等約 1 分鐘。 + +--- + +## 目前工作狀態 + + + +**最後更新:** 2026-08-02 + +**進行中:** +- 水晶熱舞社重啟:30TH 報名與水晶練習室練舞清單已接上後臺;播放器本身仍待加入合法影片來源 + +**待確認:** +- developer-rules 健檢尚未執行 + +**已完成:** +- 建立 AGENTS.md 跨裝置上下文檔案 +- 新增 `variety/bangxiao.html`(世紀級寶藏 / 爆笑養老院,甜蜜負擔計量器) +- `variety/variety-shows.html` 重構:移除多層包裝,對齊 albums.html 扁平結構 +- portal-nav 新增世紀級寶藏,正確放入淪陷瞬間子選單 +- `extra/site-guide.html` 同步更新:全員發瘋 → 世紀級寶藏 +- 新增 `admin/` 推廣部後臺第一階段:登入、控制台、管理者帳號、通用 CRUD、媒體上傳及公開狀態總覽 +- 水晶熱舞社 30TH 募集表單已接上 PHP/PDO/MySQL,可儲存報名、參與內容與參考檔案 +- 後臺新增「30TH 應援報名」清單、搜尋、狀態處理、內部備註與受登入保護的檔案下載 +- 架構決定:水晶熱舞社暫時共用推廣部後臺;偶像培訓維持獨立資料邊界,之後另拆專案與登入系統 +- 水晶練習室的「練舞清單」已改為讀取 `dance_practice_items`,後臺可管理歌曲、分類、練習重點、影片網址、難度、進度、優先順序與公開狀態 +- 練舞清單初始資料沿用原頁面的 Couple、Com' Back、Road Fighter;偶像培訓的練習生/課程/紀錄未接入此資料表 + +--- + +## 如何使用這個檔案 + +換裝置後,告訴 AI: +> 「請先讀 AGENTS.md,再讀 developer-rules/rules/ 裡的所有規範,然後繼續 [任務]。」 + +## Imported Claude Cowork project instructions diff --git a/SECHSKIES_2026_Calendar_Notion_Export.md b/SECHSKIES_2026_Calendar_Notion_Export.md new file mode 100644 index 0000000..7ea90b1 --- /dev/null +++ b/SECHSKIES_2026_Calendar_Notion_Export.md @@ -0,0 +1,71 @@ +# 💛 SECHSKIES 2026 水晶男孩專屬萬年曆 & 專案對話總覽 (Notion 格式) + +> **專案說明**:本頁面為水晶男孩(SECHSKIES)推廣網站後端課程「萬年曆」專案統整檔,適合直接複製並貼上至 Notion 頁面。 + +--- + +## 📌 2026 水晶男孩專屬萬年曆事件表 (1 ~ 12 月) + +| 月份 | 日期 | 分類 | 紀念日名稱 | 描述 / 現場圖片與連結 | +| :--- | :--- | :--- | :--- | :--- | +| **1 月** | 01/21 - 01/22 | 🎤 演唱會 | Yellow Note Final 首爾演唱會 | 2017 首爾終場演唱會 (`assets/images/concert/2017.jpg`) | +| **2 月** | 02/22 | 🎂 生日 | **姜成勳 生日** | 1980.02.22 Main Vocal (👉 [查看姜成勳介紹](member/member.html#poster-ksh)) | +| **3 月** | 03/06 - 03/08 | 🎤 演唱會 | ACCESS 首爾演唱會 | 2020 SECHSKIES Concert [ACCESS] (`assets/images/concert/2020.jpg`) | +| | 03/23 | 🌟 出道 | J-Walk 組合出道日 | 2002.03.23 金在德與張水院發行首張專輯《Suddenly》 | +| **4 月** | 04/14 | 🌟 重逢 | 無限挑戰六六歌2 上岩黃海重逢日 | 2016.04.14 上岩游擊演唱會16年後重逢 (`assets/images/totoga2/toto1.png`) | +| | 04/15 | 🌟 出道 | **SECHSKIES 團體出道紀念日** | 1997.04.15 水晶男孩正式出道,傳奇盛世起點! | +| **5 月** | 05/18 | 🌟 紀念 | 水晶男孩告別宣佈日 | 2000.05.18 告別紀念 | +| | 05/24 | 🌟 出道 | 姜成勳 Solo 出道日 | 2001.05.24 首張專輯《In Connection》 | +| **6 月** | 06/08 | 🎂 生日 | **殷志源 生日** | 1978.06.08 Leader · Rap · Dance (👉 [查看殷志源介紹](member/member.html#poster-ejw)) | +| **7 月** | 07/01 | 🎂 生日 | **高志溶 生日** | 1980.07.01 Vocal (👉 [查看高志溶介紹](member/member.html#poster-kjy)) | +| | 07/13 | 🎂 生日 | **李宰鎮 生日** | 1979.07.13 Rap · Dance (👉 [查看李宰鎮介紹](member/member.html#poster-ljj)) | +| | 07/16 | 🎂 生日 | **張水院 生日** | 1980.07.16 Vocal (👉 [查看張水院介紹](member/member.html#poster-jsw)) | +| **8 月** | 08/07 | 🎂 生日 | **金在德 生日** | 1979.08.07 Rap · Dance (👉 [查看金在德介紹](member/member.html#poster-kjd)) | +| **9 月** | 09/10 - 09/11 | 🎤 演唱會 | Yellow Note 演唱會 (二安日) | 2016 黃海 6 分鐘喊聲喚回二安舞台 (`assets/images/concert/2016.jpg`) | +| | 09/23 | 🎤 演唱會 | 20 週年高尺巨蛋演唱會 | 2017 20th Anniversary Concert (`assets/images/concert/2017.jpg`) | +| **10 月** | 10/01 | 🎤 演唱會 | BOF 釜山演唱會 | 2016 60台遊覽車 6000人黃海盛況 (`assets/images/mission-balloon-sea.png`) | +| | 10/07 | 🌟 回歸 | 《三個詞 Three Words》發行日 | 2016.10.07 回歸單曲音源發行日 | +| | 10/13 - 10/14 | 🎤 演唱會 | YELLOFANTASY 22週年演唱會 | 2018 SECHSKIES 2018 CONCERT (`assets/images/concert/2018.jpg`) | +| | 10/27 | 🌟 出道 | 殷志源 Solo 出道日 | 2000.10.27 單曲《A-Ha》/《G Pop》 | +| **11 月** | 11/19 | 🌟 出道 | 李宰鎮 Solo 出道日 | 2001.11.19 首張專輯《S.Double.J》 | + +--- + +## 🎂 全員 6 位成員生日官方權威對照表 + +- 💛 **殷志源 (EUN JI WON)**:1978 年 6 月 8 日 (06/08) +- 💛 **李宰鎮 (LEE JAE JIN)**:1979 年 7 月 13 日 (07/13) +- 💛 **金在德 (KIM JAE DUC)**:1979 年 8 月 7 日 (08/07) *(Namuwiki 查證正名)* +- 💛 **姜成勳 (KANG SUNG HOON)**:1980 年 2 月 22 日 (02/22) +- 💛 **高志溶 (KO JI YONG)**:1980 年 7 月 1 日 (07/01) +- 💛 **張水院 (JANG SU WON)**:1980 年 7 月 16 日 (07/16) + +--- + +## 🔗 萬年曆點擊跳轉對照連結 + +在 `calendar2026.html` 萬年曆頁面中,點擊成員圖片可直接跳轉至成員介紹頁: + +1. **殷志源** ➔ `member/member.html#poster-ejw` +2. **李宰鎮** ➔ `member/member.html#poster-ljj` +3. **金在德** ➔ `member/member.html#poster-kjd` +4. **姜成勳** ➔ `member/member.html#poster-ksh` +5. **高志溶** ➔ `member/member.html#poster-kjy` +6. **張水院** ➔ `member/member.html#poster-jsw` + +--- + +## 🛠️ 本次對話修改與建立檔案 + +- `calendar2026.html`:萬年曆主頁面(含網格計算、篩選標籤、Modal 與圖片連結) +- `assets/css/calendar.css`:萬年曆專用 CSS 樣式 +- `member/member.html`:修正白水晶姜成勳與高志溶肖像圖檔錯置問題 + +--- + +## 💻 Git Commit 指令紀錄 + +```bash +git add calendar2026.html assets/css/calendar.css member/member.html +git commit -m "feat: 完成 2026 水晶專屬萬年曆,更正金在德生日與六顆水晶成員照片" +``` diff --git a/SECHSKIES_Calendar2026_Design_Notes.md b/SECHSKIES_Calendar2026_Design_Notes.md new file mode 100644 index 0000000..e501271 --- /dev/null +++ b/SECHSKIES_Calendar2026_Design_Notes.md @@ -0,0 +1,133 @@ +# 水晶萬年曆 2026 — 設計決策與改版重點 + +> 來源:Cursor 對話串整理(可直接貼 Notion) +> 範圍:`calendar2026.html`、`assets/css/calendar.css` +> 日期:2026-07 + +--- + +## 一、站主設計偏好(從公開頁歸納) + +對照已公開頁(首頁、bangxiao、variety-shows、ebook、NJTW5)讀到的設計感: + +| 面向 | 偏好 | +|------|------| +| 色調 | 頁面黑底,內容落在奶油色 section;金 `#ffcc00` 當點綴/底線,不當整片 UI 底色 | +| 氣氛 | 偶像粉絲紀念冊、藝廊策展,不是生產力工具/SaaS 儀表板 | +| 版型 | 單欄敘事、扁平區塊;卡片只在「要互動」時出現 | +| 元件 | 金色實心膠囊按鈕、淺底 tabs;陰影溫柔;glow 只用在重點字 | +| 文案感 | 溫柔策展、具體回憶,不堆 emoji 狀態列 | + +### 萬年曆初期違和點 + +- 深色玻璃卡片、霓虹色事件點(紅/綠/橘 glow) +- 篩選 chip+emoji(🎂🌟🎤) +- 側欄內嵌捲軸 +- 整體偏「AI 日曆 dashboard」,與全站暖紙面藝廊感不符 + +--- + +## 二、改版原則(站主明確指示) + +### ✅ 可以做 + +- **只改設計感(換皮)**:顏色、字級、陰影、圓角、照片呈現 +- 對齊全站暖紙面 section 視覺 +- 用 color token,避免硬寫霓虹色 + +### ❌ 不可以做 + +- **不要擅自改版型**(例:雙欄改單欄) +- 沒說「執行」前不應改檔(本次站主後來說直接做) +- 只改被點名的頁/區塊 + +> 站主原話重點: +> 「我只覺擬改設計,沒有叫你動版型」 + +--- + +## 三、已落地的視覺調整 + +### 整體皮 + +- 深色玻璃卡 → 暖色 `section` 卡(`--color-surface` / `--color-surface-soft`) +- 控制列與篩選:淺底膠囊+金色 active +- 日期格、週末色、today/selected:低調暖色系 +- 事件點統一品牌金,拿掉多色霓虹 +- Modal 改暖紙底 + +### 版型(維持原狀) + +- `calendar-layout` **維持雙欄**(`1fr 380px`) +- 手機 `max-width: 900px` 才改單欄 + +--- + +## 四、成員/演唱會照片規則(重點) + +### 站主意圖 + +- **要曬成員美照**,不是小頭像+蛋糕 icon +- 成員名字要清楚可見 +- 不要一堆 🎂 之類花字 +- 右側生日卡可上下排(圖上、字下) + +### 裁切規則(A4) + +| 規則 | 說明 | +|------|------| +| 外框比例 | **A4 直式**(`aspect-ratio: 210 / 297`) | +| 填滿方式 | `object-fit: cover`(**不留黑邊**) | +| 縮放原則 | 以 A4 為裁切框,放大縮小盡量保留原圖主體 | +| 對焦 | `object-position: center 28%`(優先臉/上半身) | + +> 站主原話重點: +> 「以 A4 尺寸為裁切尺寸,放大縮小盡量保持原本的圖」 +> 「怎麼有留黑」→ 禁止用 `contain` 造成黑邊 + +### 適用範圍(必須一致) + +- ✅ 右側生日卡 +- ✅ 右側演唱會卡 +- ✅ Modal 演唱會圖 +- ❌ 不可只改生日、漏掉演唱會 + +### 右側生日卡版型 + +- **上下排**:照片在上、名字/英文/日期在下 +- 照片用 A4 比例大圖卡(海報感) +- 標題顯示成員名,不顯示「生日快樂🎂」 + +--- + +## 五、文案/顯示清理 + +- 篩選按鈕拿掉 emoji(成員生日/出道重逢/演唱會) +- 事件標題顯示時清掉 `🎂🌟🎤` +- 生日卡拿掉蛋糕 icon +- Modal 生日標題改成員名 + +--- + +## 六、待確認/可選下一步 + +- [ ] 不同成員圖各自對焦點微調(例:`center 20%` / `center 35%`) +- [ ] 相紙邊框/海報感細節 +- [ ] 字級層級、卡片密度再微調 +- [ ] 是否要把月曆格內生日照再放大 + +--- + +## 七、相關檔案 + +| 檔案 | 用途 | +|------|------| +| `calendar2026.html` | 萬年曆頁面與 JS 邏輯 | +| `assets/css/calendar.css` | 萬年曆專用樣式(本次主要改動) | +| `assets/css/style.css` | 全站 color token | + +--- + +## 八、一句話結論 + +> 萬年曆只換皮、不換版型;右側生日/演唱會照以 **A4 裁切框+cover 填滿** 曬海報,不留黑邊、不堆蛋糕 emoji,成員名字要清楚。 diff --git a/SECHSKIES_Crystal_Dance_Survey_Notion_Export.md b/SECHSKIES_Crystal_Dance_Survey_Notion_Export.md new file mode 100644 index 0000000..74d2b85 --- /dev/null +++ b/SECHSKIES_Crystal_Dance_Survey_Notion_Export.md @@ -0,0 +1,170 @@ +# 水晶熱舞社頁面改版對話紀錄 + +> 來源:`yellow-note/crystal-dance-survey.html` 改版討論 +> 日期:2026-07-09 +> 用途:可貼上 Notion 的整理稿 + +--- + +## 一、任務目標 + +把 **水晶熱舞社** 頁(`yellow-note/crystal-dance-survey.html`)從「自創黃框、多層卡片、閱讀擁擠」改成: + +1. **對齊首頁/全站簡約風格**(黑底 + 白卡片 + 品牌黃) +2. **多套用全站 CSS**(`style.css` 的 `section`、`.section-title`、`.card` 語彙) +3. **熱舞社相關內容不刪**(可改外觀與排版) +4. **Bootstrap class 必須保留**(作業要求) +5. **表單區參考「見面會應援調查表」的 summer-form 邊框排版**,顏色仍用全站黃色 token + +--- + +## 二、核心原則(這次定下來的) + +| 原則 | 說明 | +|------|------| +| 內容優先 | 跟熱舞社相關的資訊不要刪;外觀排版可以改 | +| 只改被點名的頁 | 主要動 `crystal-dance-survey.html` | +| Bootstrap 保留 | `row` / `col` / `form-control` / `form-select` / `btn-check` / `ratio` 等 class 留下 | +| 顏色不動 | 用全站 `--color-brand` 系列,不改成參考圖的藍 | +| 美感對齊 | 參考 index 簡約乾淨 + 參考圖的留白與分階段框線 | + +--- + +## 三、舊版問題診斷 + +### 為什麼「怎麼看都不順眼」 + +1. **雙重/三重卡片** + 全站 `section` 本身已是圓角白卡片,頁內又加 `video-card`、`song-card`、`form-panel` → 盒子套盒子。 + +2. **約 400 行 inline CSS 重做設計系統** + 全站已有 `.card`、`.section-title`,卻自創多套黃框樣式。 + +3. **`info-card` 深色漸層突兀** + 跟全站暖白、輕盈感相反。 + +4. **導覽太吵** + 五顆黃色 pill 按鈕搶眼;首頁錨點是低調文字列。 + +5. **UX 問題(後續回饋)** + - 表單左右分欄難讀 + - 標題跟內容距離太近 + - 主影片寬度沒跟下方三支影片左右對齊 + - 文字擠在一起、閱讀節奏差 + +--- + +## 四、結構重組(6 區 → 3 區) + +### 備份 + +``` +yellow-note/crystal-dance-survey.backup-2026-07-09.html +``` + +(本地備份;改動前版本) + +### 新架構 + +| 新區塊 | ID | 合併來源 | 合併理由 | +|--------|-----|----------|----------| +| ① 認識水晶熱舞社 | `#about` | Hero + 熱舞社介紹 | 都在回答「這是什麼」 | +| ② 從 20TH 到 30TH | `#from-20th-to-30th` | 20TH 影片回顧 + 30TH 應援募集敘事 | 同一條時間軸:過去記憶 → 未來募集 | +| ③ 30TH 應援募集表單 | `#form-30th` | 表單區獨立 | Bootstrap 表單作業清楚;敘事講完才行動 | + +### 移除 + +- 整個 **經典舞曲區**(`#song-archive` 四張 song-card) +- 歌曲資訊不丟:表單「想練的歌曲」` +

    帳號清單

    停用帳號後將無法登入

    帳號顯示名稱信箱最後登入狀態操作
    + span { display: block; margin-bottom: 7px; color: var(--color-text-muted); font-size: .78rem; font-weight: 800; } +.detail-item strong, .detail-item a, .detail-value { color: var(--color-text-dark); overflow-wrap: anywhere; } +.detail-value { line-height: 1.75; } +.detail-download { width: fit-content; max-width: 100%; white-space: normal; text-align: left; } + +.auth-page { display: grid; place-items: center; padding: 24px; color: var(--color-text-inverse); background: var(--color-bg); background-image: radial-gradient(circle at 20% 10%, rgba(255, 204, 0, .16), transparent 32%), radial-gradient(circle at 80% 90%, rgba(252, 238, 167, .1), transparent 34%); } +.auth-card { position: relative; width: min(500px, 100%); padding: clamp(28px, 6vw, 48px); overflow: hidden; border: 1px solid rgba(255, 204, 0, .35); border-radius: 20px; background: var(--color-bg-raised); box-shadow: 0 26px 80px rgba(0, 0, 0, .45); } +.auth-card::before { content: ""; position: absolute; inset: 0 0 auto; height: 4px; background: linear-gradient(90deg, transparent, var(--color-brand), transparent); } +.auth-brand { display: flex; flex-direction: column; align-items: center; gap: 9px; width: 100%; margin: 0 auto 24px; color: var(--color-brand-light); text-decoration: none; } +.auth-brand img { display: block; width: min(340px, 92%); height: auto; object-fit: contain; } +.auth-brand span { font-size: .68rem; font-weight: 800; letter-spacing: .2em; text-align: center; } +.auth-card h1 { margin: 7px 0 10px; color: var(--color-text-inverse); text-align: center; } +.auth-card > p { color: var(--color-brand-light); text-align: center; } +.auth-card .eyebrow { color: var(--color-brand); } +.auth-card form { display: flex; flex-direction: column; gap: 17px; margin-top: 28px; } +.auth-card label { display: flex; flex-direction: column; gap: 7px; color: var(--color-text-inverse); font-size: .9rem; } +.auth-card input { color: var(--color-text-inverse); background: var(--color-bg); border-color: rgba(255, 204, 0, .36); } +.auth-card .text-link { color: var(--color-brand-light); text-align: center; font-size: .86rem; } + +@media (max-width: 1100px) { + .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .module-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .inline-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 760px) { + .admin-topbar { padding-inline: 16px; } + .admin-brand small, .admin-topbar__actions span { display: none; } + .admin-brand__logo { width: min(190px, 48vw); } + .admin-shell { display: block; } + .admin-sidebar { position: fixed; z-index: 30; inset: 68px auto 0 0; width: min(290px, 86vw); transform: translateX(-105%); transition: transform .2s ease; overflow-y: auto; } + .admin-sidebar.is-open { transform: translateX(0); } + .admin-main { padding: 20px 16px 36px; } + .sidebar-toggle { display: inline-flex; align-items: center; justify-content: center; width: 44px; margin-bottom: 18px; } + .toolbar { align-items: stretch; flex-direction: column; } + .filters { display: grid; grid-template-columns: 1fr; } + .filters input, .filters select { width: 100%; } + .stat-grid, .module-grid, .form-grid, .inline-form, .detail-grid { grid-template-columns: 1fr; } + .field--wide, .detail-item--wide { grid-column: auto; } +} diff --git a/admin/assets/admin.js b/admin/assets/admin.js new file mode 100644 index 0000000..f25cf98 --- /dev/null +++ b/admin/assets/admin.js @@ -0,0 +1,9 @@ +document.querySelector('[data-sidebar-toggle]')?.addEventListener('click', function () { + document.querySelector('#admin-sidebar')?.classList.toggle('is-open'); +}); + +document.querySelectorAll('form[data-confirm]').forEach(function (form) { + form.addEventListener('submit', function (event) { + if (!window.confirm(form.dataset.confirm || '確定執行?')) event.preventDefault(); + }); +}); diff --git a/admin/bootstrap.php b/admin/bootstrap.php new file mode 100644 index 0000000..0fd7bfc --- /dev/null +++ b/admin/bootstrap.php @@ -0,0 +1,312 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + return $pdo; +} + +function h(?string $value): string +{ + return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +function redirect(string $path): never +{ + header('Location: ' . $path); + exit; +} + +function csrf_token(): string +{ + if (empty($_SESSION['csrf'])) { + $_SESSION['csrf'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf']; +} + +function verify_csrf(): void +{ + $token = (string) ($_POST['csrf'] ?? ''); + if (!hash_equals((string) ($_SESSION['csrf'] ?? ''), $token)) { + http_response_code(419); + exit('表單已過期,請返回後重新操作。'); + } +} + +function require_login(): void +{ + if (empty($_SESSION['admin_id'])) { + redirect('login.php'); + } +} + +function flash(string $type, string $message): void +{ + $_SESSION['flash'] = ['type' => $type, 'message' => $message]; +} + +function take_flash(): ?array +{ + $flash = $_SESSION['flash'] ?? null; + unset($_SESSION['flash']); + return is_array($flash) ? $flash : null; +} + +function modules(): array +{ + return [ + 'albums' => [ + 'label' => '專輯管理', 'icon' => 'disc', 'table' => 'albums', 'order' => 'release_date DESC, id DESC', + 'fields' => [ + 'title' => ['label' => '專輯名稱', 'type' => 'text', 'required' => true], + 'title_ko' => ['label' => '韓文/英文名稱', 'type' => 'text'], + 'release_date' => ['label' => '發行日期', 'type' => 'date'], + 'era' => ['label' => '時期', 'type' => 'select', 'options' => ['classic' => '輝煌全盛期', 'reunion' => '啟動新篇章']], + 'cover_media_id' => ['label' => '封面媒體編號', 'type' => 'number'], + 'summary' => ['label' => '專輯簡介', 'type' => 'textarea'], + 'sort_order' => ['label' => '排序', 'type' => 'number', 'default' => '0'], + 'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true], + ], + 'list' => ['title', 'release_date', 'era', 'status', 'updated_at'], + ], + 'songs' => [ + 'label' => '歌曲管理', 'icon' => 'music-note-list', 'table' => 'songs', 'order' => 'album_id, track_no, id', + 'fields' => [ + 'album_id' => ['label' => '所屬專輯', 'type' => 'relation', 'table' => 'albums', 'required' => true], + 'track_no' => ['label' => '曲序', 'type' => 'number'], + 'title' => ['label' => '歌名', 'type' => 'text', 'required' => true], + 'title_ko' => ['label' => '韓文歌名', 'type' => 'text'], + 'lyrics_url' => ['label' => '歌詞頁連結', 'type' => 'url'], + 'video_url' => ['label' => '影片連結', 'type' => 'url'], + 'is_title_track' => ['label' => '主打歌', 'type' => 'checkbox'], + 'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true], + ], + 'list' => ['title', 'album_id', 'track_no', 'is_title_track', 'status', 'updated_at'], + ], + 'concerts' => [ + 'label' => '演唱會管理', 'icon' => 'ticket-perforated', 'table' => 'concert_events', 'order' => 'event_date DESC, id DESC', + 'fields' => [ + 'tour_name' => ['label' => '巡演/活動名稱', 'type' => 'text', 'required' => true], + 'event_date' => ['label' => '演出日期', 'type' => 'date', 'required' => true], + 'country' => ['label' => '國家', 'type' => 'text'], + 'city' => ['label' => '城市', 'type' => 'text'], + 'venue' => ['label' => '場館', 'type' => 'text'], + 'member_names' => ['label' => '演出成員', 'type' => 'text'], + 'poster_media_id' => ['label' => '海報媒體編號', 'type' => 'number'], + 'source_url' => ['label' => '資料來源', 'type' => 'url'], + 'notes' => ['label' => '備註', 'type' => 'textarea'], + 'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true], + ], + 'list' => ['tour_name', 'event_date', 'city', 'venue', 'status', 'updated_at'], + ], + 'variety' => [ + 'label' => '綜藝節目管理', 'icon' => 'tv', 'table' => 'variety_shows', 'order' => 'air_start DESC, id DESC', + 'fields' => [ + 'title' => ['label' => '節目名稱', 'type' => 'text', 'required' => true], + 'series_name' => ['label' => '系列/單元', 'type' => 'text'], + 'platform' => ['label' => '播出平台', 'type' => 'text'], + 'air_start' => ['label' => '開始日期', 'type' => 'date'], + 'air_end' => ['label' => '結束日期', 'type' => 'date'], + 'member_names' => ['label' => '參與成員', 'type' => 'text'], + 'episode_info' => ['label' => '集數', 'type' => 'text'], + 'video_url' => ['label' => '影片連結', 'type' => 'url'], + 'summary' => ['label' => '節目簡介', 'type' => 'textarea'], + 'tags' => ['label' => '標籤', 'type' => 'text'], + 'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true], + ], + 'list' => ['title', 'platform', 'air_start', 'member_names', 'status', 'updated_at'], + ], + 'locations' => [ + 'label' => 'Yellow Note', 'icon' => 'geo-alt', 'table' => 'locations', 'order' => 'visit_date DESC, sort_order, id', + 'fields' => [ + 'title' => ['label' => '地點名稱', 'type' => 'text', 'required' => true], + 'visit_date' => ['label' => '行程日期', 'type' => 'date'], + 'day_label' => ['label' => '日次', 'type' => 'text'], + 'address' => ['label' => '地址', 'type' => 'text'], + 'latitude' => ['label' => '緯度', 'type' => 'number', 'step' => 'any'], + 'longitude' => ['label' => '經度', 'type' => 'number', 'step' => 'any'], + 'member_names' => ['label' => '參與成員', 'type' => 'text'], + 'description' => ['label' => '行程說明', 'type' => 'textarea'], + 'quote_text' => ['label' => '引言', 'type' => 'textarea'], + 'tags' => ['label' => '標籤', 'type' => 'text'], + 'media_id' => ['label' => '圖片媒體編號', 'type' => 'number'], + 'source_url' => ['label' => '來源連結', 'type' => 'url'], + 'sort_order' => ['label' => '排序', 'type' => 'number', 'default' => '0'], + 'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true], + ], + 'list' => ['title', 'visit_date', 'member_names', 'tags', 'status', 'updated_at'], + ], + 'media' => [ + 'label' => '媒體庫', 'icon' => 'images', 'table' => 'media_assets', 'order' => 'id DESC', + 'fields' => [ + 'display_name' => ['label' => '管理名稱', 'type' => 'text', 'required' => true], + 'file_path' => ['label' => '檔案', 'type' => 'file'], + 'media_type' => ['label' => '類型', 'type' => 'select', 'default' => 'image', 'options' => ['image' => '圖片', 'video' => '影片', 'pdf' => 'PDF', 'other' => '其他']], + 'alt_text' => ['label' => '替代文字', 'type' => 'text'], + 'source_note' => ['label' => '來源/授權註記', 'type' => 'textarea'], + 'category' => ['label' => '分類', 'type' => 'text'], + 'status' => ['label' => '使用狀態', 'type' => 'select', 'default' => 'draft', 'options' => ['available' => '可使用', 'draft' => '待整理', 'archived' => '封存']], + ], + 'list' => ['display_name', 'media_type', 'category', 'file_path', 'status', 'updated_at'], + ], + 'dance_practices' => [ + 'label' => '練舞清單', 'item_label' => '練舞項目', 'list_title' => '練舞清單', 'icon' => 'list-check', 'table' => 'dance_practice_items', 'order' => 'sort_order, id', 'group' => 'dance', + 'fields' => [ + 'title' => ['label' => '歌曲名稱', 'type' => 'text', 'required' => true], + 'tags' => ['label' => '練習分類', 'type' => 'text'], + 'practice_focus' => ['label' => '練習重點', 'type' => 'textarea', 'required' => true], + 'video_url' => ['label' => '參考影片網址', 'type' => 'url'], + 'difficulty' => ['label' => '難度', 'type' => 'select', 'default' => 'medium', 'required' => true, 'options' => ['easy' => '入門', 'medium' => '中等', 'hard' => '挑戰']], + 'progress_status' => ['label' => '練習進度', 'type' => 'select', 'default' => 'not_started', 'required' => true, 'options' => ['not_started' => '未開始', 'practicing' => '練習中', 'review' => '待複習', 'completed' => '已完成']], + 'note_text' => ['label' => '補充說明', 'type' => 'text'], + 'is_featured' => ['label' => '優先練習', 'type' => 'checkbox'], + 'sort_order' => ['label' => '排序', 'type' => 'number', 'default' => '0'], + 'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true], + ], + 'list' => ['title', 'difficulty', 'progress_status', 'is_featured', 'sort_order', 'status', 'updated_at'], + ], + ]; +} + +function module_config(string $key): array +{ + $all = modules(); + if (!isset($all[$key])) { + http_response_code(404); + exit('找不到管理模組。'); + } + return $all[$key]; +} + +function status_label(string $status): string +{ + return ['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存', 'available' => '可使用'][$status] ?? $status; +} + +function dance_application_statuses(): array +{ + return [ + 'new' => '新報名', + 'contacted' => '已聯絡', + 'confirmed' => '確認參與', + 'declined' => '暫不參與', + 'archived' => '已封存', + ]; +} + +function dance_attendance_label(?string $value): string +{ + return [ + 'attended' => '參加過 20TH', + 'watched_video' => '看過 20TH 影片', + 'first_time' => '第一次知道', + ][$value ?? ''] ?? '未填寫'; +} + +function dance_song_label(?string $value): string +{ + return [ + 'couple' => 'Couple', + 'comeback' => "Com' Back", + 'road_fighter' => 'Road Fighter', + 'pom_saeng_pom_sa' => 'Pom Saeng Pom Sa', + 'other' => '其他', + ][$value ?? ''] ?? '未選擇'; +} + +function dance_content_labels(?string $value): array +{ + $labels = [ + 'cover' => '翻跳', + 'medley' => '串燒聯舞', + 'blessing' => '祝福影片', + 'graphic' => '圖文應援', + 'editing' => '影片剪輯', + 'archive' => '資料整理', + ]; + $selected = array_filter(array_map('trim', explode(',', (string) $value))); + return array_map(static fn (string $item): string => $labels[$item] ?? $item, $selected); +} + +function audit(string $action, string $entityType, ?int $entityId, array $details = []): void +{ + $stmt = db()->prepare('INSERT INTO audit_logs (admin_user_id, action_name, entity_type, entity_id, details_json, ip_address) VALUES (?, ?, ?, ?, ?, ?)'); + $stmt->execute([$_SESSION['admin_id'] ?? null, $action, $entityType, $entityId, json_encode($details, JSON_UNESCAPED_UNICODE), $_SERVER['REMOTE_ADDR'] ?? null]); +} + +function render_header(string $title, string $active = ''): void +{ + global $config; + $flash = take_flash(); + $mods = modules(); + ?> + + + + + + <?= h($title) ?>|<?= h($config['site_name']) ?> Admin + + + + +
    + + + Admin + + +
    +
    + +
    + +
    +

    CONTENT MANAGEMENT

    + +
    +
    + + + + '水晶男孩推廣部', + 'db' => [ + 'host' => getenv('SECHSKIES_DB_HOST') ?: '127.0.0.1', + 'port' => getenv('SECHSKIES_DB_PORT') ?: '3306', + 'name' => getenv('SECHSKIES_DB_NAME') ?: 'sechskies_cms', + 'user' => getenv('SECHSKIES_DB_USER') ?: 'root', + 'pass' => getenv('SECHSKIES_DB_PASS') ?: '', + ], + 'upload_dir' => dirname(__DIR__) . '/assets/uploads', + 'upload_url' => '../assets/uploads', + 'max_upload_bytes' => 8 * 1024 * 1024, + 'dance_upload_dir' => dirname(__DIR__) . '/storage/dance-applications', + 'dance_max_upload_bytes' => 8 * 1024 * 1024, +]; diff --git a/admin/dance-application-file.php b/admin/dance-application-file.php new file mode 100644 index 0000000..7f6c051 --- /dev/null +++ b/admin/dance-application-file.php @@ -0,0 +1,31 @@ +prepare('SELECT reference_file_name, reference_original_name, reference_mime_type FROM dance_applications WHERE id = ?'); +$stmt->execute([$id]); +$file = $stmt->fetch(); +if (!$file || !$file['reference_file_name']) { + http_response_code(404); + exit('找不到檔案。'); +} + +$storedName = basename((string) $file['reference_file_name']); +$path = (string) $config['dance_upload_dir'] . DIRECTORY_SEPARATOR . $storedName; +if (!is_file($path)) { + http_response_code(404); + exit('檔案不存在。'); +} + +$downloadName = basename((string) ($file['reference_original_name'] ?: $storedName)); +$downloadName = str_replace(["\r", "\n", '"'], '', $downloadName); +audit('download', 'dance_applications', $id, ['file' => $downloadName]); + +header('Content-Type: ' . ((string) $file['reference_mime_type'] ?: 'application/octet-stream')); +header('Content-Length: ' . filesize($path)); +header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($downloadName)); +header('X-Content-Type-Options: nosniff'); +readfile($path); +exit; diff --git a/admin/dance-application.php b/admin/dance-application.php new file mode 100644 index 0000000..434ec1c --- /dev/null +++ b/admin/dance-application.php @@ -0,0 +1,61 @@ +prepare('SELECT * FROM dance_applications WHERE id = ?'); +$stmt->execute([$id]); +$application = $stmt->fetch(); +if (!$application) { + http_response_code(404); + exit('找不到這筆報名資料。'); +} + +$error = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $status = trim((string) ($_POST['status'] ?? '')); + $adminNotes = mb_substr(trim((string) ($_POST['admin_notes'] ?? '')), 0, 5000, 'UTF-8'); + if (!isset($statusOptions[$status])) { + $error = '請選擇有效的處理狀態。'; + } else { + $update = db()->prepare('UPDATE dance_applications SET status = ?, admin_notes = ? WHERE id = ?'); + $update->execute([$status, $adminNotes !== '' ? $adminNotes : null, $id]); + audit('update', 'dance_applications', $id, ['status' => $status, 'admin_notes' => $adminNotes]); + flash('success', '報名資料的處理狀態已更新。'); + redirect('dance-application.php?id=' . $id); + } +} + +render_header('處理熱舞社報名 #' . $id, 'dance-applications'); +?> +
    +
    回報名清單
    + +
    +

    送出於

    +
    +
    暱稱
    +
    Email
    +
    練舞經驗
    +
    可參與日期
    +
    20TH 經驗
    +
    想練的歌曲
    +
    想參與的內容
    +
    30TH 留言
    +
    參考檔案 MB)未上傳
    +
    +
    + +
    + + +
    + + +
    +
    取消
    +
    +prepare('SELECT COUNT(*) FROM dance_applications' . $whereSql); +$countStmt->execute($params); +$total = (int) $countStmt->fetchColumn(); +$pages = max(1, (int) ceil($total / $perPage)); +$page = min($page, $pages); +$offset = ($page - 1) * $perPage; + +$stmt = db()->prepare('SELECT * FROM dance_applications' . $whereSql . " ORDER BY created_at DESC, id DESC LIMIT $perPage OFFSET $offset"); +$stmt->execute($params); +$rows = $stmt->fetchAll(); + +render_header('30TH 應援報名', 'dance-applications'); +?> +
    +
    + + + +
    + 查看募集表單 +
    + +
    +

    報名清單

    筆;Email 與上傳檔案僅供企劃聯絡使用

    +
    + + + + + + + + + + + + + + + + + +
    編號暱稱Email想參與歌曲狀態送出時間操作
    #查看/處理
    目前沒有符合條件的報名資料。
    +
    + 1): ?> + + +
    + $module) { + $stats[$key] = (int) db()->query('SELECT COUNT(*) FROM `' . $module['table'] . '`')->fetchColumn(); +} +$published = 0; +$comingSoon = 0; +foreach (modules() as $key => $module) { + if ($key === 'media') continue; + $published += (int) db()->query("SELECT COUNT(*) FROM `{$module['table']}` WHERE status = 'published'")->fetchColumn(); + $comingSoon += (int) db()->query("SELECT COUNT(*) FROM `{$module['table']}` WHERE status = 'coming_soon'")->fetchColumn(); +} +$recent = db()->query('SELECT a.*, u.display_name FROM audit_logs a LEFT JOIN admin_users u ON u.id = a.admin_user_id ORDER BY a.id DESC LIMIT 8')->fetchAll(); +render_header('控制台', 'dashboard'); +?> +
    +
    已公開內容
    +
    籌備中內容
    +
    媒體素材
    +
    全部內容
    +
    +

    內容概況

    依本站真正的資料分類統計

    + $module): ?> +
    +

    最近管理紀錄

    保留新增、修改、刪除與登入軌跡

    +
    + + +
    時間管理者動作資料類型編號
    目前尚無管理紀錄。
    +prepare('SELECT * FROM admin_users WHERE username = ? AND is_active = 1 LIMIT 1'); + $stmt->execute([trim((string) ($_POST['username'] ?? ''))]); + $user = $stmt->fetch(); + if ($user && password_verify((string) ($_POST['password'] ?? ''), $user['password_hash'])) { + session_regenerate_id(true); + $_SESSION['admin_id'] = (int) $user['id']; + $_SESSION['admin_name'] = $user['display_name']; + db()->prepare('UPDATE admin_users SET last_login_at = NOW() WHERE id = ?')->execute([$user['id']]); + audit('login', 'admin_users', (int) $user['id']); + redirect('index.php'); + } + $error = '帳號或密碼輸入錯誤。'; + } catch (Throwable $e) { + $error = '資料庫尚未完成設定,請先執行初始化。'; + } +} +?> +管理登入|水晶男孩推廣部 +
    SECHSKIES 水晶男孩推廣部CONTENT MANAGEMENT SYSTEM

    SECHSKIES ARCHIVE

    推廣部管理登入

    把散落的記憶,好好收進同一片黃色海洋。

    管理者建立完成,請登入。
    +
    第一次使用?初始化系統
    diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..79d9978 --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,7 @@ + $module) { + if ($key === 'media') continue; + $counts = ['draft' => 0, 'coming_soon' => 0, 'published' => 0, 'archived' => 0]; + foreach (db()->query("SELECT status, COUNT(*) total FROM `{$module['table']}` GROUP BY status")->fetchAll() as $row) $counts[$row['status']] = (int) $row['total']; + $rows[] = ['key' => $key, 'label' => $module['label'], 'counts' => $counts]; +} +render_header('公開狀態', 'publishing'); +?> +
    公開狀態不會直接改寫 portal-nav.js。 第一階段先管理內容狀態;正式同步頂欄前仍須依本站規則逐頁確認。
    +

    各模組發布概況

    公開前先完成資料與來源檢查

    內容模組草稿籌備中公開封存管理
    查看資料
    +prepare('DELETE FROM `' . $module['table'] . '` WHERE id = ?'); +$stmt->execute([$id]); +audit('delete', $module['table'], $id); +flash('success', '資料已刪除。'); +redirect('records.php?module=' . urlencode($key)); diff --git a/admin/record-form.php b/admin/record-form.php new file mode 100644 index 0000000..f6305a2 --- /dev/null +++ b/admin/record-form.php @@ -0,0 +1,82 @@ +prepare('SELECT * FROM `' . $module['table'] . '` WHERE id = ?'); + $stmt->execute([$id]); + $record = $stmt->fetch() ?: []; + if (!$record) { http_response_code(404); exit('資料不存在。'); } +} +$error = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $data = []; + foreach ($module['fields'] as $name => $field) { + if ($field['type'] === 'file') continue; + $value = $field['type'] === 'checkbox' ? (isset($_POST[$name]) ? 1 : 0) : trim((string) ($_POST[$name] ?? '')); + if (!empty($field['required']) && $value === '') $error = '請完成所有必填欄位。'; + $data[$name] = $value === '' ? null : $value; + } + if ($key === 'media' && isset($_FILES['file_path']) && $_FILES['file_path']['error'] !== UPLOAD_ERR_NO_FILE) { + $file = $_FILES['file_path']; + $allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'application/pdf' => 'pdf', 'video/mp4' => 'mp4']; + $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']); + if ($file['error'] !== UPLOAD_ERR_OK || $file['size'] > $config['max_upload_bytes'] || !isset($allowed[$mime])) { + $error = '檔案格式不支援或超過 8 MB。'; + } else { + if (!is_dir($config['upload_dir'])) mkdir($config['upload_dir'], 0755, true); + $safeName = bin2hex(random_bytes(12)) . '.' . $allowed[$mime]; + if (!move_uploaded_file($file['tmp_name'], $config['upload_dir'] . '/' . $safeName)) { + $error = '檔案儲存失敗。'; + } else { + $data['file_path'] = $config['upload_url'] . '/' . $safeName; + $data['original_name'] = $file['name']; + $data['mime_type'] = $mime; + $data['file_size'] = $file['size']; + } + } + } elseif ($key === 'media' && $id) { + $data['file_path'] = $record['file_path']; + } elseif ($key === 'media') { + $error = '新增媒體時必須選擇檔案。'; + } + if ($error === '') { + if ($id) { + $sets = []; + foreach ($data as $name => $_) $sets[] = "`$name` = ?"; + $stmt = db()->prepare('UPDATE `' . $module['table'] . '` SET ' . implode(', ', $sets) . ' WHERE id = ?'); + $stmt->execute([...array_values($data), $id]); + audit('update', $module['table'], $id, $data); + flash('success', '資料已更新。'); + } else { + $names = array_keys($data); + $stmt = db()->prepare('INSERT INTO `' . $module['table'] . '` (`' . implode('`,`', $names) . '`) VALUES (' . implode(',', array_fill(0, count($names), '?')) . ')'); + $stmt->execute(array_values($data)); + $id = (int) db()->lastInsertId(); + audit('create', $module['table'], $id, $data); + flash('success', '資料已新增。'); + } + redirect('records.php?module=' . urlencode($key)); + } + $record = array_merge($record, $data); +} +render_header(($id ? '編輯' : '新增') . ($module['item_label'] ?? $module['label']), $key); +?> +
    +
    + $field): $value = $record[$name] ?? ($field['default'] ?? ''); ?>
    +
    + $field) { + if (in_array($field['type'], ['text', 'textarea', 'url'], true)) $searchable[] = "`$name` LIKE ?"; + } + if ($searchable) { + $where[] = '(' . implode(' OR ', $searchable) . ')'; + foreach ($searchable as $_) $params[] = '%' . $query . '%'; + } +} +if ($status !== '' && isset($module['fields']['status'])) { + $where[] = '`status` = ?'; + $params[] = $status; +} +$whereSql = $where ? ' WHERE ' . implode(' AND ', $where) : ''; +$countStmt = db()->prepare('SELECT COUNT(*) FROM `' . $module['table'] . '`' . $whereSql); +$countStmt->execute($params); +$total = (int) $countStmt->fetchColumn(); +$pages = max(1, (int) ceil($total / $perPage)); +$page = min($page, $pages); +$offset = ($page - 1) * $perPage; +$stmt = db()->prepare('SELECT * FROM `' . $module['table'] . '`' . $whereSql . ' ORDER BY ' . $module['order'] . " LIMIT $perPage OFFSET $offset"); +$stmt->execute($params); +$rows = $stmt->fetchAll(); + +$relations = []; +foreach ($module['fields'] as $name => $field) { + if ($field['type'] === 'relation') { + $relations[$name] = []; + foreach (db()->query("SELECT id, title FROM `{$field['table']}`")->fetchAll() as $rel) $relations[$name][$rel['id']] = $rel['title']; + } +} + +render_header($module['label'], $key); +?> +
    新增資料
    +

    筆;每頁

    + +
    編號操作
    #編輯
    找不到符合條件的資料。
    + 1): ?>
    +query('SELECT COUNT(*) FROM admin_users')->fetchColumn(); + if ($count > 0) { + redirect('login.php'); + } +} catch (Throwable $e) { + $error = '尚未建立資料表。請先在 phpMyAdmin 匯入 admin/database/schema.sql。'; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $error === '') { + verify_csrf(); + $username = trim((string) ($_POST['username'] ?? '')); + $displayName = trim((string) ($_POST['display_name'] ?? '')); + $email = trim((string) ($_POST['email'] ?? '')); + $password = (string) ($_POST['password'] ?? ''); + if (!preg_match('/^[A-Za-z0-9_.-]{4,50}$/', $username)) { + $error = '帳號須為 4–50 個英數字或 ._-。'; + } elseif (mb_strlen($displayName) < 2) { + $error = '請填寫管理者顯示名稱。'; + } elseif (strlen($password) < 10) { + $error = '密碼至少需要 10 個字元。'; + } else { + $stmt = db()->prepare('INSERT INTO admin_users (username, password_hash, display_name, email) VALUES (?, ?, ?, ?)'); + $stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT), $displayName, $email ?: null]); + redirect('login.php?setup=1'); + } +} +?> +初始化後臺 +
    SECHSKIES 水晶男孩推廣部CONTENT MANAGEMENT SYSTEM

    FIRST-TIME SETUP

    建立第一位管理者

    水晶男孩推廣部內容管理系統

    +
    diff --git a/assets/css/calendar.css b/assets/css/calendar.css new file mode 100644 index 0000000..0d8dfbe --- /dev/null +++ b/assets/css/calendar.css @@ -0,0 +1,611 @@ +/* ========================================== + 水晶男孩 2026 萬年曆專用樣式 (calendar.css) + 對齊全站暖紙面 section 視覺 + ========================================== */ + +:root { + --cal-grid-gap: 8px; + --cal-border-soft: rgba(255, 204, 0, 0.18); + --cal-border-strong: rgba(255, 204, 0, 0.28); + --cal-panel-shadow: 0 14px 34px rgba(0, 0, 0, 0.2); +} + +.calendar-page-container { + max-width: 1120px; + margin: 0 auto; + padding: 30px 20px 60px; +} + +/* 控制面板 */ +.calendar-controls { + background: var(--color-surface-soft); + border: 1px solid var(--cal-border-soft); + border-radius: 18px; + padding: 22px; + margin-bottom: 22px; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 14px; + box-shadow: var(--cal-panel-shadow); +} + +.calendar-select-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.calendar-select { + background: #fffef9; + color: var(--color-text-dark); + border: 1px solid rgba(255, 204, 0, 0.38); + border-radius: 999px; + padding: 9px 14px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.calendar-select:hover, +.calendar-select:focus { + border-color: var(--color-brand); + box-shadow: 0 0 0 3px rgba(255, 204, 0, 0.2); +} + +.calendar-btn { + background: var(--color-brand); + color: #1a1a1a; + border: 1px solid transparent; + border-radius: 999px; + padding: 8px 16px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: transform 0.2s ease, background-color 0.2s ease; +} + +.calendar-btn:hover { + background: #ffe066; + transform: translateY(-1px); +} + +/* 分類標籤按鈕列 */ +.calendar-filters { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; +} + +.filter-chip { + background: #fffdf6; + color: var(--color-text-dark); + border: 1px solid rgba(255, 204, 0, 0.38); + border-radius: 999px; + padding: 7px 15px; + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; +} + +.filter-chip.active, +.filter-chip:hover { + background: var(--color-brand); + color: #1a1a1a; + border-color: var(--color-brand); +} + +/* 萬年曆主要區塊:維持原雙欄版型 */ +.calendar-layout { + display: grid; + grid-template-columns: 1fr 380px; + gap: 22px; + align-items: start; +} + +@media (max-width: 900px) { + .calendar-layout { + grid-template-columns: 1fr; + } +} + +/* 日曆卡片區 */ +.calendar-card { + background: var(--color-surface); + border: 1px solid var(--cal-border-soft); + border-radius: 18px; + padding: 24px; + box-shadow: var(--cal-panel-shadow); +} + +.calendar-header-title { + text-align: center; + font-size: 1.52rem; + color: var(--color-text-dark); + margin-bottom: 18px; + font-weight: 900; + letter-spacing: 0.04em; +} + +/* 星期標頭 */ +.calendar-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + text-align: center; + font-weight: 700; + color: var(--color-brand-dark); + padding-bottom: 11px; + border-bottom: 1px solid rgba(255, 204, 0, 0.22); + margin-bottom: 11px; +} + +.calendar-weekday { + padding: 6px 0; +} + +.calendar-weekday.weekend { + color: #9b5e47; +} + +/* 日期網格 */ +.calendar-days-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: var(--cal-grid-gap); +} + +.day-cell { + aspect-ratio: 1; + min-height: 68px; + background: #fffefb; + border: 1px solid rgba(255, 204, 0, 0.12); + border-radius: 10px; + padding: 7px; + position: relative; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: space-between; + transition: all 0.18s ease; +} + +.day-cell:hover { + background: var(--color-brand-light); + border-color: var(--cal-border-strong); + transform: translateY(-1px); +} + +.day-cell.other-month { + opacity: 0.4; + cursor: default; +} + +.day-cell.other-month:hover { + transform: none; + border-color: rgba(255, 204, 0, 0.12); + background: #fffefb; +} + +.day-cell.today { + border: 2px solid var(--color-brand); + box-shadow: inset 0 0 0 1px rgba(255, 204, 0, 0.16); +} + +.day-cell.selected { + background: #fff4c7; + border-color: var(--color-brand); +} + +.day-num { + font-size: 0.94rem; + font-weight: 700; + color: var(--color-text-dark); +} + +.day-cell.weekend .day-num { + color: #8a4e40; +} + +/* 事件標記改為低調金色點 */ +.day-events { + display: flex; + flex-wrap: wrap; + gap: 3px; + margin-top: 4px; +} + +.day-birthday-link { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + margin-top: 2px; + text-decoration: none; +} + +.event-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-brand); + box-shadow: none; +} + +.event-dot.birthday, +.event-dot.debut, +.event-dot.concert { + background: var(--color-brand); +} + +/* 生日頭像縮圖小標籤 */ +.day-avatar-badge { + width: 38px; + height: 38px; + border-radius: 50%; + border: 2px solid var(--color-brand); + object-fit: cover; + margin: 0; + box-shadow: 0 2px 7px rgba(0, 0, 0, 0.18); +} + +.day-member-name { + font-size: 0.68rem; + font-weight: 700; + color: var(--color-text-dark); + line-height: 1; + letter-spacing: 0.01em; +} + +/* 演唱會微縮標籤 */ +.day-concert-badge { + width: 26px; + height: 26px; + border-radius: 4px; + border: 1px solid var(--color-brand); + object-fit: cover; + margin: 2px auto 0; +} + +/* 當月紀念日清單 */ +.events-sidebar { + background: var(--color-surface); + border: 1px solid var(--cal-border-soft); + border-radius: 18px; + padding: 24px; + box-shadow: var(--cal-panel-shadow); +} + +.sidebar-title { + font-size: 1.25rem; + color: var(--color-text-dark); + margin-bottom: 18px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 204, 0, 0.2); + display: flex; + align-items: center; + justify-content: space-between; +} + +.sidebar-title i { + color: var(--color-brand); +} + +.events-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.events-empty { + color: var(--color-text-muted); + text-align: center; + padding: 28px 0; +} + +/* 個別事件卡片 */ +.event-card { + background: var(--color-surface-soft); + border: 1px solid rgba(255, 204, 0, 0.16); + border-left: 4px solid var(--color-brand); + border-radius: 10px; + padding: 14px 16px; + transition: transform 0.2s ease, border-color 0.2s ease; +} + +.event-card:hover { + transform: translateX(2px); + border-color: rgba(255, 204, 0, 0.32); +} + +.event-card.type-birthday, +.event-card.type-debut, +.event-card.type-concert { + border-left-color: var(--color-brand); +} + +.event-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.event-date-badge { + font-size: 0.82rem; + font-weight: 700; + color: var(--color-brand-dark); + background: rgba(255, 204, 0, 0.2); + padding: 3px 9px; + border-radius: 999px; +} + +.event-title { + font-size: 1.02rem; + font-weight: 800; + color: var(--color-text-dark); + margin-bottom: 4px; +} + +.event-desc { + font-size: 0.9rem; + color: var(--color-text-muted); + line-height: 1.48; +} + +.event-media-img { + width: 100%; + max-height: 150px; + object-fit: cover; + border-radius: 8px; + margin-top: 8px; + border: 1px solid rgba(255, 204, 0, 0.24); +} + +/* 生日卡片呈現 */ +.birthday-featured-card { + background: var(--color-surface-soft); + border: 1px solid rgba(255, 204, 0, 0.22); + border-radius: 12px; + padding: 14px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin-bottom: 8px; + text-align: center; +} + +.birthday-photo-wrap { + width: 100%; + max-width: 280px; + aspect-ratio: 210 / 297; + border-radius: 12px; + border: 2px solid var(--color-brand); + overflow: hidden; + flex-shrink: 0; + background: #111; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.16); +} + +.birthday-photo-wrap img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 28%; +} + +.birthday-info { + width: 100%; +} + +.birthday-info h4 { + font-size: 1.3rem; + color: var(--color-text-dark); + margin: 0 0 2px; + line-height: 1.15; +} + +.birthday-info h4 a { + color: inherit; + text-decoration: none; +} + +.birthday-info .en-name { + font-size: 0.82rem; + color: var(--color-brand-dark); + letter-spacing: 0.05em; + margin-bottom: 7px; +} + +.birthday-info .bday-date { + font-size: 0.92rem; + color: var(--color-text-muted); + font-weight: 700; +} + +/* 演唱會卡片呈現 */ +.concert-featured-card { + background: var(--color-surface-soft); + border: 1px solid rgba(255, 204, 0, 0.22); + border-radius: 12px; + padding: 14px; + margin-bottom: 8px; +} + +.concert-featured-card img { + width: 100%; + max-width: 280px; + aspect-ratio: 210 / 297; + height: auto; + object-fit: cover; + object-position: center 28%; + display: block; + margin-left: auto; + margin-right: auto; + border-radius: 8px; + margin-bottom: 10px; + border: 1px solid rgba(255, 204, 0, 0.24); +} + +/* Modal 彈窗 */ +.calendar-modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.calendar-modal-backdrop.active { + opacity: 1; + pointer-events: auto; +} + +.calendar-modal-content { + background: var(--color-surface); + border: 1px solid var(--cal-border-strong); + border-radius: 16px; + padding: 24px; + max-width: 480px; + width: 90%; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); + position: relative; + text-align: center; +} + +.modal-close-btn { + position: absolute; + top: 14px; + right: 18px; + background: none; + border: none; + color: #7d7464; + font-size: 1.5rem; + cursor: pointer; +} + +.modal-close-btn:hover { + color: var(--color-text-dark); +} + +.modal-photo { + width: 120px; + height: 120px; + border-radius: 50%; + border: 3px solid var(--color-brand); + object-fit: cover; + margin: 0 auto 16px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); +} + +.modal-date-title { + color: var(--color-brand-dark); + margin-bottom: 14px; +} + +.modal-member-name { + color: var(--color-text-dark); + font-size: 1.32rem; + margin-bottom: 4px; +} + +.modal-member-name a { + color: inherit; + text-decoration: none; +} + +.modal-member-meta { + color: var(--color-brand-dark); + font-size: 0.9rem; + margin-bottom: 10px; +} + +.modal-desc { + color: var(--color-text-muted); + font-size: 0.92rem; + line-height: 1.5; + margin-bottom: 12px; +} + +.modal-member-link { + text-decoration: none; + display: inline-block; + font-size: 0.9rem; +} + +.modal-event-box { + background: rgba(255, 204, 0, 0.1); + border-radius: 8px; + padding: 12px; + margin-bottom: 10px; + text-align: left; +} + +.modal-event-title { + color: var(--color-brand-dark); + margin-bottom: 4px; +} + +.modal-concert-photo { + width: 100%; + max-width: 320px; + aspect-ratio: 210 / 297; + max-height: none; + object-fit: cover; + object-position: center 28%; + display: block; + margin-left: auto; + margin-right: auto; + border-radius: 8px; + border: 1px solid rgba(255, 204, 0, 0.28); + margin-bottom: 14px; +} + +@media (max-width: 900px) { + .calendar-page-container { + padding: 24px 14px 50px; + } + + .calendar-controls, + .calendar-card, + .events-sidebar { + padding: 16px; + } + + .calendar-header-title { + font-size: 1.33rem; + } + + .day-cell { + min-height: 56px; + } + + .day-avatar-badge { + width: 30px; + height: 30px; + } + + .day-member-name { + font-size: 0.62rem; + } + + .birthday-photo-wrap { + max-width: 100%; + } +} diff --git a/assets/css/captain-aura.css b/assets/css/captain-aura.css new file mode 100644 index 0000000..3e0d1d2 --- /dev/null +++ b/assets/css/captain-aura.css @@ -0,0 +1,101 @@ +/* 隊長的氣場 — 殷志源台北見面會存檔頁(2018-07-21) */ + +body.captain-aura-page { + background-color: var(--color-bg); + background-image: url('../images/concert/captain-aura/bg-digital-placeholder.svg'); + background-repeat: repeat; +} + +body.captain-aura-page .captain-aura-hero { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; + box-sizing: border-box; +} + +body.captain-aura-page .captain-aura-hero img { + display: block; + width: 100%; + height: auto; + border-radius: 4px; +} + +body.captain-aura-page .captain-aura-main { + max-width: 893px; + margin: 0 auto; + padding: 50px 20px 72px; + box-sizing: border-box; +} + +body.captain-aura-page .captain-aura-act { + margin-bottom: 2.5rem; +} + +body.captain-aura-page .captain-aura-act:last-child { + margin-bottom: 0; +} + +body.captain-aura-page .captain-aura-act__title { + margin: 0 0 1rem; + text-align: center; + color: var(--color-brand); + font-size: 1.65rem; + font-weight: 700; + line-height: 1.35; + text-shadow: 0 0 5px rgba(0, 0, 0, 0.3); +} + +body.captain-aura-page .captain-aura-act__title--plain { + color: var(--color-text-inverse); + text-shadow: none; + font-size: 1.35rem; + margin-bottom: 0.65rem; +} + +body.captain-aura-page .captain-aura-act__title--plain + .captain-aura-act__title--plain { + margin-top: 0; +} + +body.captain-aura-page .captain-aura-divider { + margin: 2.75rem 0; + text-align: center; + color: var(--color-brand); + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0.2em; +} + +body.captain-aura-page .captain-aura-videos { + display: flex; + flex-direction: column; + gap: 1rem; +} + +body.captain-aura-page .captain-aura-video { + position: relative; + width: 100%; + padding-bottom: 56.25%; + background: #000; + border-radius: 4px; + overflow: hidden; +} + +body.captain-aura-page .captain-aura-video iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; +} + +@media (max-width: 640px) { + body.captain-aura-page .captain-aura-main { + padding-top: 32px; + padding-bottom: 48px; + } + + body.captain-aura-page .captain-aura-act__title { + font-size: 1.35rem; + } +} diff --git a/assets/css/style.css b/assets/css/style.css index 6b3ed12..984ca28 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -2256,6 +2256,45 @@ header.inner-page-header .sub-title { padding: 4px 20px; } +/* 地區切換(台灣/海外):比成員 tab 高一階,用深底色跟它區隔 */ +.region-tabs { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px; + margin: 18px auto 0; + max-width: 1100px; + padding: 4px 20px; +} + +.region-tab-btn { + border: 1px solid var(--color-bg-raised); + background: var(--color-bg-raised); + color: #f4f4f4; + border-radius: 999px; + padding: 8px 20px; + font-size: 0.9rem; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; + transition: all 0.2s ease; +} + +.region-tab-btn:hover, +.region-tab-btn.active { + background: var(--color-brand); + color: #2f2f2f; + border-color: var(--color-brand); +} + +.region-empty-state { + text-align: center; + color: #8b7423; + font-size: 0.95rem; + padding: 40px 20px; +} + #itinerary-search { display: block; max-width: 1100px; @@ -2328,6 +2367,16 @@ header.inner-page-header .sub-title { gap: 16px; } +.map-embed-empty { + text-align: center; + color: #8b7423; + font-size: 0.88rem; + padding: 24px 16px; + background: #fffef8; + border: 1px dashed #e4dbb8; + border-radius: 10px; +} + .map-embed-label { margin: 0 0 8px; font-size: 0.88rem; @@ -2450,6 +2499,7 @@ header.inner-page-header .sub-title { .photo-frame { width: 118px; flex: 0 0 118px; + position: relative; } .photo-frame img { @@ -2459,6 +2509,21 @@ header.inner-page-header .sub-title { border-radius: 8px; } +/* 圖片授權標示:僅供合規(CC BY / CC BY-SA 需姓名標示),刻意做得很小、低對比,不搶視覺 */ +.photo-credit { + position: absolute; + right: 4px; + bottom: 3px; + font-size: 0.5rem; + line-height: 1; + color: rgba(255, 255, 255, 0.7); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85); + letter-spacing: 0.01em; + pointer-events: none; + user-select: none; + white-space: nowrap; +} + .card-content .date { display: inline-block; font-size: 0.72rem; @@ -2495,6 +2560,10 @@ header.inner-page-header .sub-title { box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06); } +.detail-photo { + position: relative; +} + .detail-photo img { width: 100%; max-height: 300px; @@ -2502,6 +2571,12 @@ header.inner-page-header .sub-title { display: block; } +.detail-photo .photo-credit { + font-size: 0.62rem; + right: 8px; + bottom: 6px; +} + .detail-content { padding: 16px; } @@ -3480,6 +3555,11 @@ body.site-guide-page .site-guide-legend__off { font-weight: 700; } +body.site-guide-page .site-guide-legend__extra { + color: #8a8a8a; + font-weight: 700; +} + body.site-guide-page .site-guide-nav-table-wrap { width: 100%; overflow-x: auto; @@ -3590,6 +3670,142 @@ body.site-guide-page .site-guide-mini-table a { text-decoration: none; } +/* 主選單樹:心智圖巢狀清單(取代原表格),主選單→次選單→子節點以連接線呈現 */ +body.site-guide-page .site-guide-nav-caption--standalone { + text-align: center; + padding: 0 0.5rem 0.65rem; + font-size: 0.8rem; + color: #777; + line-height: 1.4; +} + +body.site-guide-page .site-guide-mindmap, +body.site-guide-page .site-guide-mindmap ul { + list-style: none; + margin: 0; + padding: 0; +} + +body.site-guide-page .site-guide-mindmap { + padding: 0.5rem 0.75rem 0.5rem 0.25rem; + min-width: 20rem; +} + +body.site-guide-page .site-guide-mindmap ul { + margin-left: 1.5rem; +} + +body.site-guide-page .site-guide-mindmap li { + position: relative; + padding: 0.32rem 0 0.32rem 1.35rem; +} + +body.site-guide-page .site-guide-mindmap li::before, +body.site-guide-page .site-guide-mindmap li::after { + content: ""; + position: absolute; + left: 0; +} + +/* 節點到垂直線的橫向短連接線 */ +body.site-guide-page .site-guide-mindmap li::before { + top: 1.1rem; + width: 1.15rem; + height: 0; + border-top: 2px solid rgba(150, 115, 20, 0.4); +} + +/* 同層兄弟節點間的垂直連接線 */ +body.site-guide-page .site-guide-mindmap li::after { + top: 0; + bottom: 0; + width: 0; + border-left: 2px solid rgba(150, 115, 20, 0.4); +} + +/* 每層最後一個節點:垂直線只畫到分支點,不繼續往下延伸 */ +body.site-guide-page .site-guide-mindmap li:last-child::after { + height: 1.1rem; + bottom: auto; +} + +body.site-guide-page .sg-node { + display: inline-flex; + align-items: center; + padding: 0.3em 0.85em; + border-radius: 999px; + font-size: 0.88rem; + line-height: 1.3; + background: #fff; + border: 1px solid rgba(255, 204, 0, 0.45); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +body.site-guide-page .sg-node a { + color: #5c4010; + font-weight: 700; + text-decoration: none; +} + +body.site-guide-page .sg-node a:hover { + text-decoration: underline; +} + +body.site-guide-page .sg-node--nolink { + border-style: dashed; + border-color: rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.03); + color: #959595; + font-weight: 600; +} + +/* 不在頂欄的延伸內容(頁內錨點/草稿/備查):灰色虛線,與頂欄真實連結區隔 */ +body.site-guide-page .sg-node--extra { + border-style: dashed; + border-color: rgba(0, 0, 0, 0.22); + background: rgba(0, 0, 0, 0.02); +} + +body.site-guide-page .sg-node--extra a { + color: #6e6e6e; +} + +body.site-guide-page .sg-node--extra code { + font-size: 0.85em; + color: #7a6a30; +} + +body.site-guide-page .sg-node--extra.sg-node--nolink { + color: #9a9a9a; + font-style: italic; +} + +body.site-guide-page .site-guide-mindmap li[data-site-guide-depth="1"] > .sg-node { + font-weight: 800; + font-size: 0.98rem; + background: rgba(255, 220, 150, 0.55); + border-color: rgba(255, 204, 0, 0.6); +} + +body.site-guide-page .site-guide-mindmap li[data-site-guide-depth="2"] > .sg-node { + background: rgba(255, 238, 200, 0.55); +} + +body.site-guide-page .site-guide-mindmap li[data-site-guide-depth="3"] > .sg-node { + background: #fffaf2; +} + +body.site-guide-page .site-guide-mindmap li[data-site-guide-depth="4"] > .sg-node { + background: #f5f8ff; + font-size: 0.82rem; +} + +/* 延伸內容維持灰色虛線,不隨深度套用金黃底色(放在深度規則之後以蓋過同層底色) */ +body.site-guide-page .site-guide-mindmap li[data-site-guide-depth] > .sg-node--extra { + background: rgba(0, 0, 0, 0.02); + border-color: rgba(0, 0, 0, 0.22); +} + body.site-guide-page .site-guide-nav-nolink { color: #959595; font-weight: 600; diff --git a/assets/images/concert/captain-aura/bg-digital-placeholder.svg b/assets/images/concert/captain-aura/bg-digital-placeholder.svg new file mode 100644 index 0000000..e8a5b64 --- /dev/null +++ b/assets/images/concert/captain-aura/bg-digital-placeholder.svg @@ -0,0 +1,5 @@ + diff --git a/assets/images/concert/captain-aura/hero-placeholder.svg b/assets/images/concert/captain-aura/hero-placeholder.svg new file mode 100644 index 0000000..608ab78 --- /dev/null +++ b/assets/images/concert/captain-aura/hero-placeholder.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + G1 private � 1theland in Taipei + kEG � ��� hero.jpg� sli01.png + diff --git a/assets/images/crystal-dance-hero.jpg b/assets/images/crystal-dance-hero.jpg new file mode 100644 index 0000000..783aed7 Binary files /dev/null and b/assets/images/crystal-dance-hero.jpg differ diff --git a/assets/js/map.js b/assets/js/map.js index 718cec4..37659a0 100644 --- a/assets/js/map.js +++ b/assets/js/map.js @@ -1,304 +1,404 @@ -(() => { - const canvas = document.getElementById("particles"); - if (canvas) { - const ctx = canvas.getContext("2d"); - let particles = []; - - function resizeCanvas() { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - } - - class Particle { - constructor() { - this.x = Math.random() * canvas.width; - this.y = Math.random() * canvas.height; - this.size = Math.random() * 4 + 1; - this.speedX = Math.random() * 0.5 - 0.25; - this.speedY = Math.random() * 0.5 + 0.2; - } - update() { - this.x += this.speedX; - this.y += this.speedY; - if (this.y > canvas.height) this.y = 0; - } - draw() { - ctx.fillStyle = "#FFDD00"; - ctx.globalAlpha = 0.6; - ctx.beginPath(); - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); - ctx.fill(); - } - } - - function initParticles() { - particles = []; - for (let i = 0; i < 80; i += 1) { - particles.push(new Particle()); - } - } - - function animateParticles() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - for (const particle of particles) { - particle.update(); - particle.draw(); - } - requestAnimationFrame(animateParticles); - } - - resizeCanvas(); - initParticles(); - animateParticles(); - window.addEventListener("resize", () => { - resizeCanvas(); - initParticles(); - }); - } - - const tabs = Array.from(document.querySelectorAll(".tab-btn")); - const cards = Array.from(document.querySelectorAll(".polaroid-card")); - const searchInput = document.getElementById("itinerary-search"); - const itineraryList = document.querySelector(".itinerary-list"); - const detailPanel = document.getElementById("detail-panel"); - const detailDate = document.getElementById("detail-date"); - const detailTitle = document.getElementById("detail-title"); - const detailDesc = document.getElementById("detail-desc"); - const detailQuote = document.getElementById("detail-quote"); - const detailTag = document.getElementById("detail-tag"); - const detailLink = document.getElementById("detail-link"); - const detailImage = document.getElementById("detail-image"); - const memberMapFrame = document.getElementById("member-map"); - const mapEmbedSingle = document.getElementById("map-embed-single"); - const mapEmbedAll = document.getElementById("map-embed-all"); - const openMapLink = document.getElementById("open-map-link"); - const openMapLinksDual = document.getElementById("open-map-links-dual"); - const openMapLinkKo = document.getElementById("open-map-link-ko"); - const openMapLinkEun = document.getElementById("open-map-link-eun"); - const dayGroups = new Map(); - - let currentMember = "eun"; - let currentKeyword = ""; - const memberMaps = { - eun: { - embed: "https://www.google.com/maps/d/embed?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI&ehbc=2E312F", - open: "https://www.google.com/maps/d/u/0/viewer?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI" - }, - ko: { - embed: "https://www.google.com/maps/d/embed?mid=1xpAsSoO8gNmMiW4x2sfkndISFTtjMQDw&hl=zh-TW&ehbc=2E312F", - open: "https://www.google.com/maps/d/u/0/viewer?mid=1xpAsSoO8gNmMiW4x2sfkndISFTtjMQDw" - } - }; - const memberTripPrefix = { - ko: "高高父子遊台灣", - eun: "殷空少搭飛機去台灣高雄" - }; - const memberSortOrder = { eun: 0, ko: 1 }; - - function normalizeMember(member) { - if (member === "kogo") return "ko"; - return member; - } - - function getMemberLabel(member) { - const normalized = normalizeMember(member); - if (normalized === "eun") return "殷志源"; - if (normalized === "ko") return "高志溶"; - return "未分類"; - } - - function getDayToken(rawDate) { - const match = String(rawDate || "").match(/Day\s*(\d+)/i); - if (!match) return String(rawDate || "未分類"); - return "Day " + match[1]; - } - - function getGroupLabel(card) { - const date = getDayToken(card.dataset.date || "未分類"); - const normalized = normalizeMember(card.dataset.member || ""); - const prefix = memberTripPrefix[normalized] || getMemberLabel(normalized); - return prefix + "_" + date; - } - - function setDualMapVisible(isAll) { - if (mapEmbedSingle && mapEmbedAll) { - mapEmbedSingle.hidden = isAll ? true : false; - mapEmbedAll.hidden = isAll ? false : true; - mapEmbedAll.setAttribute("aria-hidden", isAll ? "false" : "true"); - } - if (openMapLink && openMapLinksDual) { - openMapLink.hidden = isAll ? true : false; - openMapLinksDual.hidden = isAll ? false : true; - } - } - - function updateMapByMember(member) { - const normalized = normalizeMember(member); - if (normalized === "all") { - setDualMapVisible(true); - if (openMapLinkKo) openMapLinkKo.href = memberMaps.ko.open; - if (openMapLinkEun) openMapLinkEun.href = memberMaps.eun.open; - return; - } - setDualMapVisible(false); - const mapConfig = memberMaps[normalized]; - if (!mapConfig || !memberMapFrame) return; - memberMapFrame.src = mapConfig.embed; - if (openMapLink) openMapLink.href = mapConfig.open; - } - - function getCardSearchText(card) { - return [ - card.dataset.date || "", - card.dataset.title || "", - card.dataset.desc || "", - card.dataset.tag || "", - ].join(" ").toLowerCase(); - } - - function createDayGroup(dayLabel) { - const group = document.createElement("section"); - group.className = "day-group"; - group.dataset.day = dayLabel; - - const toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = "day-toggle"; - toggle.setAttribute("aria-expanded", "false"); - toggle.innerHTML = "" + dayLabel + ""; - - const body = document.createElement("div"); - body.className = "day-cards"; - body.style.display = "none"; - - group.appendChild(toggle); - group.appendChild(body); - itineraryList.appendChild(group); - dayGroups.set(dayLabel, { group, toggle, body }); - return dayGroups.get(dayLabel); - } - - function setDayExpanded(dayGroup, shouldExpand) { - dayGroup.toggle.setAttribute("aria-expanded", shouldExpand ? "true" : "false"); - dayGroup.body.style.display = shouldExpand ? "grid" : "none"; - dayGroup.group.classList.toggle("expanded", shouldExpand); - } - - function initializeDayGroups() { - const sortedCards = [...cards].sort((a, b) => { - const memberA = normalizeMember(a.dataset.member || ""); - const memberB = normalizeMember(b.dataset.member || ""); - const memberDiff = (memberSortOrder[memberA] ?? 99) - (memberSortOrder[memberB] ?? 99); - if (memberDiff !== 0) return memberDiff; - - const dayA = Number((getDayToken(a.dataset.date).match(/\d+/) || [999])[0]); - const dayB = Number((getDayToken(b.dataset.date).match(/\d+/) || [999])[0]); - return dayA - dayB; - }); - - sortedCards.forEach((card) => { - const dayLabel = getGroupLabel(card); - const dayGroup = dayGroups.get(dayLabel) || createDayGroup(dayLabel); - dayGroup.body.appendChild(card); - }); - } - - function updateDayGroupVisibility() { - dayGroups.forEach(({ group, body, toggle }) => { - const visibleCards = Array.from(body.querySelectorAll(".polaroid-card")).filter((card) => card.style.display !== "none"); - group.style.display = visibleCards.length ? "" : "none"; - if (!visibleCards.length) { - setDayExpanded({ group, toggle, body }, false); - } - }); - } - - function ensureExpandedForCard(card) { - const dayGroup = dayGroups.get(getGroupLabel(card)); - if (!dayGroup) return; - setDayExpanded(dayGroup, true); - } - - function setActiveCard(card, options = {}) { - const { expandDay = true } = options; - cards.forEach((item) => item.classList.remove("active")); - card.classList.add("active"); - if (expandDay) { - ensureExpandedForCard(card); - } - const mapKey = currentMember === "all" ? "all" : currentMember; - updateMapByMember(mapKey); - - detailDate.textContent = card.dataset.date || ""; - detailTitle.textContent = card.dataset.title || ""; - detailDesc.textContent = card.dataset.desc || ""; - detailQuote.textContent = "「" + (card.dataset.quote || "") + "」"; - detailTag.textContent = card.dataset.tag || ""; - detailLink.href = card.dataset.link || "#"; - detailImage.src = card.querySelector("img").src; - detailImage.alt = "節目截圖 - " + (card.dataset.title || "行程地點"); - } - - function applyFilter() { - let firstVisible = null; - cards.forEach((card) => { - const cardMember = normalizeMember(card.dataset.member || ""); - const matchedMember = currentMember === "all" || cardMember === currentMember; - const matchedKeyword = !currentKeyword || getCardSearchText(card).includes(currentKeyword); - const matched = matchedMember && matchedKeyword; - card.style.display = matched ? "" : "none"; - if (matched && !firstVisible) firstVisible = card; - }); - updateDayGroupVisibility(); - const activeVisibleCard = cards.find((card) => card.classList.contains("active") && card.style.display !== "none"); - const fallbackCard = activeVisibleCard || firstVisible; - if (fallbackCard) { - setActiveCard(fallbackCard, { expandDay: false }); - } else { - updateMapByMember(currentMember === "all" ? "all" : currentMember); - } - } - - tabs.forEach((tab) => { - tab.addEventListener("click", () => { - tabs.forEach((item) => { - item.classList.remove("active"); - item.setAttribute("aria-pressed", "false"); - }); - tab.classList.add("active"); - tab.setAttribute("aria-pressed", "true"); - currentMember = normalizeMember(tab.dataset.member || "all"); - applyFilter(); - }); - }); - - if (searchInput) { - searchInput.addEventListener("input", (event) => { - currentKeyword = event.target.value.trim().toLowerCase(); - applyFilter(); - }); - } - - itineraryList.addEventListener("click", (event) => { - const toggle = event.target.closest(".day-toggle"); - if (!toggle) return; - const group = toggle.closest(".day-group"); - if (!group) return; - const dayGroup = dayGroups.get(group.dataset.day); - if (!dayGroup) return; - const isExpanded = dayGroup.toggle.getAttribute("aria-expanded") === "true"; - setDayExpanded(dayGroup, !isExpanded); - }); - - cards.forEach((card) => { - card.addEventListener("click", () => { - setActiveCard(card, { expandDay: true }); - if (window.innerWidth < 920) { - detailPanel.scrollIntoView({ behavior: "smooth", block: "start" }); - } - }); - }); - - initializeDayGroups(); - applyFilter(); -})(); +(() => { + const canvas = document.getElementById("particles"); + if (canvas) { + const ctx = canvas.getContext("2d"); + let particles = []; + + function resizeCanvas() { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + } + + class Particle { + constructor() { + this.x = Math.random() * canvas.width; + this.y = Math.random() * canvas.height; + this.size = Math.random() * 4 + 1; + this.speedX = Math.random() * 0.5 - 0.25; + this.speedY = Math.random() * 0.5 + 0.2; + } + update() { + this.x += this.speedX; + this.y += this.speedY; + if (this.y > canvas.height) this.y = 0; + } + draw() { + ctx.fillStyle = "#FFDD00"; + ctx.globalAlpha = 0.6; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); + ctx.fill(); + } + } + + function initParticles() { + particles = []; + for (let i = 0; i < 80; i += 1) { + particles.push(new Particle()); + } + } + + function animateParticles() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (const particle of particles) { + particle.update(); + particle.draw(); + } + requestAnimationFrame(animateParticles); + } + + resizeCanvas(); + initParticles(); + animateParticles(); + window.addEventListener("resize", () => { + resizeCanvas(); + initParticles(); + }); + } + + const searchInput = document.getElementById("itinerary-search"); + + /** + * 每個 .region-panel(例如 #taipei、#global)都是獨立的地區區塊,各自的 + * member-tabs/地圖/地點卡片互不影響。新增行程時只要在對應 .region-panel + * 裡加一顆 .tab-btn(帶 data-trip-name / data-map-embed / data-map-open, + * 沒有地圖就不要寫 data-map-embed),以及對應的 .polaroid-card,不需要動這支 + * 檔案。「全部行程」的地圖會依當時有設定地圖的 tab 自動組出來;完全沒有 + * 設定地圖的行程,地圖區塊會自動收起來,只留卡片列表。 + */ + function initRegionController(panel) { + const tabs = Array.from(panel.querySelectorAll(":scope > .member-tabs .tab-btn")); + const cards = Array.from(panel.querySelectorAll(".polaroid-card")); + const itineraryList = panel.querySelector(".itinerary-list"); + const detailPanel = panel.querySelector(".detail-panel"); + const detailImage = panel.querySelector(".detail-photo img"); + const detailDate = panel.querySelector(".detail-content .date"); + const detailTitle = panel.querySelector(".detail-content h3"); + const detailDesc = panel.querySelector(".detail-content p"); + const detailQuote = panel.querySelector(".detail-content blockquote"); + const detailTag = panel.querySelector(".detail-content .tag"); + const detailLink = panel.querySelector(".detail-content .detail-link"); + + const mapSticky = panel.querySelector(".map-sticky"); + const mapEmbedSingle = mapSticky ? mapSticky.querySelector(".map-embed-single") : null; + const memberMapFrame = mapEmbedSingle ? mapEmbedSingle.querySelector(".map-my-maps-frame") : null; + const mapEmbedEmpty = mapSticky ? mapSticky.querySelector(".map-embed-empty") : null; + const mapEmbedAll = mapSticky ? mapSticky.querySelector(".map-embed-all") : null; + const openMapLink = mapSticky ? mapSticky.querySelector(".map-link") : null; + const openMapLinksDual = mapSticky ? mapSticky.querySelector(".map-links-dual") : null; + + const dayGroups = new Map(); + + /* 舊資料裡卡片用 data-member="kogo",tab 用 data-member="ko";新地區請直接讓兩邊字串一致,不需要這個對照 */ + function normalizeMember(member) { + if (member === "kogo") return "ko"; + return member; + } + + const tripConfig = {}; + tabs.forEach((tab) => { + const member = normalizeMember(tab.dataset.member || ""); + if (!member || member === "all") return; + tripConfig[member] = { + tripName: tab.dataset.tripName || tab.textContent.trim(), + memberLabel: tab.dataset.memberLabel || tab.textContent.trim(), + sortOrder: Number.isFinite(Number(tab.dataset.sortOrder)) ? Number(tab.dataset.sortOrder) : 99, + mapEmbed: tab.dataset.mapEmbed || "", + mapOpen: tab.dataset.mapOpen || "", + }; + }); + + const initialTab = tabs.find((tab) => tab.classList.contains("active")) || tabs[0]; + let currentMember = initialTab ? normalizeMember(initialTab.dataset.member || "all") : "all"; + let currentKeyword = ""; + + function getMemberLabel(member) { + return (tripConfig[member] && tripConfig[member].memberLabel) || "未分類"; + } + + function getDayToken(rawDate) { + const match = String(rawDate || "").match(/Day\s*(\d+)/i); + if (!match) return String(rawDate || "未分類"); + return "Day " + match[1]; + } + + function getGroupLabel(card) { + const date = getDayToken(card.dataset.date || "未分類"); + const member = normalizeMember(card.dataset.member || ""); + const prefix = (tripConfig[member] && tripConfig[member].tripName) || getMemberLabel(member); + return prefix + "_" + date; + } + + function tripsWithMaps() { + return Object.keys(tripConfig) + .filter((member) => tripConfig[member].mapEmbed) + .sort((a, b) => tripConfig[a].sortOrder - tripConfig[b].sortOrder); + } + + function buildMapPairs(members) { + if (!mapEmbedAll) return; + mapEmbedAll.innerHTML = ""; + members.forEach((member) => { + const trip = tripConfig[member]; + const pair = document.createElement("div"); + pair.className = "map-embed-pair"; + + const label = document.createElement("p"); + label.className = "map-embed-label"; + label.textContent = trip.tripName; + + const iframe = document.createElement("iframe"); + iframe.className = "map-my-maps-frame"; + iframe.loading = "lazy"; + iframe.referrerPolicy = "no-referrer-when-downgrade"; + iframe.title = trip.tripName + " 踩點地圖"; + iframe.src = trip.mapEmbed; + + pair.appendChild(label); + pair.appendChild(iframe); + mapEmbedAll.appendChild(pair); + }); + } + + function buildOpenLinksDual(members) { + if (!openMapLinksDual) return; + openMapLinksDual.innerHTML = ""; + members.forEach((member) => { + const trip = tripConfig[member]; + if (!trip.mapOpen) return; + const a = document.createElement("a"); + a.className = "map-link"; + a.target = "_blank"; + a.href = trip.mapOpen; + a.textContent = "開啟「" + trip.tripName + "」My Maps"; + openMapLinksDual.appendChild(a); + }); + } + + function updateMapByMember(member) { + if (!mapSticky) return; + if (!Object.keys(tripConfig).length) { + mapSticky.hidden = true; + return; + } + mapSticky.hidden = false; + + if (member === "all") { + const withMaps = tripsWithMaps(); + if (mapEmbedSingle) mapEmbedSingle.hidden = true; + if (openMapLink) openMapLink.hidden = true; + if (mapEmbedAll) { + buildMapPairs(withMaps); + mapEmbedAll.hidden = withMaps.length === 0; + } + if (openMapLinksDual) { + buildOpenLinksDual(withMaps); + openMapLinksDual.hidden = withMaps.length === 0; + } + if (mapEmbedEmpty) mapEmbedEmpty.hidden = withMaps.length > 0; + return; + } + + const trip = tripConfig[member]; + const hasMap = !!(trip && trip.mapEmbed); + if (mapEmbedAll) mapEmbedAll.hidden = true; + if (openMapLinksDual) openMapLinksDual.hidden = true; + if (mapEmbedSingle) mapEmbedSingle.hidden = !hasMap; + if (memberMapFrame && hasMap) memberMapFrame.src = trip.mapEmbed; + if (openMapLink) { + openMapLink.hidden = !hasMap; + if (hasMap) openMapLink.href = trip.mapOpen || "#"; + } + if (mapEmbedEmpty) mapEmbedEmpty.hidden = hasMap; + } + + function getCardSearchText(card) { + return [ + card.dataset.date || "", + card.dataset.title || "", + card.dataset.desc || "", + card.dataset.tag || "", + ].join(" ").toLowerCase(); + } + + function createDayGroup(dayLabel) { + const group = document.createElement("section"); + group.className = "day-group"; + group.dataset.day = dayLabel; + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "day-toggle"; + toggle.setAttribute("aria-expanded", "false"); + toggle.innerHTML = "" + dayLabel + ""; + + const body = document.createElement("div"); + body.className = "day-cards"; + body.style.display = "none"; + + group.appendChild(toggle); + group.appendChild(body); + itineraryList.appendChild(group); + dayGroups.set(dayLabel, { group, toggle, body }); + return dayGroups.get(dayLabel); + } + + function setDayExpanded(dayGroup, shouldExpand) { + dayGroup.toggle.setAttribute("aria-expanded", shouldExpand ? "true" : "false"); + dayGroup.body.style.display = shouldExpand ? "grid" : "none"; + dayGroup.group.classList.toggle("expanded", shouldExpand); + } + + function initializeDayGroups() { + if (!itineraryList || !cards.length) return; + const sortedCards = [...cards].sort((a, b) => { + const memberA = normalizeMember(a.dataset.member || ""); + const memberB = normalizeMember(b.dataset.member || ""); + const sortA = (tripConfig[memberA] && tripConfig[memberA].sortOrder) ?? 99; + const sortB = (tripConfig[memberB] && tripConfig[memberB].sortOrder) ?? 99; + if (sortA !== sortB) return sortA - sortB; + + const dayA = Number((getDayToken(a.dataset.date).match(/\d+/) || [999])[0]); + const dayB = Number((getDayToken(b.dataset.date).match(/\d+/) || [999])[0]); + return dayA - dayB; + }); + + sortedCards.forEach((card) => { + const dayLabel = getGroupLabel(card); + const dayGroup = dayGroups.get(dayLabel) || createDayGroup(dayLabel); + dayGroup.body.appendChild(card); + }); + } + + function updateDayGroupVisibility() { + dayGroups.forEach(({ group, body, toggle }) => { + const visibleCards = Array.from(body.querySelectorAll(".polaroid-card")).filter((card) => card.style.display !== "none"); + group.style.display = visibleCards.length ? "" : "none"; + if (!visibleCards.length) { + setDayExpanded({ group, toggle, body }, false); + } + }); + } + + function ensureExpandedForCard(card) { + const dayGroup = dayGroups.get(getGroupLabel(card)); + if (!dayGroup) return; + setDayExpanded(dayGroup, true); + } + + function setActiveCard(card, options = {}) { + const { expandDay = true } = options; + cards.forEach((item) => item.classList.remove("active")); + card.classList.add("active"); + if (expandDay) { + ensureExpandedForCard(card); + } + updateMapByMember(currentMember === "all" ? "all" : currentMember); + + if (!detailPanel) return; + if (detailDate) detailDate.textContent = card.dataset.date || ""; + if (detailTitle) detailTitle.textContent = card.dataset.title || ""; + if (detailDesc) detailDesc.textContent = card.dataset.desc || ""; + if (detailQuote) detailQuote.textContent = "「" + (card.dataset.quote || "") + "」"; + if (detailTag) detailTag.textContent = card.dataset.tag || ""; + if (detailLink) detailLink.href = card.dataset.link || "#"; + const cardImg = card.querySelector("img"); + if (detailImage && cardImg) { + detailImage.src = cardImg.src; + detailImage.alt = "節目截圖 - " + (card.dataset.title || "行程地點"); + } + } + + function applyFilter() { + let firstVisible = null; + cards.forEach((card) => { + const cardMember = normalizeMember(card.dataset.member || ""); + const matchedMember = currentMember === "all" || cardMember === currentMember; + const matchedKeyword = !currentKeyword || getCardSearchText(card).includes(currentKeyword); + const matched = matchedMember && matchedKeyword; + card.style.display = matched ? "" : "none"; + if (matched && !firstVisible) firstVisible = card; + }); + updateDayGroupVisibility(); + const activeVisibleCard = cards.find((card) => card.classList.contains("active") && card.style.display !== "none"); + const fallbackCard = activeVisibleCard || firstVisible; + if (fallbackCard) { + setActiveCard(fallbackCard, { expandDay: false }); + } else { + updateMapByMember(currentMember === "all" ? "all" : currentMember); + } + } + + tabs.forEach((tab) => { + tab.addEventListener("click", () => { + tabs.forEach((item) => { + item.classList.remove("active"); + item.setAttribute("aria-pressed", "false"); + }); + tab.classList.add("active"); + tab.setAttribute("aria-pressed", "true"); + currentMember = normalizeMember(tab.dataset.member || "all"); + applyFilter(); + }); + }); + + if (itineraryList) { + itineraryList.addEventListener("click", (event) => { + const toggle = event.target.closest(".day-toggle"); + if (!toggle) return; + const group = toggle.closest(".day-group"); + if (!group) return; + const dayGroup = dayGroups.get(group.dataset.day); + if (!dayGroup) return; + const isExpanded = dayGroup.toggle.getAttribute("aria-expanded") === "true"; + setDayExpanded(dayGroup, !isExpanded); + }); + } + + cards.forEach((card) => { + card.addEventListener("click", () => { + setActiveCard(card, { expandDay: true }); + if (window.innerWidth < 920 && detailPanel) { + detailPanel.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); + }); + + initializeDayGroups(); + applyFilter(); + + return { + setKeyword(keyword) { + currentKeyword = keyword; + applyFilter(); + }, + }; + } + + const regionPanels = Array.from(document.querySelectorAll(".region-panel")); + const controllers = regionPanels.map((panel) => initRegionController(panel)); + + if (searchInput) { + searchInput.addEventListener("input", (event) => { + const keyword = event.target.value.trim().toLowerCase(); + controllers.forEach((controller) => controller.setKeyword(keyword)); + }); + } + + const regionTabs = Array.from(document.querySelectorAll("[data-region-tabs] .region-tab-btn")); + if (regionTabs.length && regionPanels.length) { + regionTabs.forEach((tab) => { + tab.addEventListener("click", () => { + const region = tab.dataset.region; + regionPanels.forEach((panel) => { + panel.hidden = panel.dataset.regionPanel !== region; + }); + regionTabs.forEach((item) => { + const isActive = item === tab; + item.classList.toggle("active", isActive); + item.setAttribute("aria-pressed", isActive ? "true" : "false"); + }); + }); + }); + } +})(); diff --git a/calendar2026.html b/calendar2026.html new file mode 100644 index 0000000..c163103 --- /dev/null +++ b/calendar2026.html @@ -0,0 +1,460 @@ + + + + + + + 水晶萬年曆 2026 — SECHSKIES SPECIAL CALENDAR + + + + + + + +
    +
    +

    水晶萬年曆

    +
    + 2026 SECHSKIES SPECIAL CALENDAR +
    +
    +
    + + + + + +
    + +
    +
    + + + + +
    + + +
    + + + + +
    +
    + + +
    + +
    +

    2026 年 8 月

    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + + + + + + + + + + + diff --git a/concert/captain-aura.html b/concert/captain-aura.html new file mode 100644 index 0000000..4cc9028 --- /dev/null +++ b/concert/captain-aura.html @@ -0,0 +1,300 @@ + + + + + + + 水晶男孩|殷志源台北見面會 + + + + + + + +
    +
    +

    殷志源台北見面會

    +
    + G1 private · 1theland in Taipei · 2018.07.21 +
    +
    +
    + + + + + + +
    + + + +
    +

    8t. Truck

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    入境表格

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    粉絲提問

    +

    Taipei in Melody

    +

    G1's Great Moment

    +

    Dance

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    撒嬌

    +
    +
    + +
    +
    +
    + +
    +

    1 the land

    +
    + +
    +

    烏克麗麗

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    Couple大合唱

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    Never, Ever…

    +
    +
    + +
    +
    +
    + + + +
    +
    +
    + +
    +
    +
    + +
    +

    酒醉

    +
    +
    + +
    +
    +
    + +
    +

    滿醉 in Melody

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    All Famy

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    Suddenly

    +
    +
    + +
    +
    +
    + +
    +

    ONE

    +
    +
    + +
    +
    + +
    +
    +
    + +
    +

    安可 / Adios

    +
    +
    + +
    +
    + +
    +
    +
    + +
    + + + + + + diff --git a/developer-rules/session-logs/2026-07-08-bootstrap-homework.md b/developer-rules/session-logs/2026-07-08-bootstrap-homework.md new file mode 100644 index 0000000..34bde5c --- /dev/null +++ b/developer-rules/session-logs/2026-07-08-bootstrap-homework.md @@ -0,0 +1,39 @@ +# 對話紀錄:Bootstrap 5 作業(2026-07-08) + +> 這份是這次對話的重點備份,不是規範檔。規範仍以 `developer-rules/rules/` 為準。 + +## 起因 + +老師的 Bootstrap 5 課堂範例重點:Navbar、RWD 格線(container/row/col)、錨點導覽、Carousel(同頁多組需不同 id、可放進 grid 欄位)。目標是做成符合本站風格的作業/作品展示頁,而不是照抄範例或做成教學筆記。 + +## 過程與轉折 + +1. **第一版**(`extra/hw-bootstrap.html` 初版):做成「Bootstrap 元件練習誌」,內容偏向解釋 navbar/grid/carousel 是什麼 → 被打回,因為變成讀書筆記,沒有應用在專案內容上。 +2. **第二版**:改寫同一檔案為「精華牆」,用真實內容(淪陷瞬間/完整的六顆/黃色留聲機/現場的震撼/Yellow Note)取代教學說明,Bootstrap 降到最後一行小字帶過。 +3. 另外新增一頁 **`extra/bootstrap-navbar-carousel.html`**(跨時空精選):Hero+Sticky Navbar 錨點導覽+兩組不同 id 的 Carousel(一組放進 grid 欄位)+Grid 卡片區+結尾備註,符合站主指定的頁面結構規範。 +4. 最後一個任務:**把 Bootstrap 實際套進首頁**,但不能動到 `index.html` 本體 → 建立備份 **`index-bootstrap.html`**,只在這份備份裡: + - 把既有的 `