Add 2026 calendar page and admin CMS backend, update map and member pages
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python3 -c ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"liveServer.settings.port": 5501
|
||||
"liveServer.settings.port": 5503
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# 水晶男孩推廣部 — 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 是第一層 `<li>`,depth-2 是子選單項目,depth-3 是 subgroup 裡的巢狀項目。
|
||||
|
||||
2. **`COMING_SOON_PAGES` 只放檔名**(不含路徑)。
|
||||
`normalizePageKey` 只取 href 最後一段,所以 `variety/bangxiao.html` → 比對 `bangxiao.html`。
|
||||
|
||||
3. **直接連結不需要 subgroup**。
|
||||
子選單內只要 `<li><a>` 就夠;`portal-nav__subgroup` 只在該項目還有巢狀子項目時才用(例如「傳奇的轉折」有時間軸、電子書等四項)。
|
||||
|
||||
4. **site-guide.html 要同步更新**,否則站主索引頁會顯示舊名稱。
|
||||
|
||||
5. **commit 前網站不會更新**。本地端強制重整用 `Ctrl+Shift+R`;GitHub Pages 需 `git push` 後等約 1 分鐘。
|
||||
|
||||
---
|
||||
|
||||
## 目前工作狀態
|
||||
|
||||
<!-- 每次對話結束後更新這個區塊 -->
|
||||
|
||||
**最後更新:** 2026-07-28
|
||||
|
||||
**進行中:**
|
||||
- 推廣部內容管理系統第一階段:已建立 PHP/PDO/MySQL 後臺骨架,待在 XAMPP 匯入 schema 後進行瀏覽器整合測試
|
||||
|
||||
**待確認:**
|
||||
- developer-rules 健檢尚未執行
|
||||
|
||||
**已完成:**
|
||||
- 建立 AGENTS.md 跨裝置上下文檔案
|
||||
- 新增 `variety/bangxiao.html`(世紀級寶藏 / 爆笑養老院,甜蜜負擔計量器)
|
||||
- `variety/variety-shows.html` 重構:移除多層包裝,對齊 albums.html 扁平結構
|
||||
- portal-nav 新增世紀級寶藏,正確放入淪陷瞬間子選單
|
||||
- `extra/site-guide.html` 同步更新:全員發瘋 → 世紀級寶藏
|
||||
- 新增 `admin/` 推廣部後臺第一階段:登入、控制台、管理者帳號、通用 CRUD、媒體上傳及公開狀態總覽
|
||||
|
||||
---
|
||||
|
||||
## 如何使用這個檔案
|
||||
|
||||
換裝置後,告訴 AI:
|
||||
> 「請先讀 AGENTS.md,再讀 developer-rules/rules/ 裡的所有規範,然後繼續 [任務]。」
|
||||
|
||||
## Imported Claude Cowork project instructions
|
||||
@@ -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 水晶專屬萬年曆,更正金在德生日與六顆水晶成員照片"
|
||||
```
|
||||
@@ -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,成員名字要清楚。
|
||||
@@ -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)
|
||||
- 歌曲資訊不丟:表單「想練的歌曲」`<select>` 仍保留 Couple / Com' Back / Road Fighter / Pom Saeng Pom Sa / 其他
|
||||
|
||||
---
|
||||
|
||||
## 五、視覺與版型決策
|
||||
|
||||
### 對齊全站
|
||||
|
||||
- 骨架:`inner-page-header` → `portal-nav` → `particles` → `container` → `section`
|
||||
- 大區塊靠全站 `section` 白卡片浮在星空底上
|
||||
- 避免 section 內再套一層 `.card`(影片/表單尤甚)
|
||||
|
||||
### 影片區
|
||||
|
||||
- 主影片與下方三支延伸影片包在同一 `video-grid`
|
||||
- **主影片寬度與下方三欄左右切齊**(拿掉主影片單獨 `max-width: 720px` 置中)
|
||||
|
||||
### 間距與閱讀
|
||||
|
||||
- `.section-title` 與內文拉開(約 `2rem`)
|
||||
- 段落行高約 `1.9`、段間距加大
|
||||
- 子標題(延伸閱讀、30TH)上方留白加大,避免貼住上一段
|
||||
|
||||
---
|
||||
|
||||
## 六、表單:summer-form 風格
|
||||
|
||||
參考「見面會應援調查表」的**排版結構**,**顏色維持全站黃**:
|
||||
|
||||
### 結構
|
||||
|
||||
1. **填寫須知**(淡黃底邊框盒)
|
||||
2. **第一階段|參與者基本資料**(暱稱、Email、練舞年數、可參與日期)
|
||||
3. **第二階段|30TH 參與意向**(20TH 經驗、參與內容複選)
|
||||
4. **第三階段|應援內容與祝福**(歌曲、上傳、留言)
|
||||
5. **底部按鈕**(重新填寫 / 送出並排)
|
||||
|
||||
### 排版特徵
|
||||
|
||||
- 階段標題在邊框盒**上方**、左對齊
|
||||
- 每階段獨立 `summer-form__stage-body`(圓角細邊框)
|
||||
- label 在上、輸入在下(單欄垂直流)
|
||||
- 不用表單左右分欄
|
||||
- Bootstrap class 全保留
|
||||
|
||||
### Class 命名(頁面專用)
|
||||
|
||||
- `.summer-form`
|
||||
- `.summer-form__stage`
|
||||
- `.summer-form__stage-title`
|
||||
- `.summer-form__stage-body`
|
||||
- `.summer-form__stage-body--notice`
|
||||
- `.summer-form__option-row`
|
||||
- `.summer-form__actions`
|
||||
|
||||
---
|
||||
|
||||
## 七、必須保留的 Bootstrap class(作業)
|
||||
|
||||
```html
|
||||
row / col-* / g-*
|
||||
form-label / form-control / form-select / form-text
|
||||
btn-check / btn
|
||||
ratio / ratio-16x9
|
||||
d-flex / flex-wrap / gap-* / mt-*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、Git 狀態(對話當時)
|
||||
|
||||
- Commit:`a977e78`
|
||||
`refactor: 水晶熱舞社頁面重組為三區並對齊全站簡約風格`
|
||||
- 已 push 至 `origin/main`(後續還有本地再改:間距、影片對齊、summer-form)
|
||||
- 後續的 summer-form/間距微調若尚未再 commit,需另開 commit
|
||||
|
||||
---
|
||||
|
||||
## 九、給下次改這頁的檢查清單
|
||||
|
||||
- [ ] 熱舞社敘事、20TH 影片、30TH 募集說明、表單欄位是否都在
|
||||
- [ ] 是否又出現「section 外套 + 內層黃框」雙重卡片
|
||||
- [ ] 主影片是否與下方三支左右對齊
|
||||
- [ ] 表單是否單欄分階段(summer-form),沒有左右表單/須知分欄
|
||||
- [ ] 標題與內文、區塊與區塊是否有足夠留白
|
||||
- [ ] Bootstrap 必要 class 是否還在
|
||||
- [ ] 顏色是否仍用 `--color-brand` 系列(沒偷換成藍色)
|
||||
|
||||
---
|
||||
|
||||
## 十、一句話總結
|
||||
|
||||
> **讓 Bootstrap 負責格線與表單元件,讓 `style.css` 負責全站美感;頁面專用 CSS 只補缺口。內容依敘事合三區,表單用 summer-form 分階段邊框,顏色不動。**
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
require_login();
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = (string) ($_POST['action'] ?? 'create');
|
||||
if ($action === 'create') {
|
||||
$username = trim((string) ($_POST['username'] ?? ''));
|
||||
$name = trim((string) ($_POST['display_name'] ?? ''));
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
if (!preg_match('/^[A-Za-z0-9_.-]{4,50}$/', $username) || mb_strlen($name) < 2 || strlen($password) < 10) {
|
||||
$error = '請確認帳號、名稱與至少 10 字元的密碼。';
|
||||
} else {
|
||||
try {
|
||||
$stmt = db()->prepare('INSERT INTO admin_users (username, password_hash, display_name, email) VALUES (?, ?, ?, ?)');
|
||||
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT), $name, trim((string) ($_POST['email'] ?? '')) ?: null]);
|
||||
audit('create', 'admin_users', (int) db()->lastInsertId());
|
||||
flash('success', '管理者已新增。'); redirect('admins.php');
|
||||
} catch (PDOException $e) { $error = '帳號已存在或資料格式不正確。'; }
|
||||
}
|
||||
} elseif ($action === 'toggle') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if ($id === (int) $_SESSION['admin_id']) $error = '不能停用目前登入的帳號。';
|
||||
else { db()->prepare('UPDATE admin_users SET is_active = 1 - is_active WHERE id = ?')->execute([$id]); audit('toggle', 'admin_users', $id); flash('success', '帳號狀態已更新。'); redirect('admins.php'); }
|
||||
}
|
||||
}
|
||||
$users = db()->query('SELECT id, username, display_name, email, is_active, last_login_at, created_at FROM admin_users ORDER BY id')->fetchAll();
|
||||
render_header('管理者帳號', 'admins');
|
||||
?>
|
||||
<?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
|
||||
<section class="panel"><div class="panel__head"><div><h2>新增管理者</h2><p>密碼使用不可逆雜湊儲存</p></div></div><form class="inline-form" method="post"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><input type="hidden" name="action" value="create"><label>登入帳號<input name="username" required></label><label>顯示名稱<input name="display_name" required></label><label>電子信箱<input name="email" type="email"></label><label>密碼<input name="password" type="password" minlength="10" required></label><button class="button button--primary" type="submit">新增帳號</button></form></section>
|
||||
<section class="panel"><div class="panel__head"><div><h2>帳號清單</h2><p>停用帳號後將無法登入</p></div></div><div class="table-wrap"><table><thead><tr><th>帳號</th><th>顯示名稱</th><th>信箱</th><th>最後登入</th><th>狀態</th><th>操作</th></tr></thead><tbody><?php foreach ($users as $user): ?><tr><td><?= h($user['username']) ?></td><td><?= h($user['display_name']) ?></td><td><?= h($user['email']) ?></td><td><?= h($user['last_login_at'] ?? '尚未登入') ?></td><td><span class="status status--<?= $user['is_active'] ? 'published' : 'archived' ?>"><?= $user['is_active'] ? '啟用' : '停用' ?></span></td><td><form method="post" data-confirm="確定變更此管理者狀態?"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><input type="hidden" name="action" value="toggle"><input type="hidden" name="id" value="<?= (int) $user['id'] ?>"><button class="table-button" type="submit" <?= (int) $user['id'] === (int) $_SESSION['admin_id'] ? 'disabled' : '' ?>><?= $user['is_active'] ? '停用' : '啟用' ?></button></form></td></tr><?php endforeach; ?></tbody></table></div></section>
|
||||
<?php render_footer();
|
||||
@@ -0,0 +1,141 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;600;700;800;900&display=swap');
|
||||
|
||||
:root {
|
||||
--color-brand: #ffcc00;
|
||||
--color-brand-light: #fceea7;
|
||||
--color-brand-dark: #4e492b;
|
||||
--color-bg: #0a0a0a;
|
||||
--color-bg-raised: #1a1a1a;
|
||||
--color-bg-alt: #fcfaf2;
|
||||
--color-surface: #fffdf7;
|
||||
--color-surface-soft: #fff9e8;
|
||||
--color-text: #333333;
|
||||
--color-text-dark: #1a1a1a;
|
||||
--color-text-muted: #4d4d4d;
|
||||
--color-text-inverse: #f4f4f4;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-height: 100%; background: var(--color-bg); }
|
||||
body { margin: 0; min-height: 100vh; font-family: 'Noto Sans TC', system-ui, sans-serif; color: var(--color-text); background: var(--color-bg-alt); }
|
||||
a { color: inherit; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button, .button { min-height: 42px; }
|
||||
|
||||
.admin-topbar { position: sticky; z-index: 20; top: 0; min-height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; color: var(--color-text-inverse); background: var(--color-bg); border-bottom: 1px solid rgba(255, 204, 0, .3); }
|
||||
.admin-brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 900; letter-spacing: .06em; }
|
||||
.admin-brand i { color: var(--color-brand); }
|
||||
.admin-brand small { padding-left: 10px; color: var(--color-brand-light); border-left: 1px solid rgba(255, 255, 255, .25); font-weight: 500; }
|
||||
.admin-topbar__actions { display: flex; align-items: center; gap: 18px; font-size: .9rem; }
|
||||
.admin-topbar__actions a { color: var(--color-brand-light); text-decoration: none; }
|
||||
|
||||
.admin-shell { display: grid; grid-template-columns: 252px minmax(0, 1fr); min-height: calc(100vh - 68px); }
|
||||
.admin-sidebar { padding: 24px 16px; color: var(--color-text-inverse); background: var(--color-bg-raised); }
|
||||
.admin-sidebar nav { position: sticky; top: 92px; }
|
||||
.admin-sidebar p { margin: 24px 12px 8px; color: var(--color-brand-light); font-size: .72rem; font-weight: 800; letter-spacing: .16em; }
|
||||
.admin-sidebar a { display: flex; align-items: center; gap: 11px; min-height: 43px; margin: 3px 0; padding: 9px 12px; border-radius: 9px; text-decoration: none; color: var(--color-text-inverse); }
|
||||
.admin-sidebar a:hover { background: rgba(255, 255, 255, .08); }
|
||||
.admin-sidebar a.active { color: var(--color-text-dark); background: var(--color-brand); font-weight: 800; }
|
||||
.admin-sidebar i { width: 20px; text-align: center; }
|
||||
|
||||
.admin-main { width: 100%; max-width: 1540px; padding: clamp(24px, 4vw, 48px); overflow: hidden; }
|
||||
.sidebar-toggle { display: none; border: 0; color: var(--color-text-inverse); background: var(--color-bg); border-radius: 8px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; }
|
||||
.page-heading h1 { margin: 3px 0 0; color: var(--color-text-dark); font-size: clamp(1.65rem, 3vw, 2.35rem); line-height: 1.2; }
|
||||
.eyebrow { margin: 0; color: var(--color-brand-dark); font-size: .72rem; font-weight: 900; letter-spacing: .18em; }
|
||||
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin-bottom: 24px; }
|
||||
.stat-card { position: relative; min-height: 148px; padding: 24px; overflow: hidden; border: 1px solid rgba(78, 73, 43, .2); border-radius: 16px; background: var(--color-surface); box-shadow: 0 12px 28px rgba(10, 10, 10, .06); }
|
||||
.stat-card span { display: block; color: var(--color-text-muted); font-size: .9rem; }
|
||||
.stat-card strong { display: block; margin-top: 8px; color: var(--color-text-dark); font-size: 2.35rem; }
|
||||
.stat-card i { position: absolute; right: 20px; bottom: 12px; color: rgba(78, 73, 43, .16); font-size: 3.6rem; }
|
||||
|
||||
.panel { margin-bottom: 24px; padding: 0; border: 1px solid rgba(78, 73, 43, .2); border-radius: 16px; background: var(--color-surface); box-shadow: 0 12px 28px rgba(10, 10, 10, .05); overflow: hidden; }
|
||||
.panel__head { display: flex; justify-content: space-between; padding: 22px 24px 18px; border-bottom: 1px solid rgba(78, 73, 43, .14); }
|
||||
.panel__head h2 { margin: 0; color: var(--color-text-dark); font-size: 1.1rem; }
|
||||
.panel__head p { margin: 4px 0 0; color: var(--color-text-muted); font-size: .86rem; }
|
||||
.module-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; padding: 20px; }
|
||||
.module-grid a { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; min-height: 62px; padding: 12px 15px; border: 1px solid rgba(78, 73, 43, .16); border-radius: 10px; text-decoration: none; background: var(--color-surface-soft); }
|
||||
.module-grid a:hover { border-color: var(--color-brand); }
|
||||
.module-grid i { color: var(--color-brand-dark); font-size: 1.2rem; }
|
||||
.module-grid strong { color: var(--color-text-dark); font-size: 1.15rem; }
|
||||
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.search { position: relative; }
|
||||
.search i { position: absolute; top: 50%; left: 13px; transform: translateY(-50%); color: var(--color-text-muted); }
|
||||
.search input { padding-left: 38px; }
|
||||
input, select, textarea { width: 100%; padding: 10px 12px; color: var(--color-text); background: var(--color-surface); border: 1px solid rgba(78, 73, 43, .38); border-radius: 8px; }
|
||||
input:focus, select:focus, textarea:focus { outline: 3px solid rgba(255, 204, 0, .3); border-color: var(--color-brand-dark); }
|
||||
.filters input { width: min(320px, 70vw); }
|
||||
.filters select { width: auto; min-width: 135px; }
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 9px 16px; border: 1px solid rgba(78, 73, 43, .35); border-radius: 8px; color: var(--color-text-dark); background: var(--color-surface); text-decoration: none; cursor: pointer; white-space: nowrap; }
|
||||
.button--primary { color: var(--color-text-dark); background: var(--color-brand); border-color: var(--color-brand); font-weight: 800; }
|
||||
.button:hover { filter: brightness(.97); }
|
||||
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; white-space: nowrap; }
|
||||
th, td { padding: 14px 16px; border-bottom: 1px solid rgba(78, 73, 43, .13); text-align: left; font-size: .88rem; }
|
||||
th { color: var(--color-brand-dark); background: var(--color-surface-soft); font-size: .77rem; letter-spacing: .04em; }
|
||||
tbody tr:hover { background: rgba(255, 204, 0, .06); }
|
||||
.actions { display: flex; align-items: center; gap: 12px; }
|
||||
.actions a, .actions button, .table-button { padding: 0; border: 0; color: var(--color-brand-dark); background: transparent; cursor: pointer; text-decoration: underline; }
|
||||
.actions button { color: var(--color-brand-dark); font-weight: 700; }
|
||||
.table-button:disabled { color: var(--color-text-muted); cursor: not-allowed; }
|
||||
.empty { padding: 42px; text-align: center; color: var(--color-text-muted); }
|
||||
.status { display: inline-flex; align-items: center; justify-content: center; min-width: 66px; padding: 4px 9px; border-radius: 999px; font-size: .75rem; font-weight: 800; }
|
||||
.status--published, .status--available { color: var(--color-brand-dark); background: var(--color-brand-light); }
|
||||
.status--coming_soon { color: var(--color-brand-dark); background: var(--color-brand-light); }
|
||||
.status--draft { color: var(--color-text-muted); background: rgba(77, 77, 77, .12); }
|
||||
.status--archived { color: var(--color-text-muted); background: rgba(77, 77, 77, .12); }
|
||||
.pagination { display: flex; justify-content: center; gap: 7px; padding: 18px; }
|
||||
.pagination a { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid rgba(78, 73, 43, .25); border-radius: 7px; text-decoration: none; }
|
||||
.pagination a.active { color: var(--color-text-dark); background: var(--color-brand); border-color: var(--color-brand); font-weight: 800; }
|
||||
|
||||
.notice { margin-bottom: 20px; padding: 14px 17px; border-radius: 9px; border: 1px solid rgba(78, 73, 43, .25); background: var(--color-surface); }
|
||||
.notice--success { color: var(--color-brand-dark); border-color: rgba(78, 73, 43, .3); background: var(--color-brand-light); }
|
||||
.notice--error { color: var(--color-text-dark); border-color: rgba(78, 73, 43, .4); background: var(--color-surface-soft); }
|
||||
.notice--warning { color: var(--color-brand-dark); background: var(--color-brand-light); }
|
||||
|
||||
.editor { padding: clamp(20px, 4vw, 34px); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 7px; color: var(--color-text-muted); font-size: .87rem; font-weight: 700; }
|
||||
.field--wide { grid-column: 1 / -1; }
|
||||
.field small { font-weight: 400; overflow-wrap: anywhere; }
|
||||
.check { display: flex; align-items: center; min-height: 43px; gap: 8px; }
|
||||
.check input { width: auto; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 28px; padding-top: 20px; border-top: 1px solid rgba(78, 73, 43, .14); }
|
||||
.inline-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; align-items: end; gap: 14px; padding: 22px; }
|
||||
.inline-form label { display: flex; flex-direction: column; gap: 6px; color: var(--color-text-muted); font-size: .82rem; font-weight: 700; }
|
||||
|
||||
.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 { width: min(460px, 100%); padding: clamp(28px, 6vw, 48px); 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 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-gem { color: var(--color-brand); font-size: 2.5rem; text-align: center; }
|
||||
.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-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 { grid-template-columns: 1fr; }
|
||||
.field--wide { grid-column: auto; }
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo;
|
||||
global $config;
|
||||
if ($pdo instanceof PDO) {
|
||||
return $pdo;
|
||||
}
|
||||
$db = $config['db'];
|
||||
$dsn = "mysql:host={$db['host']};port={$db['port']};dbname={$db['name']};charset=utf8mb4";
|
||||
$pdo = new PDO($dsn, $db['user'], $db['pass'], [
|
||||
PDO::ATTR_ERRMODE => 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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
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 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();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($title) ?>|<?= h($config['site_name']) ?> Admin</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="assets/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="admin-topbar">
|
||||
<a class="admin-brand" href="index.php"><i class="bi bi-gem"></i><span>水晶男孩推廣部</span><small>Admin</small></a>
|
||||
<div class="admin-topbar__actions"><span><?= h($_SESSION['admin_name'] ?? '') ?></span><a href="../index.html" target="_blank" rel="noopener">查看前臺</a><a href="logout.php">登出</a></div>
|
||||
</header>
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar" id="admin-sidebar">
|
||||
<nav aria-label="後臺管理選單">
|
||||
<a class="<?= $active === 'dashboard' ? 'active' : '' ?>" href="index.php"><i class="bi bi-speedometer2"></i>控制台</a>
|
||||
<p>內容資料</p>
|
||||
<?php foreach ($mods as $key => $mod): ?>
|
||||
<a class="<?= $active === $key ? 'active' : '' ?>" href="records.php?module=<?= h($key) ?>"><i class="bi bi-<?= h($mod['icon']) ?>"></i><?= h($mod['label']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<p>系統</p>
|
||||
<a class="<?= $active === 'publishing' ? 'active' : '' ?>" href="publishing.php"><i class="bi bi-eye"></i>公開狀態</a>
|
||||
<a class="<?= $active === 'admins' ? 'active' : '' ?>" href="admins.php"><i class="bi bi-shield-lock"></i>管理者帳號</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="admin-main">
|
||||
<button class="sidebar-toggle" type="button" data-sidebar-toggle aria-label="開啟管理選單"><i class="bi bi-list"></i></button>
|
||||
<?php if ($flash): ?><div class="notice notice--<?= h($flash['type']) ?>"><?= h($flash['message']) ?></div><?php endif; ?>
|
||||
<div class="page-heading"><div><p class="eyebrow">CONTENT MANAGEMENT</p><h1><?= h($title) ?></h1></div></div>
|
||||
<?php
|
||||
}
|
||||
|
||||
function render_footer(): void
|
||||
{
|
||||
?>
|
||||
</main>
|
||||
</div>
|
||||
<script src="assets/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'site_name' => '水晶男孩推廣部',
|
||||
'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,
|
||||
];
|
||||
@@ -0,0 +1,128 @@
|
||||
CREATE DATABASE IF NOT EXISTS sechskies_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE sechskies_cms;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(80) NOT NULL,
|
||||
email VARCHAR(190) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_assets (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
display_name VARCHAR(160) NOT NULL,
|
||||
original_name VARCHAR(255) NULL,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
media_type ENUM('image','video','pdf','other') NOT NULL DEFAULT 'image',
|
||||
mime_type VARCHAR(100) NULL,
|
||||
file_size INT UNSIGNED NULL,
|
||||
alt_text VARCHAR(255) NULL,
|
||||
source_note TEXT NULL,
|
||||
category VARCHAR(80) NULL,
|
||||
status ENUM('available','draft','archived') NOT NULL DEFAULT 'draft',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(120) NOT NULL,
|
||||
title_ko VARCHAR(160) NULL,
|
||||
release_date DATE NULL,
|
||||
era ENUM('classic','reunion') NULL,
|
||||
cover_media_id INT UNSIGNED NULL,
|
||||
summary TEXT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_albums_cover FOREIGN KEY (cover_media_id) REFERENCES media_assets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
album_id INT UNSIGNED NOT NULL,
|
||||
track_no SMALLINT UNSIGNED NULL,
|
||||
title VARCHAR(160) NOT NULL,
|
||||
title_ko VARCHAR(160) NULL,
|
||||
lyrics_url VARCHAR(500) NULL,
|
||||
video_url VARCHAR(500) NULL,
|
||||
is_title_track TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_songs_album FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS concert_events (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
tour_name VARCHAR(180) NOT NULL,
|
||||
event_date DATE NOT NULL,
|
||||
country VARCHAR(80) NULL,
|
||||
city VARCHAR(80) NULL,
|
||||
venue VARCHAR(180) NULL,
|
||||
member_names VARCHAR(255) NULL,
|
||||
poster_media_id INT UNSIGNED NULL,
|
||||
source_url VARCHAR(500) NULL,
|
||||
notes TEXT NULL,
|
||||
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_concert_poster FOREIGN KEY (poster_media_id) REFERENCES media_assets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS variety_shows (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(180) NOT NULL,
|
||||
series_name VARCHAR(180) NULL,
|
||||
platform VARCHAR(100) NULL,
|
||||
air_start DATE NULL,
|
||||
air_end DATE NULL,
|
||||
member_names VARCHAR(255) NULL,
|
||||
episode_info VARCHAR(100) NULL,
|
||||
video_url VARCHAR(500) NULL,
|
||||
summary TEXT NULL,
|
||||
tags VARCHAR(255) NULL,
|
||||
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(180) NOT NULL,
|
||||
visit_date DATE NULL,
|
||||
day_label VARCHAR(40) NULL,
|
||||
address VARCHAR(255) NULL,
|
||||
latitude DECIMAL(10,7) NULL,
|
||||
longitude DECIMAL(10,7) NULL,
|
||||
member_names VARCHAR(255) NULL,
|
||||
description TEXT NULL,
|
||||
quote_text TEXT NULL,
|
||||
tags VARCHAR(255) NULL,
|
||||
media_id INT UNSIGNED NULL,
|
||||
source_url VARCHAR(500) NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
status ENUM('draft','coming_soon','published','archived') NOT NULL DEFAULT 'draft',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_location_media FOREIGN KEY (media_id) REFERENCES media_assets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
admin_user_id INT UNSIGNED NULL,
|
||||
action_name VARCHAR(40) NOT NULL,
|
||||
entity_type VARCHAR(80) NOT NULL,
|
||||
entity_id INT UNSIGNED NULL,
|
||||
details_json JSON NULL,
|
||||
ip_address VARCHAR(45) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_audit_entity (entity_type, entity_id),
|
||||
CONSTRAINT fk_audit_admin FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB;
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$stats = [];
|
||||
foreach (modules() as $key => $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');
|
||||
?>
|
||||
<section class="stat-grid">
|
||||
<article class="stat-card"><span>已公開內容</span><strong><?= $published ?></strong><i class="bi bi-broadcast"></i></article>
|
||||
<article class="stat-card"><span>籌備中內容</span><strong><?= $comingSoon ?></strong><i class="bi bi-hourglass-split"></i></article>
|
||||
<article class="stat-card"><span>媒體素材</span><strong><?= $stats['media'] ?></strong><i class="bi bi-images"></i></article>
|
||||
<article class="stat-card"><span>全部內容</span><strong><?= array_sum($stats) ?></strong><i class="bi bi-archive"></i></article>
|
||||
</section>
|
||||
<section class="panel"><div class="panel__head"><div><h2>內容概況</h2><p>依本站真正的資料分類統計</p></div></div><div class="module-grid">
|
||||
<?php foreach (modules() as $key => $module): ?><a href="records.php?module=<?= h($key) ?>"><i class="bi bi-<?= h($module['icon']) ?>"></i><span><?= h($module['label']) ?></span><strong><?= $stats[$key] ?></strong></a><?php endforeach; ?>
|
||||
</div></section>
|
||||
<section class="panel"><div class="panel__head"><div><h2>最近管理紀錄</h2><p>保留新增、修改、刪除與登入軌跡</p></div></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>時間</th><th>管理者</th><th>動作</th><th>資料類型</th><th>編號</th></tr></thead><tbody>
|
||||
<?php foreach ($recent as $row): ?><tr><td><?= h($row['created_at']) ?></td><td><?= h($row['display_name'] ?? '系統') ?></td><td><?= h($row['action_name']) ?></td><td><?= h($row['entity_type']) ?></td><td><?= h((string) $row['entity_id']) ?></td></tr><?php endforeach; ?>
|
||||
<?php if (!$recent): ?><tr><td colspan="5" class="empty">目前尚無管理紀錄。</td></tr><?php endif; ?>
|
||||
</tbody></table></div></section>
|
||||
<?php render_footer();
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
if (!empty($_SESSION['admin_id'])) redirect('index.php');
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
try {
|
||||
$stmt = db()->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 = '資料庫尚未完成設定,請先執行初始化。';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>管理登入|水晶男孩推廣部</title><link rel="stylesheet" href="assets/admin.css"></head>
|
||||
<body class="auth-page"><main class="auth-card"><div class="auth-gem">◆</div><p class="eyebrow">SECHSKIES ARCHIVE</p><h1>推廣部管理登入</h1><p>把散落的記憶,好好收進同一片黃色海洋。</p><?php if (isset($_GET['setup'])): ?><div class="notice notice--success">管理者建立完成,請登入。</div><?php endif; ?><?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
|
||||
<form method="post"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><label>管理者帳號<input name="username" required autocomplete="username" autofocus></label><label>密碼<input name="password" type="password" required autocomplete="current-password"></label><button class="button button--primary" type="submit">登入後臺</button><a class="text-link" href="setup.php">第一次使用?初始化系統</a></form></main></body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
if (!empty($_SESSION['admin_id'])) audit('logout', 'admin_users', (int) $_SESSION['admin_id']);
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
redirect('login.php');
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
require_login();
|
||||
$rows = [];
|
||||
foreach (modules() as $key => $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');
|
||||
?>
|
||||
<div class="notice notice--warning"><strong>公開狀態不會直接改寫 portal-nav.js。</strong> 第一階段先管理內容狀態;正式同步頂欄前仍須依本站規則逐頁確認。</div>
|
||||
<section class="panel"><div class="panel__head"><div><h2>各模組發布概況</h2><p>公開前先完成資料與來源檢查</p></div></div><div class="table-wrap"><table><thead><tr><th>內容模組</th><th>草稿</th><th>籌備中</th><th>公開</th><th>封存</th><th>管理</th></tr></thead><tbody><?php foreach ($rows as $row): ?><tr><td><?= h($row['label']) ?></td><td><?= $row['counts']['draft'] ?></td><td><?= $row['counts']['coming_soon'] ?></td><td><?= $row['counts']['published'] ?></td><td><?= $row['counts']['archived'] ?></td><td><a href="records.php?module=<?= h($row['key']) ?>">查看資料</a></td></tr><?php endforeach; ?></tbody></table></div></section>
|
||||
<?php render_footer();
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
require_login();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
|
||||
verify_csrf();
|
||||
$key = (string) ($_POST['module'] ?? '');
|
||||
$module = module_config($key);
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$stmt = db()->prepare('DELETE FROM `' . $module['table'] . '` WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
audit('delete', $module['table'], $id);
|
||||
flash('success', '資料已刪除。');
|
||||
redirect('records.php?module=' . urlencode($key));
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$key = (string) ($_GET['module'] ?? $_POST['module'] ?? '');
|
||||
$module = module_config($key);
|
||||
$id = (int) ($_GET['id'] ?? $_POST['id'] ?? 0);
|
||||
$record = [];
|
||||
if ($id) {
|
||||
$stmt = db()->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['label'], $key);
|
||||
?>
|
||||
<?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
|
||||
<form class="panel editor" method="post" enctype="multipart/form-data"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><input type="hidden" name="module" value="<?= h($key) ?>"><input type="hidden" name="id" value="<?= $id ?>"><div class="form-grid">
|
||||
<?php foreach ($module['fields'] as $name => $field): $value = $record[$name] ?? ($field['default'] ?? ''); ?><label class="field <?= $field['type'] === 'textarea' ? 'field--wide' : '' ?>"><span><?= h($field['label']) ?><?= !empty($field['required']) ? ' *' : '' ?></span>
|
||||
<?php if ($field['type'] === 'textarea'): ?><textarea name="<?= h($name) ?>" rows="6" <?= !empty($field['required']) ? 'required' : '' ?>><?= h((string) $value) ?></textarea>
|
||||
<?php elseif ($field['type'] === 'select'): ?><select name="<?= h($name) ?>" <?= !empty($field['required']) ? 'required' : '' ?>><option value="">請選擇</option><?php foreach ($field['options'] as $optionValue => $optionLabel): ?><option value="<?= h($optionValue) ?>" <?= (string) $value === (string) $optionValue ? 'selected' : '' ?>><?= h($optionLabel) ?></option><?php endforeach; ?></select>
|
||||
<?php elseif ($field['type'] === 'status'): ?><select name="<?= h($name) ?>" required><?php foreach (['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存'] as $optionValue => $optionLabel): ?><option value="<?= $optionValue ?>" <?= (string) ($value ?: 'draft') === $optionValue ? 'selected' : '' ?>><?= $optionLabel ?></option><?php endforeach; ?></select>
|
||||
<?php elseif ($field['type'] === 'relation'): ?><select name="<?= h($name) ?>" required><option value="">請選擇</option><?php foreach (db()->query("SELECT id, title FROM `{$field['table']}` ORDER BY title")->fetchAll() as $option): ?><option value="<?= (int) $option['id'] ?>" <?= (string) $value === (string) $option['id'] ? 'selected' : '' ?>><?= h($option['title']) ?></option><?php endforeach; ?></select>
|
||||
<?php elseif ($field['type'] === 'checkbox'): ?><span class="check"><input type="checkbox" name="<?= h($name) ?>" value="1" <?= $value ? 'checked' : '' ?>> 是</span>
|
||||
<?php elseif ($field['type'] === 'file'): ?><?php if ($value): ?><small>目前檔案:<?= h((string) $value) ?></small><?php endif; ?><input type="file" name="<?= h($name) ?>" accept="image/*,video/mp4,application/pdf" <?= !$id ? 'required' : '' ?>>
|
||||
<?php else: ?><input type="<?= h($field['type']) ?>" name="<?= h($name) ?>" value="<?= h((string) $value) ?>" <?= isset($field['step']) ? 'step="' . h($field['step']) . '"' : '' ?> <?= !empty($field['required']) ? 'required' : '' ?>><?php endif; ?></label><?php endforeach; ?></div>
|
||||
<div class="form-actions"><a class="button" href="records.php?module=<?= h($key) ?>">取消</a><button class="button button--primary" type="submit"><?= $id ? '儲存修改' : '建立資料' ?></button></div></form>
|
||||
<?php render_footer();
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
require_login();
|
||||
|
||||
$key = (string) ($_GET['module'] ?? '');
|
||||
$module = module_config($key);
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = 12;
|
||||
$query = trim((string) ($_GET['q'] ?? ''));
|
||||
$status = trim((string) ($_GET['status'] ?? ''));
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($query !== '') {
|
||||
$searchable = [];
|
||||
foreach ($module['fields'] as $name => $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);
|
||||
?>
|
||||
<div class="toolbar"><form class="filters" method="get"><input type="hidden" name="module" value="<?= h($key) ?>"><label class="search"><i class="bi bi-search"></i><input name="q" value="<?= h($query) ?>" placeholder="搜尋本區資料"></label><?php if (isset($module['fields']['status'])): ?><select name="status"><option value="">全部狀態</option><?php foreach (['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存', 'available' => '可使用'] as $value => $label): ?><option value="<?= $value ?>" <?= $status === $value ? 'selected' : '' ?>><?= $label ?></option><?php endforeach; ?></select><?php endif; ?><button class="button" type="submit">篩選</button></form><a class="button button--primary" href="record-form.php?module=<?= h($key) ?>"><i class="bi bi-plus-lg"></i>新增資料</a></div>
|
||||
<section class="panel"><div class="panel__head"><div><h2><?= h($module['label']) ?>清單</h2><p>共 <?= $total ?> 筆;每頁 <?= $perPage ?> 筆</p></div></div><div class="table-wrap"><table><thead><tr><th>編號</th><?php foreach ($module['list'] as $field): ?><th><?= h($module['fields'][$field]['label'] ?? $field) ?></th><?php endforeach; ?><th>操作</th></tr></thead><tbody>
|
||||
<?php foreach ($rows as $row): ?><tr><td>#<?= (int) $row['id'] ?></td><?php foreach ($module['list'] as $field): ?><td><?php $value = $row[$field] ?? ''; if ($field === 'status'): ?><span class="status status--<?= h($value) ?>"><?= h(status_label($value)) ?></span><?php elseif (isset($relations[$field])): ?><?= h($relations[$field][$value] ?? ('#' . $value)) ?><?php elseif (($module['fields'][$field]['type'] ?? '') === 'checkbox'): ?><?= $value ? '是' : '否' ?><?php else: ?><?= h(mb_strimwidth((string) $value, 0, 42, '…', 'UTF-8')) ?><?php endif; ?></td><?php endforeach; ?><td class="actions"><a href="record-form.php?module=<?= h($key) ?>&id=<?= (int) $row['id'] ?>">編輯</a><form method="post" action="record-delete.php" data-confirm="確定刪除這筆資料?此動作無法復原。"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><input type="hidden" name="module" value="<?= h($key) ?>"><input type="hidden" name="id" value="<?= (int) $row['id'] ?>"><button type="submit">刪除</button></form></td></tr><?php endforeach; ?>
|
||||
<?php if (!$rows): ?><tr><td colspan="<?= count($module['list']) + 2 ?>" class="empty">找不到符合條件的資料。</td></tr><?php endif; ?></tbody></table></div>
|
||||
<?php if ($pages > 1): ?><nav class="pagination" aria-label="資料分頁"><?php for ($i = 1; $i <= $pages; $i++): ?><a class="<?= $i === $page ? 'active' : '' ?>" href="?module=<?= h($key) ?>&q=<?= urlencode($query) ?>&status=<?= urlencode($status) ?>&page=<?= $i ?>"><?= $i ?></a><?php endfor; ?></nav><?php endif; ?></section>
|
||||
<?php render_footer();
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
|
||||
$error = '';
|
||||
try {
|
||||
$count = (int) db()->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');
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>初始化後臺</title><link rel="stylesheet" href="assets/admin.css"></head>
|
||||
<body class="auth-page"><main class="auth-card"><p class="eyebrow">FIRST-TIME SETUP</p><h1>建立第一位管理者</h1><p>水晶男孩推廣部內容管理系統</p><?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
|
||||
<form method="post"><input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>"><label>登入帳號<input name="username" required autocomplete="username"></label><label>顯示名稱<input name="display_name" required></label><label>電子信箱<input name="email" type="email"></label><label>登入密碼<input name="password" type="password" required minlength="10" autocomplete="new-password"></label><button class="button button--primary" type="submit">建立管理者</button></form></main></body></html>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+404
-304
@@ -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 = "<span>" + dayLabel + "</span><i class='fa-solid fa-chevron-down'></i>";
|
||||
|
||||
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 = "<span>" + dayLabel + "</span><i class='fa-solid fa-chevron-down'></i>";
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>水晶萬年曆 2026 — SECHSKIES SPECIAL CALENDAR</title>
|
||||
<link rel="stylesheet" href="assets/css/style.css">
|
||||
<link rel="stylesheet" href="assets/css/calendar.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="assets/css/tablet.css">
|
||||
</head>
|
||||
|
||||
<body class="calendar-page">
|
||||
<header class="inner-page-header">
|
||||
<div class="header-inner site-header-inner">
|
||||
<h1 class="main-title">水晶萬年曆</h1>
|
||||
<div class="subtitle-wrap">
|
||||
<span class="sub-title">2026 SECHSKIES SPECIAL CALENDAR</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="portal-nav" aria-label="水晶男孩推廣部導覽" data-portal-nav></nav>
|
||||
|
||||
<canvas id="particles"></canvas>
|
||||
|
||||
<main class="calendar-page-container">
|
||||
<!-- 上方控制控制面板 -->
|
||||
<section class="calendar-controls" aria-label="萬年曆控制選項">
|
||||
<div class="calendar-select-group">
|
||||
<button type="button" class="calendar-btn" id="prev-month-btn">
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i> 上個月
|
||||
</button>
|
||||
<select id="year-select" class="calendar-select" aria-label="選擇年份">
|
||||
<option value="2025">2025 年</option>
|
||||
<option value="2026" selected>2026 年</option>
|
||||
<option value="2027">2027 年</option>
|
||||
</select>
|
||||
<select id="month-select" class="calendar-select" aria-label="選擇月份">
|
||||
<option value="1">1 月</option>
|
||||
<option value="2">2 月</option>
|
||||
<option value="3">3 月</option>
|
||||
<option value="4">4 月</option>
|
||||
<option value="5">5 月</option>
|
||||
<option value="6">6 月</option>
|
||||
<option value="7">7 月</option>
|
||||
<option value="8">8 月</option>
|
||||
<option value="9">9 月</option>
|
||||
<option value="10">10 月</option>
|
||||
<option value="11">11 月</option>
|
||||
<option value="12">12 月</option>
|
||||
</select>
|
||||
<button type="button" class="calendar-btn" id="next-month-btn">
|
||||
下個月 <i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 分類篩選標籤 -->
|
||||
<div class="calendar-filters" role="group" aria-label="紀念日分類篩選">
|
||||
<button type="button" class="filter-chip active" data-category="all">全部紀念日</button>
|
||||
<button type="button" class="filter-chip" data-category="birthday">成員生日</button>
|
||||
<button type="button" class="filter-chip" data-category="debut">出道 / 重逢</button>
|
||||
<button type="button" class="filter-chip" data-category="concert">演唱會 / 見面會</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 萬年曆主要版面 (雙欄結構) -->
|
||||
<div class="calendar-layout">
|
||||
<!-- 左欄:動態日曆網格卡片 -->
|
||||
<section class="calendar-card">
|
||||
<h2 class="calendar-header-title" id="calendar-month-year-display">2026 年 8 月</h2>
|
||||
|
||||
<div class="calendar-weekdays">
|
||||
<div class="calendar-weekday weekend">日</div>
|
||||
<div class="calendar-weekday">一</div>
|
||||
<div class="calendar-weekday">二</div>
|
||||
<div class="calendar-weekday">三</div>
|
||||
<div class="calendar-weekday">四</div>
|
||||
<div class="calendar-weekday">五</div>
|
||||
<div class="calendar-weekday weekend">六</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-days-grid" id="calendar-days-grid">
|
||||
<!-- JS 動態生成日期網格 -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右欄:當月水晶紀念日明細清單 -->
|
||||
<aside class="events-sidebar">
|
||||
<h3 class="sidebar-title">
|
||||
<span id="sidebar-month-text">8 月專屬紀念日</span>
|
||||
<i class="fa-solid fa-star" aria-hidden="true"></i>
|
||||
</h3>
|
||||
|
||||
<div class="events-list" id="events-list">
|
||||
<!-- JS 動態生成事件列表與成員生日/演唱會照片卡片 -->
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 點擊日期的 Modal 彈窗 -->
|
||||
<div class="calendar-modal-backdrop" id="event-modal">
|
||||
<div class="calendar-modal-content">
|
||||
<button type="button" class="modal-close-btn" id="modal-close" aria-label="關閉視窗">×</button>
|
||||
<div id="modal-body-content">
|
||||
<!-- 動態注入內容 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>SECHSKIES Fanpage © 2026 — 2026 水晶男孩專屬萬年曆</p>
|
||||
<a href="https://jekkinoopy.github.io/Portfolio/" class="studio-link" target="_blank" rel="noopener noreferrer">
|
||||
Design by <span class="mark_b">Jekkinoopy Studio</span>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<!-- 全站 JavaScript 資源 -->
|
||||
<script src="assets/js/portal-nav.js"></script>
|
||||
<script src="assets/js/particles.js"></script>
|
||||
|
||||
<!-- 萬年曆核心邏輯 -->
|
||||
<script>
|
||||
// 水晶男孩專屬紀念日數據集 (含成員介紹頁連結錨點)
|
||||
const sechskiesEvents = [
|
||||
// 1月
|
||||
{
|
||||
month: 1, day: 21, endDay: 22, type: 'concert', title: 'Yellow Note Final 首爾演唱會 🎤',
|
||||
photo: 'assets/images/concert/2017.jpg', desc: '2017 年首爾終場演唱會紀念'
|
||||
},
|
||||
// 2月
|
||||
{
|
||||
month: 2, day: 22, type: 'birthday', title: '姜成勳 生日 🎂', memberName: '姜成勳', enName: 'KANG SUNG HOON', role: 'Main Vocal',
|
||||
photo: 'assets/images/member/kpoplover082006___Bsc2U5lhXuK___.jpg', desc: '1980.02.22 主唱姜成勳生日', memberUrl: 'member/member.html#poster-ksh'
|
||||
},
|
||||
// 3月
|
||||
{
|
||||
month: 3, day: 6, endDay: 8, type: 'concert', title: 'ACCESS 演唱會紀念 🎤',
|
||||
photo: 'assets/images/concert/2020.jpg', desc: '2020 SECHSKIES Concert [ACCESS]'
|
||||
},
|
||||
{ month: 3, day: 23, type: 'debut', title: 'J-Walk 組合出道日 🌟', desc: '2002.03.23 金在德與張水院發行首張專輯《Suddenly》' },
|
||||
// 4月
|
||||
{
|
||||
month: 4, day: 14, type: 'concert', title: '六六歌2 上岩黃海重逢日 🌟',
|
||||
photo: 'assets/images/totoga2/toto1.png', desc: '2016.04.14 無限挑戰六六歌2 上岩游擊演唱會16年後重逢'
|
||||
},
|
||||
{ month: 4, day: 15, type: 'debut', title: 'SECHSKIES 團體出道紀念日 🌟', desc: '1997.04.15 水晶男孩正式出道,傳奇盛世起點!' },
|
||||
// 5月
|
||||
{ month: 5, day: 18, type: 'debut', title: '水晶男孩告別宣佈日', desc: '2000.05.18 告別紀念' },
|
||||
{ month: 5, day: 24, type: 'debut', title: '姜成勳 Solo 出道日 🌟', desc: '2001.05.24 首張專輯《In Connection》' },
|
||||
// 6月
|
||||
{
|
||||
month: 6, day: 8, type: 'birthday', title: '殷志源 生日 🎂', memberName: '殷志源', enName: 'EUN JI WON', role: 'Leader · Rap · Dance',
|
||||
photo: 'assets/images/Eun Ji Won.jpeg', desc: '1978.06.08 水晶男孩隊長殷志源生日', memberUrl: 'member/member.html#poster-ejw'
|
||||
},
|
||||
// 7月
|
||||
{
|
||||
month: 7, day: 1, type: 'birthday', title: '高志溶 生日 🎂', memberName: '高志溶', enName: 'KO JI YONG', role: 'Vocal',
|
||||
photo: 'assets/images/member/92599-502183.jpg', desc: '1980.07.01 高志溶生日', memberUrl: 'member/member.html#poster-kjy'
|
||||
},
|
||||
{
|
||||
month: 7, day: 13, type: 'birthday', title: '李宰鎮 生日 🎂', memberName: '李宰鎮', enName: 'LEE JAE JIN', role: 'Rap · Dance',
|
||||
photo: 'assets/images/member/1595330620293.jpg', desc: '1979.07.13 李宰鎮生日', memberUrl: 'member/member.html#poster-ljj'
|
||||
},
|
||||
{
|
||||
month: 7, day: 16, type: 'birthday', title: '張水院 生日 🎂', memberName: '張水院', enName: 'JANG SU WON', role: 'Vocal',
|
||||
photo: 'assets/images/member/seven7lai_1___BuRBxcPAIsP___.jpg', desc: '1980.07.16 張水院生日', memberUrl: 'member/member.html#poster-jsw'
|
||||
},
|
||||
// 8月
|
||||
{
|
||||
month: 8, day: 7, type: 'birthday', title: '金在德 生日 🎂', memberName: '金在德', enName: 'KIM JAE DUC', role: 'Rap · Dance',
|
||||
photo: 'assets/images/member/4c0e013b341e85622979475bc230719b.jpg', desc: '1979.08.07 天使笑顏與舞蹈機器金在德生日', memberUrl: 'member/member.html#poster-kjd'
|
||||
},
|
||||
// 9月
|
||||
{
|
||||
month: 9, day: 10, endDay: 11, type: 'concert', title: 'Yellow Note 演唱會 (二安日) 🎤',
|
||||
photo: 'assets/images/concert/2016.jpg', desc: '2016 黃海 6 分鐘喊聲喚回大爺們重返舞台'
|
||||
},
|
||||
{
|
||||
month: 9, day: 23, type: 'concert', title: '20 週年高尺巨蛋演唱會 🎤',
|
||||
photo: 'assets/images/concert/2017.jpg', desc: '2017 20th Anniversary Concert 高尺天空巨蛋'
|
||||
},
|
||||
// 10月
|
||||
{
|
||||
month: 10, day: 1, type: 'concert', title: 'BOF 釜山演唱會 🎤',
|
||||
photo: 'assets/images/mission-balloon-sea.png', desc: '2016 60台遊覽車 6000人黃海盛況「黑幫黃」傳奇'
|
||||
},
|
||||
{ month: 10, day: 7, type: 'debut', title: '《三個詞 Three Words》發行日 🌟', desc: '2016.10.07 回歸單曲音源發行日' },
|
||||
{
|
||||
month: 10, day: 13, endDay: 14, type: 'concert', title: 'YELLOFANTASY 22週年演唱會 🎤',
|
||||
photo: 'assets/images/concert/2018.jpg', desc: '2018 SECHSKIES 2018 CONCERT – YELLOFANTASY'
|
||||
},
|
||||
{ month: 10, day: 27, type: 'debut', title: '殷志源 Solo 出道日 🌟', desc: '2000.10.27 單曲《A-Ha》/《G Pop》' },
|
||||
// 11月
|
||||
{ month: 11, day: 19, type: 'debut', title: '李宰鎮 Solo 出道日 🌟', desc: '2001.11.19 首張專輯《S.Double.J》' }
|
||||
];
|
||||
|
||||
// 狀態變數
|
||||
let currentYear = 2026;
|
||||
let currentMonth = 8; // 預設 8月
|
||||
let activeCategory = 'all';
|
||||
|
||||
// DOM 元素
|
||||
const yearSelect = document.getElementById('year-select');
|
||||
const monthSelect = document.getElementById('month-select');
|
||||
const prevBtn = document.getElementById('prev-month-btn');
|
||||
const nextBtn = document.getElementById('next-month-btn');
|
||||
const daysGrid = document.getElementById('calendar-days-grid');
|
||||
const monthYearDisplay = document.getElementById('calendar-month-year-display');
|
||||
const eventsList = document.getElementById('events-list');
|
||||
const sidebarMonthText = document.getElementById('sidebar-month-text');
|
||||
|
||||
const eventModal = document.getElementById('event-modal');
|
||||
const modalClose = document.getElementById('modal-close');
|
||||
const modalBody = document.getElementById('modal-body-content');
|
||||
|
||||
function cleanEventTitle(title) {
|
||||
return String(title || '').replace(/[🎂🌟🎤]/g, '').trim();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
function initCalendar() {
|
||||
yearSelect.value = currentYear;
|
||||
monthSelect.value = currentMonth;
|
||||
renderCalendar(currentYear, currentMonth);
|
||||
|
||||
// 事件監聽
|
||||
yearSelect.addEventListener('change', (e) => {
|
||||
currentYear = parseInt(e.target.value);
|
||||
renderCalendar(currentYear, currentMonth);
|
||||
});
|
||||
|
||||
monthSelect.addEventListener('change', (e) => {
|
||||
currentMonth = parseInt(e.target.value);
|
||||
renderCalendar(currentYear, currentMonth);
|
||||
});
|
||||
|
||||
prevBtn.addEventListener('click', () => {
|
||||
currentMonth--;
|
||||
if (currentMonth < 1) {
|
||||
currentMonth = 12;
|
||||
currentYear--;
|
||||
}
|
||||
yearSelect.value = currentYear;
|
||||
monthSelect.value = currentMonth;
|
||||
renderCalendar(currentYear, currentMonth);
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', () => {
|
||||
currentMonth++;
|
||||
if (currentMonth > 12) {
|
||||
currentMonth = 1;
|
||||
currentYear++;
|
||||
}
|
||||
yearSelect.value = currentYear;
|
||||
monthSelect.value = currentMonth;
|
||||
renderCalendar(currentYear, currentMonth);
|
||||
});
|
||||
|
||||
// 分類標籤篩選
|
||||
document.querySelectorAll('.filter-chip').forEach(chip => {
|
||||
chip.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
activeCategory = e.target.getAttribute('data-category');
|
||||
renderCalendar(currentYear, currentMonth);
|
||||
});
|
||||
});
|
||||
|
||||
// Modal 關閉
|
||||
modalClose.addEventListener('click', () => {
|
||||
eventModal.classList.remove('active');
|
||||
});
|
||||
eventModal.addEventListener('click', (e) => {
|
||||
if (e.target === eventModal) eventModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// 渲染日曆
|
||||
function renderCalendar(year, month) {
|
||||
monthYearDisplay.textContent = `${year} 年 ${month} 月`;
|
||||
sidebarMonthText.textContent = `${month} 月水晶紀念日`;
|
||||
daysGrid.innerHTML = '';
|
||||
|
||||
// 1. 計算該月第一天是星期幾 & 總天數
|
||||
const firstDayIndex = new Date(year, month - 1, 1).getDay(); // 0 = 星期日
|
||||
const totalDays = new Date(year, month, 0).getDate();
|
||||
const prevMonthTotalDays = new Date(year, month - 1, 0).getDate();
|
||||
|
||||
// 取得當月屬於水晶的事件
|
||||
const monthEvents = sechskiesEvents.filter(ev => ev.month === month && (activeCategory === 'all' || ev.type === activeCategory));
|
||||
|
||||
// 2. 渲染上個月填補空格
|
||||
for (let i = firstDayIndex - 1; i >= 0; i--) {
|
||||
const dayCell = document.createElement('div');
|
||||
dayCell.className = 'day-cell other-month';
|
||||
dayCell.innerHTML = `<span class="day-num">${prevMonthTotalDays - i}</span>`;
|
||||
daysGrid.appendChild(dayCell);
|
||||
}
|
||||
|
||||
// 3. 渲染當月每一天
|
||||
for (let day = 1; day <= totalDays; day++) {
|
||||
const dayCell = document.createElement('div');
|
||||
dayCell.className = 'day-cell';
|
||||
const dayOfWeek = new Date(year, month - 1, day).getDay();
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
dayCell.classList.add('weekend');
|
||||
}
|
||||
|
||||
// 檢查是否有水晶事件
|
||||
const matchedEvents = monthEvents.filter(ev => {
|
||||
if (ev.endDay) {
|
||||
return day >= ev.day && day <= ev.endDay;
|
||||
}
|
||||
return ev.day === day;
|
||||
});
|
||||
|
||||
let dayContent = `<span class="day-num">${day}</span>`;
|
||||
|
||||
if (matchedEvents.length > 0) {
|
||||
// 有生日事件時顯示頭像
|
||||
const bdayEvent = matchedEvents.find(ev => ev.type === 'birthday');
|
||||
const concertEvent = matchedEvents.find(ev => ev.type === 'concert' && ev.photo);
|
||||
|
||||
if (bdayEvent && bdayEvent.photo) {
|
||||
const linkUrl = bdayEvent.memberUrl || 'member/member.html';
|
||||
dayContent += `<a href="${linkUrl}" class="day-birthday-link" onclick="event.stopPropagation();" title="點擊查看 ${bdayEvent.memberName} 詳細介紹"><img src="${bdayEvent.photo}" alt="${bdayEvent.memberName}" class="day-avatar-badge"><span class="day-member-name">${bdayEvent.memberName}</span></a>`;
|
||||
} else if (concertEvent && concertEvent.photo) {
|
||||
dayContent += `<img src="${concertEvent.photo}" alt="${concertEvent.title}" class="day-concert-badge" title="${concertEvent.title}">`;
|
||||
} else {
|
||||
// 一般事件點點
|
||||
dayContent += '<div class="day-events">';
|
||||
matchedEvents.forEach(ev => {
|
||||
dayContent += `<span class="event-dot ${ev.type}" title="${ev.title}"></span>`;
|
||||
});
|
||||
dayContent += '</div>';
|
||||
}
|
||||
|
||||
// 點擊格點觸發 Modal (若非點擊在直接連結上)
|
||||
dayCell.addEventListener('click', () => {
|
||||
showDayModal(year, month, day, matchedEvents);
|
||||
});
|
||||
}
|
||||
|
||||
dayCell.innerHTML = dayContent;
|
||||
daysGrid.appendChild(dayCell);
|
||||
}
|
||||
|
||||
// 4. 渲染下個月填補空格
|
||||
const totalRendered = firstDayIndex + totalDays;
|
||||
const remainingGrid = (totalRendered % 7 === 0) ? 0 : 7 - (totalRendered % 7);
|
||||
for (let i = 1; i <= remainingGrid; i++) {
|
||||
const dayCell = document.createElement('div');
|
||||
dayCell.className = 'day-cell other-month';
|
||||
dayCell.innerHTML = `<span class="day-num">${i}</span>`;
|
||||
daysGrid.appendChild(dayCell);
|
||||
}
|
||||
|
||||
// 5. 渲染右側事件列表
|
||||
renderSidebarEvents(monthEvents);
|
||||
}
|
||||
|
||||
// 渲染右欄當月事件明細
|
||||
function renderSidebarEvents(events) {
|
||||
eventsList.innerHTML = '';
|
||||
if (events.length === 0) {
|
||||
eventsList.innerHTML = '<p class="events-empty">本月無符合條件之水晶紀念日</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
events.forEach(ev => {
|
||||
if (ev.type === 'birthday' && ev.photo) {
|
||||
// 生日精美照片卡片 (點圖片連成員介紹頁面)
|
||||
const linkUrl = ev.memberUrl || 'member/member.html';
|
||||
const bdayCard = document.createElement('div');
|
||||
bdayCard.className = 'birthday-featured-card';
|
||||
bdayCard.innerHTML = `
|
||||
<a href="${linkUrl}" class="birthday-photo-wrap" title="點擊前往 ${ev.memberName} 個人介紹頁面">
|
||||
<img src="${ev.photo}" alt="${ev.memberName}">
|
||||
</a>
|
||||
<div class="birthday-info">
|
||||
<h4><a href="${linkUrl}" title="前往成員介紹">${ev.memberName}</a></h4>
|
||||
<div class="en-name">${ev.enName} • ${ev.role}</div>
|
||||
<div class="bday-date">${ev.month} 月 ${ev.day} 日</div>
|
||||
</div>
|
||||
`;
|
||||
eventsList.appendChild(bdayCard);
|
||||
} else if (ev.type === 'concert' && ev.photo) {
|
||||
// 演唱會帶圖卡片
|
||||
const dateStr = ev.endDay ? `${ev.month}/${ev.day} ~ ${ev.month}/${ev.endDay}` : `${ev.month}/${ev.day}`;
|
||||
const concertCard = document.createElement('div');
|
||||
concertCard.className = 'concert-featured-card';
|
||||
concertCard.innerHTML = `
|
||||
<img src="${ev.photo}" alt="${ev.title}">
|
||||
<div class="event-card-header">
|
||||
<span class="event-date-badge">${dateStr}</span>
|
||||
</div>
|
||||
<div class="event-title">${cleanEventTitle(ev.title)}</div>
|
||||
<div class="event-desc">${ev.desc}</div>
|
||||
`;
|
||||
eventsList.appendChild(concertCard);
|
||||
} else {
|
||||
// 一般事件卡片
|
||||
const dateStr = ev.endDay ? `${ev.month}/${ev.day} ~ ${ev.month}/${ev.endDay}` : `${ev.month}/${ev.day}`;
|
||||
const card = document.createElement('div');
|
||||
card.className = `event-card type-${ev.type}`;
|
||||
card.innerHTML = `
|
||||
<div class="event-card-header">
|
||||
<span class="event-date-badge">${dateStr}</span>
|
||||
</div>
|
||||
<div class="event-title">${cleanEventTitle(ev.title)}</div>
|
||||
<div class="event-desc">${ev.desc}</div>
|
||||
`;
|
||||
eventsList.appendChild(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 顯示 Modal 詳情
|
||||
function showDayModal(year, month, day, events) {
|
||||
let html = `<h3 class="modal-date-title">${year} 年 ${month} 月 ${day} 日</h3>`;
|
||||
events.forEach(ev => {
|
||||
if (ev.type === 'birthday' && ev.photo) {
|
||||
const linkUrl = ev.memberUrl || 'member/member.html';
|
||||
html += `
|
||||
<a href="${linkUrl}" title="點擊前往 ${ev.memberName} 個人介紹頁面">
|
||||
<img src="${ev.photo}" alt="${ev.memberName}" class="modal-photo">
|
||||
</a>
|
||||
<h4 class="modal-member-name"><a href="${linkUrl}">${ev.memberName}</a></h4>
|
||||
<p class="modal-member-meta">${ev.enName} (${ev.role})</p>
|
||||
<p class="modal-desc">${ev.desc}</p>
|
||||
<a href="${linkUrl}" class="calendar-btn modal-member-link">前往 ${ev.memberName} 專屬頁面 <i class="fa-solid fa-arrow-right"></i></a>
|
||||
`;
|
||||
} else if (ev.type === 'concert' && ev.photo) {
|
||||
html += `
|
||||
<img src="${ev.photo}" alt="${ev.title}" class="modal-concert-photo">
|
||||
<h4 class="modal-event-title">${cleanEventTitle(ev.title)}</h4>
|
||||
<p class="modal-desc">${ev.desc}</p>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<div class="modal-event-box">
|
||||
<h4 class="modal-event-title">${cleanEventTitle(ev.title)}</h4>
|
||||
<p class="modal-desc">${ev.desc}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
modalBody.innerHTML = html;
|
||||
eventModal.classList.add('active');
|
||||
}
|
||||
|
||||
// 頁面載入完成後執行 init
|
||||
document.addEventListener('DOMContentLoaded', initCalendar);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+2
-2
@@ -99,7 +99,7 @@
|
||||
<div class="member-poster__wanted">WHITE</div>
|
||||
<div class="member-poster__sheet">
|
||||
<div class="member-poster__photo">
|
||||
<img src="../assets/images/member/92599-502183.jpg" alt="姜成勳肖像" width="280" height="340" loading="lazy">
|
||||
<img src="../assets/images/member/kpoplover082006___Bsc2U5lhXuK___.jpg" alt="姜成勳肖像" width="280" height="340" loading="lazy">
|
||||
</div>
|
||||
<p class="member-poster__role">Main Vocal</p>
|
||||
<h2 class="member-poster__name" id="poster-ksh">姜成勳</h2>
|
||||
@@ -114,7 +114,7 @@
|
||||
<div class="member-poster__wanted">WHITE</div>
|
||||
<div class="member-poster__sheet">
|
||||
<div class="member-poster__photo">
|
||||
<img src="../assets/images/member/noona7273___Bt7EZHRAGgs___.jpg" alt="高志溶肖像" width="280" height="340" loading="lazy">
|
||||
<img src="../assets/images/member/92599-502183.jpg" alt="高志溶肖像" width="280" height="340" loading="lazy">
|
||||
</div>
|
||||
<p class="member-poster__role">Vocal</p>
|
||||
<h2 class="member-poster__name" id="poster-kjy">高志溶</h2>
|
||||
|
||||
+92
-63
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SECHSKIES 台灣行程地圖</title>
|
||||
<title>SECHSKIES 節目景點地圖</title>
|
||||
<link rel="stylesheet" href="../assets/css/style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="stylesheet" href="../assets/css/tablet.css">
|
||||
@@ -13,7 +13,7 @@
|
||||
body.map-page .map-embed-all[hidden],
|
||||
body.map-page .map-embed-single[hidden],
|
||||
body.map-page .map-links-dual[hidden],
|
||||
body.map-page #open-map-link[hidden] {
|
||||
body.map-page .map-link[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -29,19 +29,18 @@
|
||||
否則會變成瀏覽器的「片段模式」,樣式常常吃不到或跑版。 -->
|
||||
<header class="inner-page-header">
|
||||
<div class="header-inner site-header-inner">
|
||||
<h1 class="main-title">台灣行程列表</h1>
|
||||
<h1 class="main-title">節目景點列表</h1>
|
||||
<div class="subtitle-wrap">
|
||||
<span class="sub-title">SECHSKIES TAIWAN ARCHIVE</span>
|
||||
<span class="sub-title">SECHSKIES LOCATION ARCHIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="portal-nav" aria-label="水晶男孩推廣部導覽" data-portal-nav></nav>
|
||||
|
||||
<div class="member-tabs" aria-label="成員篩選">
|
||||
<button class="tab-btn active" data-member="eun" aria-pressed="true">殷空少搭飛機去台灣高雄</button>
|
||||
<button class="tab-btn" data-member="ko" aria-pressed="false">高高父子遊台灣</button>
|
||||
<button class="tab-btn" data-member="all" aria-pressed="false">全部行程</button>
|
||||
<div class="region-tabs" aria-label="地區篩選" data-region-tabs>
|
||||
<button class="region-tab-btn active" data-region="taipei" aria-pressed="true">台灣行程</button>
|
||||
<button class="region-tab-btn" data-region="global" aria-pressed="false">海外行程</button>
|
||||
</div>
|
||||
|
||||
<canvas id="particles"></canvas>
|
||||
@@ -49,53 +48,50 @@
|
||||
<input id="itinerary-search" type="search" aria-label="行程搜尋"
|
||||
placeholder="搜尋地點、描述、標籤(例如:九份、夜市、Day 2)">
|
||||
|
||||
<main id="taipei" class="content-container map-layout">
|
||||
<!-- 備註:Google My Maps 嵌入。預設殷志源單圖;「全部行程」並排兩張(殷志源在上/左)。
|
||||
右側列表為敘事與導覽;地圖與標籤在 map.js 的 memberMaps 設定。 -->
|
||||
<section class="map-sticky">
|
||||
<h2 class="map-title">水晶男孩台灣行程地圖</h2>
|
||||
<div class="map-embed-single" id="map-embed-single">
|
||||
<iframe id="member-map" class="map-my-maps-frame" title="SECHSKIES 踩點地圖(單一行程)" loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
src="https://www.google.com/maps/d/embed?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI&ehbc=2E312F">
|
||||
</iframe>
|
||||
<main class="content-container">
|
||||
<!-- 備註:每個 .region-panel 是獨立地區區塊,member-tabs 上的 data-map-embed/data-map-open
|
||||
是該行程的 Google My Maps(可留空=該行程沒有地圖,只顯示卡片)。新增行程只要加一顆 tab 按鈕,
|
||||
不需要改 map.js。「全部行程」的地圖會依目前分頁裡「有設定地圖」的 tab 動態組出來。 -->
|
||||
|
||||
<section id="taipei" class="region-panel" data-region-panel="taipei">
|
||||
<div class="member-tabs" aria-label="成員篩選" data-member-tabs>
|
||||
<button class="tab-btn active" data-member="eun" aria-pressed="true"
|
||||
data-trip-name="殷空少搭飛機去台灣高雄" data-member-label="殷志源" data-sort-order="0"
|
||||
data-map-embed="https://www.google.com/maps/d/embed?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI&ehbc=2E312F"
|
||||
data-map-open="https://www.google.com/maps/d/u/0/viewer?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI">殷空少搭飛機去台灣高雄</button>
|
||||
<button class="tab-btn" data-member="ko" aria-pressed="false"
|
||||
data-trip-name="高高父子遊台灣" data-member-label="高志溶" data-sort-order="1"
|
||||
data-map-embed="https://www.google.com/maps/d/embed?mid=1xpAsSoO8gNmMiW4x2sfkndISFTtjMQDw&hl=zh-TW&ehbc=2E312F"
|
||||
data-map-open="https://www.google.com/maps/d/u/0/viewer?mid=1xpAsSoO8gNmMiW4x2sfkndISFTtjMQDw">高高父子遊台灣</button>
|
||||
<button class="tab-btn" data-member="all" aria-pressed="false">全部行程</button>
|
||||
</div>
|
||||
<div class="map-embed-all" id="map-embed-all" hidden>
|
||||
<div class="map-embed-pair">
|
||||
<p class="map-embed-label">殷空少搭飛機去台灣高雄</p>
|
||||
<iframe id="member-map-eun" class="map-my-maps-frame" title="殷志源高雄行程 踩點地圖" loading="lazy"
|
||||
|
||||
<div class="map-layout">
|
||||
<section class="map-sticky" data-map-sticky>
|
||||
<h2 class="map-title">水晶男孩台灣行程地圖</h2>
|
||||
<div class="map-embed-single" id="map-embed-single">
|
||||
<iframe id="member-map" class="map-my-maps-frame" title="SECHSKIES 踩點地圖(單一行程)" loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
src="https://www.google.com/maps/d/embed?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI&ehbc=2E312F">
|
||||
</iframe>
|
||||
</div>
|
||||
<div class="map-embed-pair">
|
||||
<p class="map-embed-label">高高父子遊台灣</p>
|
||||
<iframe id="member-map-ko" class="map-my-maps-frame" title="高高父子遊台灣 踩點地圖" loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
src="https://www.google.com/maps/d/embed?mid=1xpAsSoO8gNmMiW4x2sfkndISFTtjMQDw&hl=zh-TW&ehbc=2E312F">
|
||||
</iframe>
|
||||
<p class="map-embed-empty" data-map-embed-empty hidden>這個行程目前沒有設定地圖,只有下方的地點卡片。</p>
|
||||
<div class="map-embed-all" id="map-embed-all" hidden></div>
|
||||
<div class="map-links-wrap">
|
||||
<a id="open-map-link" class="map-link" target="_blank"
|
||||
href="https://www.google.com/maps/d/u/0/viewer?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI">
|
||||
在 Google My Maps 開啟完整地圖
|
||||
</a>
|
||||
<div id="open-map-links-dual" class="map-links-dual" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-links-wrap">
|
||||
<a id="open-map-link" class="map-link" target="_blank"
|
||||
href="https://www.google.com/maps/d/u/0/viewer?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI">
|
||||
在 Google My Maps 開啟完整地圖
|
||||
</a>
|
||||
<div id="open-map-links-dual" class="map-links-dual" hidden>
|
||||
<a id="open-map-link-eun" class="map-link" target="_blank"
|
||||
href="https://www.google.com/maps/d/u/0/viewer?mid=1VGZ76SPPjm3NWCBQe-hkm08oe5WPRJI">開啟「殷空少搭飛機去台灣高雄」My Maps</a>
|
||||
<a id="open-map-link-ko" class="map-link" target="_blank"
|
||||
href="https://www.google.com/maps/d/u/0/viewer?mid=1xpAsSoO8gNmMiW4x2sfkndISFTtjMQDw">開啟「高高父子遊台灣」My Maps</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="itinerary-list">
|
||||
<!-- 備註:每張卡的 data-* 是「細節面板的資料來源」。
|
||||
你只要改 data-date/title/desc/quote/tag/link 和圖片,就會同步更新上方介紹。 -->
|
||||
<article class="detail-panel" id="detail-panel" aria-live="polite">
|
||||
<div class="detail-photo">
|
||||
<img id="detail-image" src="https://picsum.photos/seed/day1-airport/900/520" alt="行程節目截圖">
|
||||
<img id="detail-image" src="https://images.pexels.com/photos/31504986/pexels-photo-31504986.jpeg" alt="臺灣桃園國際機場停機坪與客機">
|
||||
</div>
|
||||
<div class="detail-content">
|
||||
<span id="detail-date" class="date">Day 1</span>
|
||||
@@ -112,7 +108,7 @@
|
||||
data-quote="高雄,我們來了。" data-tag="#殷志源 #Day1 #出發"
|
||||
data-link="https://maps.google.com/?q=仁川國際機場">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/eun-khh-day1-1/640/420" alt="殷志源高雄行程節目截圖-仁川國際機場">
|
||||
<img src="https://images.pexels.com/photos/32211619/pexels-photo-32211619.jpeg" alt="仁川國際機場明亮現代化航廈">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">殷志源 Day 1</span>
|
||||
@@ -125,7 +121,7 @@
|
||||
data-desc="抵達高雄,正式開始在地行程。"
|
||||
data-quote="落地就是旅行模式開啟。" data-tag="#殷志源 #Day1 #高雄"
|
||||
data-link="https://maps.google.com/?q=高雄國際機場">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day1-2/640/420" alt="殷志源高雄行程節目截圖-高雄國際機場"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/d/d7/Outside_of_KHH_Terminal.jpg" alt="高雄國際機場航廈外觀" title="圖片來源:Wikimedia Commons,© ABOVE THE SKY,CC BY-SA 4.0"><span class="photo-credit">© ABOVE THE SKY</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 1</span><h3>高雄國際機場</h3><p class="description">殷志源高雄行程第二站。</p><div class="tag">#殷志源 #Day1</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 1" data-title="星巴克小港國際站門市"
|
||||
@@ -160,28 +156,28 @@
|
||||
data-desc="高雄經典景點,白天景色與步行路線都很適合取景。"
|
||||
data-quote="每一步都有風景可以拍。" data-tag="#殷志源 #Day2 #景點"
|
||||
data-link="https://maps.google.com/?q=蓮池潭">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day2-2/640/420" alt="殷志源高雄行程節目截圖-蓮池潭"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/b/b3/Lotus_Lake%2C_Kaohsiung%2C_panorama%2C_Dec_06.jpg" alt="蓮池潭與春秋閣全景" title="圖片來源:Wikimedia Commons,© Sengkang,CC BY-SA 3.0"><span class="photo-credit">© Sengkang</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 2</span><h3>蓮池潭</h3><p class="description">殷志源高雄行程 Day 2 第二站。</p><div class="tag">#殷志源 #Day2</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 2" data-title="龍虎塔"
|
||||
data-desc="蓮池潭代表地標,適合搭配導覽與拍照段落。"
|
||||
data-quote="高雄的經典場景之一。" data-tag="#殷志源 #Day2 #地標"
|
||||
data-link="https://maps.google.com/?q=龍虎塔">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day2-3/640/420" alt="殷志源高雄行程節目截圖-龍虎塔"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/b/b6/Dragon_and_Tiger_Pagodas.jpg" alt="蓮池潭龍虎塔與九曲橋" title="圖片來源:Wikimedia Commons,© Selvamsathish,CC BY-SA 4.0"><span class="photo-credit">© Selvamsathish</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 2</span><h3>龍虎塔</h3><p class="description">殷志源高雄行程 Day 2 第三站。</p><div class="tag">#殷志源 #Day2</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 2" data-title="輕軌高雄展覽館站(新光碼頭)"
|
||||
data-desc="港區路線轉乘點,串接後續海線行程。"
|
||||
data-quote="搭上輕軌,城市風景一路展開。" data-tag="#殷志源 #Day2 #輕軌"
|
||||
data-link="https://maps.google.com/?q=輕軌高雄展覽館站(新光碼頭)">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day2-4/640/420" alt="殷志源高雄行程節目截圖-輕軌高雄展覽館站"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/1/1c/Kaohsiung_LRT_C8_Station.jpg" alt="高雄輕軌車站與列車" title="圖片來源:Wikimedia Commons,© Tze Chiang Hao,CC BY-SA 4.0"><span class="photo-credit">© Tze Chiang Hao</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 2</span><h3>輕軌高雄展覽館站(新光碼頭)</h3><p class="description">殷志源高雄行程 Day 2 第四站。</p><div class="tag">#殷志源 #Day2</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 2" data-title="捷運哈瑪星站(輕軌哈瑪星站)"
|
||||
data-desc="山海交界的人氣轉運點,步行動線完整。"
|
||||
data-quote="到了哈瑪星,海港味就很濃。" data-tag="#殷志源 #Day2 #捷運"
|
||||
data-link="https://maps.google.com/?q=捷運哈瑪星站">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day2-5/640/420" alt="殷志源高雄行程節目截圖-捷運哈瑪星站"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/7/7c/Hamasen%2C_Kaohsiung_2011-04-24.jpg" alt="哈瑪星港區歷史街景" title="圖片來源:Wikimedia Commons,© Yi Chen,CC BY 2.0"><span class="photo-credit">© Yi Chen</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 2</span><h3>捷運哈瑪星站(輕軌哈瑪星站)</h3><p class="description">殷志源高雄行程 Day 2 第五站。</p><div class="tag">#殷志源 #Day2</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 2" data-title="仁武烤鴨"
|
||||
@@ -202,14 +198,14 @@
|
||||
data-desc="前往旗津的重要交通節點。"
|
||||
data-quote="坐上渡輪,旅程感直接拉滿。" data-tag="#殷志源 #Day2 #輪渡"
|
||||
data-link="https://maps.google.com/?q=鼓山輪渡站">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day2-8/640/420" alt="殷志源高雄行程節目截圖-鼓山輪渡站"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/e/e0/Gushan_Ferry_Station-20250403.jpg" alt="鼓山輪渡站站體" title="圖片來源:Wikimedia Commons,© Allervous,CC BY-SA 4.0"><span class="photo-credit">© Allervous</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 2</span><h3>鼓山輪渡站</h3><p class="description">殷志源高雄行程 Day 2 第八站。</p><div class="tag">#殷志源 #Day2</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 2" data-title="旗津輪渡站"
|
||||
data-desc="抵達旗津,展開海岸線路段。"
|
||||
data-quote="到了旗津,風都不一樣。" data-tag="#殷志源 #Day2 #旗津"
|
||||
data-link="https://maps.google.com/?q=旗津輪渡站">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day2-9/640/420" alt="殷志源高雄行程節目截圖-旗津輪渡站"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/4/4a/%E6%97%97%E6%B4%A5%E8%BC%AA%E6%B8%A1%E7%AB%99%E5%BB%BA%E7%AF%89%E5%A4%96%E8%A7%80.jpg" alt="旗津輪渡站建築外觀" title="圖片來源:Wikimedia Commons,© Yu-Cian Wu,CC BY-SA 4.0"><span class="photo-credit">© Yu-Cian Wu</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 2</span><h3>旗津輪渡站</h3><p class="description">殷志源高雄行程 Day 2 第九站。</p><div class="tag">#殷志源 #Day2</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 2" data-title="樂騎腳踏車、電動車、遊園車(旗津)"
|
||||
@@ -223,14 +219,14 @@
|
||||
data-desc="旗津拍照地標之一。"
|
||||
data-quote="彩虹教堂是旗津必拍清單。" data-tag="#殷志源 #Day2 #旗津"
|
||||
data-link="https://maps.google.com/?q=旗津彩虹教堂">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day2-11/640/420" alt="殷志源高雄行程節目截圖-旗津彩虹教堂"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/3/37/%E3%80%90%E9%AB%98%E9%9B%84%E6%99%AF%E9%BB%9E%E3%80%91%E6%97%97%E6%B4%A5%E5%BD%A9%E8%99%B9%E6%95%99%E5%A0%82_%2831505226685%29.jpg" alt="旗津彩虹教堂繽紛拱門" title="圖片來源:Wikimedia Commons,© fullfen666,CC BY-SA 2.0"><span class="photo-credit">© fullfen666</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 2</span><h3>旗津彩虹教堂</h3><p class="description">殷志源高雄行程 Day 2 第十一站。</p><div class="tag">#殷志源 #Day2</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 2" data-title="旗津貝殼館"
|
||||
data-desc="旗津主題展點,補足海線內容。"
|
||||
data-quote="一站一站把海線故事拼起來。" data-tag="#殷志源 #Day2 #旗津"
|
||||
data-link="https://maps.google.com/?q=旗津貝殼館">
|
||||
<div class="photo-frame"><img src="https://picsum.photos/seed/eun-khh-day2-12/640/420" alt="殷志源高雄行程節目截圖-旗津貝殼館"></div>
|
||||
<div class="photo-frame"><img src="https://upload.wikimedia.org/wikipedia/commons/f/fa/Cijin_Shell_Museum.JPG" alt="旗津貝殼館外觀" title="圖片來源:Wikimedia Commons,© SSR2000,CC BY-SA 3.0"><span class="photo-credit">© SSR2000</span></div>
|
||||
<div class="card-content"><span class="date">殷志源 Day 2</span><h3>旗津貝殼館</h3><p class="description">殷志源高雄行程 Day 2 第十二站。</p><div class="tag">#殷志源 #Day2</div></div>
|
||||
</article>
|
||||
<article class="polaroid-card" data-member="eun" data-date="殷志源 Day 2" data-title="海忠寶活海產店"
|
||||
@@ -246,7 +242,7 @@
|
||||
data-quote="旅程從落地那一刻就開始了。" data-tag="#高高父子遊台灣 #Day1 #機場"
|
||||
data-link="https://maps.google.com/?q=臺灣桃園國際機場">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day1-airport/640/420" alt="高高父子遊台灣節目截圖-臺灣桃園國際機場">
|
||||
<img src="https://images.pexels.com/photos/31504986/pexels-photo-31504986.jpeg" alt="臺灣桃園國際機場停機坪與客機">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">高志溶 Day 1</span>
|
||||
@@ -261,7 +257,7 @@
|
||||
data-quote="從這裡開始感受台北節奏。" data-tag="#高高父子遊台灣 #Day1 #西門"
|
||||
data-link="https://maps.google.com/?q=捷運西門站">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day1-ximen-mrt/640/420" alt="高高父子遊台灣節目截圖-捷運西門站">
|
||||
<img src="https://images.pexels.com/photos/32222711/pexels-photo-32222711.jpeg" alt="西門町街景">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">Day 1</span>
|
||||
@@ -306,7 +302,7 @@
|
||||
data-quote="台灣夜市是不能錯過的必修行程。" data-tag="#高高父子遊台灣 #Day1 #夜市"
|
||||
data-link="https://maps.google.com/?q=士林夜市">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day1-nightmarket/640/420" alt="高高父子遊台灣節目截圖-士林夜市">
|
||||
<img src="https://images.pexels.com/photos/35558289/pexels-photo-35558289.jpeg" alt="士林夜市夜間街景">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">Day 1</span>
|
||||
@@ -351,7 +347,8 @@
|
||||
data-quote="第二天從白天的城市日常開始。" data-tag="#高高父子遊台灣 #Day2 #校園"
|
||||
data-link="https://maps.google.com/?q=臺灣師範大學">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day2-ntnu/640/420" alt="高高父子遊台灣節目截圖-臺灣師範大學">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/b/b0/202407_Main_gate_of_NTNU_Main_Campus.jpg" alt="臺灣師範大學校門" title="圖片來源:Wikimedia Commons,© Jonashtand,CC BY-SA 4.0">
|
||||
<span class="photo-credit">© Jonashtand</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">Day 2</span>
|
||||
@@ -381,7 +378,7 @@
|
||||
data-quote="九份是會讓人想慢慢走慢慢看的地方。" data-tag="#高高父子遊台灣 #Day2 #九份"
|
||||
data-link="https://maps.google.com/?q=九份老街">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day2-jiufen/640/420" alt="高高父子遊台灣節目截圖-九份老街">
|
||||
<img src="https://images.pexels.com/photos/37975552/pexels-photo-37975552.jpeg" alt="九份老街紅燈籠夜景">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">Day 2</span>
|
||||
@@ -426,7 +423,8 @@
|
||||
data-quote="第三天就用溫泉感把節奏放慢。" data-tag="#高高父子遊台灣 #Day3 #北投"
|
||||
data-link="https://maps.google.com/?q=北投公園露天溫泉">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day3-hotspring/640/420" alt="高高父子遊台灣節目截圖-北投公園露天溫泉">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/d/da/Hot_river_%283823489104%29.jpg" alt="北投溫泉公園地熱溪流" title="圖片來源:Wikimedia Commons,© Caitriana Nicholson,CC BY-SA 2.0">
|
||||
<span class="photo-credit">© Caitriana Nicholson</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">Day 3</span>
|
||||
@@ -441,7 +439,8 @@
|
||||
data-quote="在橋上看風景,旅程也會變慢。" data-tag="#高高父子遊台灣 #Day3"
|
||||
data-link="https://maps.google.com/?q=靜安吊橋">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day3-bridge/640/420" alt="高高父子遊台灣節目截圖-靜安吊橋">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/a/a8/Jing%27an_Suspension_Bridge_New_Taipei_%E9%9D%9C%E5%AE%89%E6%A9%8B_%282025%29_-_img_08.jpg" alt="靜安吊橋" title="圖片來源:Wikimedia Commons,© Chainwit.,CC BY 4.0">
|
||||
<span class="photo-credit">© Chainwit.</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">Day 3</span>
|
||||
@@ -456,7 +455,7 @@
|
||||
data-quote="十分的氛圍,跟平溪線一樣有故事感。" data-tag="#高高父子遊台灣 #Day3 #十分"
|
||||
data-link="https://maps.google.com/?q=十分老街">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day3-shifen/640/420" alt="高高父子遊台灣節目截圖-十分老街">
|
||||
<img src="https://images.pexels.com/photos/30131488/pexels-photo-30131488.jpeg" alt="十分老街火車穿越街道">
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">Day 3</span>
|
||||
@@ -501,7 +500,8 @@
|
||||
data-quote="把願望寫上天燈,這趟旅程就有了句點。" data-tag="#高高父子遊台灣 #Day3 #天燈"
|
||||
data-link="https://maps.google.com/?q=胡家天燈">
|
||||
<div class="photo-frame">
|
||||
<img src="https://picsum.photos/seed/day3-sky-lantern/640/420" alt="高高父子遊台灣節目截圖-胡家天燈">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/5/50/20250707_lantern2.jpg" alt="十分天燈夜間施放" title="圖片來源:Wikimedia Commons,© Wombatjpw,CC0">
|
||||
<span class="photo-credit">© Wombatjpw</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<span class="date">Day 3</span>
|
||||
@@ -510,10 +510,39 @@
|
||||
<div class="tag">#高高父子遊台灣 #Day3</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="global" class="region-panel" data-region-panel="global" hidden>
|
||||
<div class="member-tabs" aria-label="成員篩選" data-member-tabs>
|
||||
<button class="tab-btn active" data-member="all" aria-pressed="true">全部行程</button>
|
||||
</div>
|
||||
|
||||
<div class="map-layout">
|
||||
<section class="map-sticky" data-map-sticky hidden>
|
||||
<h2 class="map-title">海外行程地圖</h2>
|
||||
<div class="map-embed-single" id="map-embed-single-global">
|
||||
<iframe id="member-map-global" class="map-my-maps-frame" title="SECHSKIES 踩點地圖(單一行程)" loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade" src="about:blank"></iframe>
|
||||
</div>
|
||||
<p class="map-embed-empty" data-map-embed-empty hidden>這個行程目前沒有設定地圖,只有下方的地點卡片。</p>
|
||||
<div class="map-embed-all" id="map-embed-all-global" hidden></div>
|
||||
<div class="map-links-wrap">
|
||||
<a class="map-link" target="_blank" href="#" hidden>在 Google My Maps 開啟完整地圖</a>
|
||||
<div class="map-links-dual" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="itinerary-list">
|
||||
<p class="region-empty-state" data-region-empty>
|
||||
海外行程整理中,之後會陸續補上節目裡出現過的國家與地點。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<div id="global"></div>
|
||||
<p>SECHSKIES Fanpage © 2026 - 黃色氣球永遠飄揚</p>
|
||||
<a href="https://jekkinoopy.github.io/Portfolio/" class="studio-link" target="_blank">
|
||||
Design by <span class="mark_b">Jekkinoopy Studio</span>
|
||||
|
||||
Reference in New Issue
Block a user