feat: 串接練舞清單
This commit is contained in:
@@ -75,7 +75,7 @@
|
|||||||
**最後更新:** 2026-08-02
|
**最後更新:** 2026-08-02
|
||||||
|
|
||||||
**進行中:**
|
**進行中:**
|
||||||
- 水晶熱舞社重啟:30TH 應援報名已接上資料庫與後臺,下一階段可再做活動/練習影片內容管理
|
- 水晶熱舞社重啟:30TH 報名與水晶練習室練舞清單已接上後臺;播放器本身仍待加入合法影片來源
|
||||||
|
|
||||||
**待確認:**
|
**待確認:**
|
||||||
- developer-rules 健檢尚未執行
|
- developer-rules 健檢尚未執行
|
||||||
@@ -90,6 +90,8 @@
|
|||||||
- 水晶熱舞社 30TH 募集表單已接上 PHP/PDO/MySQL,可儲存報名、參與內容與參考檔案
|
- 水晶熱舞社 30TH 募集表單已接上 PHP/PDO/MySQL,可儲存報名、參與內容與參考檔案
|
||||||
- 後臺新增「30TH 應援報名」清單、搜尋、狀態處理、內部備註與受登入保護的檔案下載
|
- 後臺新增「30TH 應援報名」清單、搜尋、狀態處理、內部備註與受登入保護的檔案下載
|
||||||
- 架構決定:水晶熱舞社暫時共用推廣部後臺;偶像培訓維持獨立資料邊界,之後另拆專案與登入系統
|
- 架構決定:水晶熱舞社暫時共用推廣部後臺;偶像培訓維持獨立資料邊界,之後另拆專案與登入系統
|
||||||
|
- 水晶練習室的「練舞清單」已改為讀取 `dance_practice_items`,後臺可管理歌曲、分類、練習重點、影片網址、難度、進度、優先順序與公開狀態
|
||||||
|
- 練舞清單初始資料沿用原頁面的 Couple、Com' Back、Road Fighter;偶像培訓的練習生/課程/紀錄未接入此資料表
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,22 @@ function modules(): array
|
|||||||
],
|
],
|
||||||
'list' => ['display_name', 'media_type', 'category', 'file_path', 'status', 'updated_at'],
|
'list' => ['display_name', 'media_type', 'category', 'file_path', 'status', 'updated_at'],
|
||||||
],
|
],
|
||||||
|
'dance_practices' => [
|
||||||
|
'label' => '練舞清單', 'item_label' => '練舞項目', 'list_title' => '練舞清單', 'icon' => 'list-check', 'table' => 'dance_practice_items', 'order' => 'sort_order, id', 'group' => 'dance',
|
||||||
|
'fields' => [
|
||||||
|
'title' => ['label' => '歌曲名稱', 'type' => 'text', 'required' => true],
|
||||||
|
'tags' => ['label' => '練習分類', 'type' => 'text'],
|
||||||
|
'practice_focus' => ['label' => '練習重點', 'type' => 'textarea', 'required' => true],
|
||||||
|
'video_url' => ['label' => '參考影片網址', 'type' => 'url'],
|
||||||
|
'difficulty' => ['label' => '難度', 'type' => 'select', 'default' => 'medium', 'required' => true, 'options' => ['easy' => '入門', 'medium' => '中等', 'hard' => '挑戰']],
|
||||||
|
'progress_status' => ['label' => '練習進度', 'type' => 'select', 'default' => 'not_started', 'required' => true, 'options' => ['not_started' => '未開始', 'practicing' => '練習中', 'review' => '待複習', 'completed' => '已完成']],
|
||||||
|
'note_text' => ['label' => '補充說明', 'type' => 'text'],
|
||||||
|
'is_featured' => ['label' => '優先練習', 'type' => 'checkbox'],
|
||||||
|
'sort_order' => ['label' => '排序', 'type' => 'number', 'default' => '0'],
|
||||||
|
'status' => ['label' => '公開狀態', 'type' => 'status', 'required' => true],
|
||||||
|
],
|
||||||
|
'list' => ['title', 'difficulty', 'progress_status', 'is_featured', 'sort_order', 'status', 'updated_at'],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,9 +279,14 @@ function render_header(string $title, string $active = ''): void
|
|||||||
<a class="<?= $active === 'dashboard' ? 'active' : '' ?>" href="index.php"><i class="bi bi-speedometer2"></i>控制台</a>
|
<a class="<?= $active === 'dashboard' ? 'active' : '' ?>" href="index.php"><i class="bi bi-speedometer2"></i>控制台</a>
|
||||||
<p>內容資料</p>
|
<p>內容資料</p>
|
||||||
<?php foreach ($mods as $key => $mod): ?>
|
<?php foreach ($mods as $key => $mod): ?>
|
||||||
|
<?php if (($mod['group'] ?? 'content') === 'dance') continue; ?>
|
||||||
<a class="<?= $active === $key ? 'active' : '' ?>" href="records.php?module=<?= h($key) ?>"><i class="bi bi-<?= h($mod['icon']) ?>"></i><?= h($mod['label']) ?></a>
|
<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; ?>
|
<?php endforeach; ?>
|
||||||
<p>水晶熱舞社</p>
|
<p>水晶熱舞社</p>
|
||||||
|
<?php foreach ($mods as $key => $mod): ?>
|
||||||
|
<?php if (($mod['group'] ?? 'content') !== 'dance') continue; ?>
|
||||||
|
<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; ?>
|
||||||
<a class="<?= $active === 'dance-applications' ? 'active' : '' ?>" href="dance-applications.php"><i class="bi bi-person-hearts"></i>30TH 應援報名</a>
|
<a class="<?= $active === 'dance-applications' ? 'active' : '' ?>" href="dance-applications.php"><i class="bi bi-person-hearts"></i>30TH 應援報名</a>
|
||||||
<p>系統</p>
|
<p>系統</p>
|
||||||
<a class="<?= $active === 'publishing' ? 'active' : '' ?>" href="publishing.php"><i class="bi bi-eye"></i>公開狀態</a>
|
<a class="<?= $active === 'publishing' ? 'active' : '' ?>" href="publishing.php"><i class="bi bi-eye"></i>公開狀態</a>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
CREATE DATABASE IF NOT EXISTS sechskies_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS sechskies_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
USE sechskies_cms;
|
USE sechskies_cms;
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_users (
|
CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -138,6 +139,39 @@ CREATE TABLE IF NOT EXISTS dance_applications (
|
|||||||
INDEX idx_dance_applications_email (email)
|
INDEX idx_dance_applications_email (email)
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dance_practice_items (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
tags VARCHAR(255) NULL,
|
||||||
|
practice_focus TEXT NOT NULL,
|
||||||
|
video_url VARCHAR(500) NULL,
|
||||||
|
difficulty ENUM('easy','medium','hard') NOT NULL DEFAULT 'medium',
|
||||||
|
progress_status ENUM('not_started','practicing','review','completed') NOT NULL DEFAULT 'not_started',
|
||||||
|
note_text VARCHAR(255) NULL,
|
||||||
|
is_featured TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
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,
|
||||||
|
INDEX idx_dance_practice_order (status, sort_order, id),
|
||||||
|
INDEX idx_dance_practice_title (title)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO dance_practice_items
|
||||||
|
(title, tags, practice_focus, difficulty, progress_status, note_text, is_featured, sort_order, status)
|
||||||
|
SELECT 'Couple', 'Dance,Vocal', '副歌動作、隊形感、甜感表情', 'easy', 'not_started', '可作為第一首示範歌', 1, 10, 'published'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM dance_practice_items WHERE title = 'Couple');
|
||||||
|
|
||||||
|
INSERT INTO dance_practice_items
|
||||||
|
(title, tags, practice_focus, difficulty, progress_status, note_text, is_featured, sort_order, status)
|
||||||
|
SELECT 'Com'' Back', 'Dance,Rap', '強拍、Rap 段落、舞台氣勢', 'medium', 'not_started', '未來擴充', 0, 20, 'coming_soon'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM dance_practice_items WHERE title = 'Com'' Back');
|
||||||
|
|
||||||
|
INSERT INTO dance_practice_items
|
||||||
|
(title, tags, practice_focus, difficulty, progress_status, note_text, is_featured, sort_order, status)
|
||||||
|
SELECT 'Road Fighter', 'Dance,Rap,Performance', '節奏、力量、舞台感', 'hard', 'not_started', '未來擴充', 0, 30, 'coming_soon'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM dance_practice_items WHERE title = 'Road Fighter');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
admin_user_id INT UNSIGNED NULL,
|
admin_user_id INT UNSIGNED NULL,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
$record = array_merge($record, $data);
|
$record = array_merge($record, $data);
|
||||||
}
|
}
|
||||||
render_header(($id ? '編輯' : '新增') . $module['label'], $key);
|
render_header(($id ? '編輯' : '新增') . ($module['item_label'] ?? $module['label']), $key);
|
||||||
?>
|
?>
|
||||||
<?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
|
<?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">
|
<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">
|
||||||
|
|||||||
+2
-2
@@ -47,8 +47,8 @@ foreach ($module['fields'] as $name => $field) {
|
|||||||
render_header($module['label'], $key);
|
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>
|
<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>
|
<section class="panel"><div class="panel__head"><div><h2><?= h($module['list_title'] ?? ($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 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 elseif (($module['fields'][$field]['type'] ?? '') === 'select'): ?><?= h($module['fields'][$field]['options'][$value] ?? (string) $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 (!$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 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();
|
<?php render_footer();
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../admin/config.php';
|
||||||
|
$db = $config['db'];
|
||||||
|
$dsn = "mysql:host={$db['host']};port={$db['port']};dbname={$db['name']};charset=utf8mb4";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT id, title, tags, practice_focus, video_url, difficulty, progress_status,
|
||||||
|
note_text, is_featured, sort_order, status
|
||||||
|
FROM dance_practice_items
|
||||||
|
WHERE status IN ('published', 'coming_soon')
|
||||||
|
ORDER BY sort_order, id"
|
||||||
|
)->fetchAll();
|
||||||
|
} catch (PDOException) {
|
||||||
|
http_response_code(503);
|
||||||
|
echo json_encode(['ok' => false, 'items' => []], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$difficultyLabels = ['easy' => '入門', 'medium' => '中等', 'hard' => '挑戰'];
|
||||||
|
$progressLabels = ['not_started' => '未開始', 'practicing' => '練習中', 'review' => '待複習', 'completed' => '已完成'];
|
||||||
|
|
||||||
|
$items = array_map(static function (array $row) use ($difficultyLabels, $progressLabels): array {
|
||||||
|
$videoUrl = trim((string) ($row['video_url'] ?? ''));
|
||||||
|
if ($videoUrl !== '' && (!filter_var($videoUrl, FILTER_VALIDATE_URL) || !preg_match('/^https?:\/\//i', $videoUrl))) {
|
||||||
|
$videoUrl = '';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'title' => (string) $row['title'],
|
||||||
|
'tags' => array_values(array_filter(array_map('trim', explode(',', (string) ($row['tags'] ?? ''))))),
|
||||||
|
'practiceFocus' => (string) $row['practice_focus'],
|
||||||
|
'videoUrl' => $videoUrl,
|
||||||
|
'difficulty' => $difficultyLabels[$row['difficulty']] ?? (string) $row['difficulty'],
|
||||||
|
'progress' => $progressLabels[$row['progress_status']] ?? (string) $row['progress_status'],
|
||||||
|
'note' => (string) ($row['note_text'] ?? ''),
|
||||||
|
'featured' => (bool) $row['is_featured'],
|
||||||
|
'comingSoon' => $row['status'] === 'coming_soon',
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true, 'items' => $items], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
+121
-55
@@ -499,10 +499,41 @@
|
|||||||
color: var(--practice-text-dark);
|
color: var(--practice-text-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.practice-room-page .practice-room__song-status--soon {
|
body.practice-room-page .practice-room__song-status--soon {
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.5);
|
||||||
color: var(--practice-text-muted);
|
color: var(--practice-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.practice-room-page .practice-room__song-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-height: 42px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border: 1px solid var(--practice-accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--practice-text-dark);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.practice-room-page .practice-room__song-link:hover {
|
||||||
|
background: var(--practice-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.practice-room-page .practice-room__song-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px dashed var(--practice-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--practice-text-muted);
|
||||||
|
background: var(--practice-surface);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 段落練習 ── */
|
/* ── 段落練習 ── */
|
||||||
body.practice-room-page .practice-room__section-panel {
|
body.practice-room-page .practice-room__section-panel {
|
||||||
@@ -639,18 +670,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) and (max-width: 991.98px) {
|
@media (min-width: 768px) and (max-width: 991.98px) {
|
||||||
body.practice-room-page .practice-room__song-grid {
|
body.practice-room-page .practice-room__song-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
body.practice-room-page .practice-room__song-card:last-child {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
max-width: 50%;
|
|
||||||
margin-inline: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -791,41 +815,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ④ 歌曲範例 -->
|
<!-- ④ 練舞清單 -->
|
||||||
<section class="practice-room__block" aria-labelledby="songs-title">
|
<section class="practice-room__block" aria-labelledby="songs-title">
|
||||||
<h2 class="practice-room__block-title" id="songs-title">歌曲練習範例</h2>
|
<h2 class="practice-room__block-title" id="songs-title">練舞清單</h2>
|
||||||
<span class="practice-room__block-sub">SAMPLE TRACKS</span>
|
<span class="practice-room__block-sub">DANCE PRACTICE LIST</span>
|
||||||
<div class="practice-room__song-grid">
|
<div class="practice-room__song-grid" data-dance-practice-list aria-live="polite">
|
||||||
<article class="practice-room__song-card practice-room__song-card--featured">
|
<p class="practice-room__song-empty">正在載入練舞清單…</p>
|
||||||
<h3 class="practice-room__song-title">Couple</h3>
|
</div>
|
||||||
<div class="practice-room__song-meta">
|
</section>
|
||||||
<span class="practice-room__song-tag">Dance</span>
|
|
||||||
<span class="practice-room__song-tag">Vocal</span>
|
|
||||||
</div>
|
|
||||||
<p class="practice-room__song-focus"><strong>練習重點:</strong>副歌動作、隊形感、甜感表情</p>
|
|
||||||
<span class="practice-room__song-status">可作為第一首示範歌</span>
|
|
||||||
</article>
|
|
||||||
<article class="practice-room__song-card">
|
|
||||||
<h3 class="practice-room__song-title">Com' Back</h3>
|
|
||||||
<div class="practice-room__song-meta">
|
|
||||||
<span class="practice-room__song-tag">Dance</span>
|
|
||||||
<span class="practice-room__song-tag">Rap</span>
|
|
||||||
</div>
|
|
||||||
<p class="practice-room__song-focus"><strong>練習重點:</strong>強拍、Rap 段落、舞台氣勢</p>
|
|
||||||
<span class="practice-room__song-status practice-room__song-status--soon">未來擴充</span>
|
|
||||||
</article>
|
|
||||||
<article class="practice-room__song-card">
|
|
||||||
<h3 class="practice-room__song-title">Road Fighter</h3>
|
|
||||||
<div class="practice-room__song-meta">
|
|
||||||
<span class="practice-room__song-tag">Dance</span>
|
|
||||||
<span class="practice-room__song-tag">Rap</span>
|
|
||||||
<span class="practice-room__song-tag">Performance</span>
|
|
||||||
</div>
|
|
||||||
<p class="practice-room__song-focus"><strong>練習重點:</strong>節奏、力量、舞台感</p>
|
|
||||||
<span class="practice-room__song-status practice-room__song-status--soon">未來擴充</span>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ⑤ 歌詞段落示意 -->
|
<!-- ⑤ 歌詞段落示意 -->
|
||||||
<section class="practice-room__block" aria-labelledby="sections-title">
|
<section class="practice-room__block" aria-labelledby="sections-title">
|
||||||
@@ -1131,10 +1128,79 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
updatePlaceholder();
|
updatePlaceholder();
|
||||||
updateProgress();
|
updateProgress();
|
||||||
})();
|
})();
|
||||||
</script>
|
|
||||||
|
(function () {
|
||||||
|
var list = document.querySelector('[data-dance-practice-list]');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
function element(tagName, className, text) {
|
||||||
|
var node = document.createElement(tagName);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (typeof text === 'string') node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty(message) {
|
||||||
|
list.replaceChildren(element('p', 'practice-room__song-empty', message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems(items) {
|
||||||
|
if (!items.length) {
|
||||||
|
renderEmpty('練舞清單目前整理中。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragment = document.createDocumentFragment();
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var cardClass = 'practice-room__song-card' + (item.featured ? ' practice-room__song-card--featured' : '');
|
||||||
|
var card = element('article', cardClass);
|
||||||
|
card.appendChild(element('h3', 'practice-room__song-title', item.title));
|
||||||
|
|
||||||
|
var meta = element('div', 'practice-room__song-meta');
|
||||||
|
(item.tags || []).forEach(function (tag) {
|
||||||
|
meta.appendChild(element('span', 'practice-room__song-tag', tag));
|
||||||
|
});
|
||||||
|
meta.appendChild(element('span', 'practice-room__song-tag', '難度:' + item.difficulty));
|
||||||
|
meta.appendChild(element('span', 'practice-room__song-tag', item.progress));
|
||||||
|
card.appendChild(meta);
|
||||||
|
|
||||||
|
var focus = element('p', 'practice-room__song-focus');
|
||||||
|
focus.appendChild(element('strong', '', '練習重點:'));
|
||||||
|
focus.appendChild(document.createTextNode(item.practiceFocus));
|
||||||
|
card.appendChild(focus);
|
||||||
|
|
||||||
|
if (item.videoUrl) {
|
||||||
|
var link = element('a', 'practice-room__song-link', '打開參考影片');
|
||||||
|
link.href = item.videoUrl;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
card.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusClass = 'practice-room__song-status' + (item.comingSoon ? ' practice-room__song-status--soon' : '');
|
||||||
|
card.appendChild(element('span', statusClass, item.note || item.progress));
|
||||||
|
fragment.appendChild(card);
|
||||||
|
});
|
||||||
|
list.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('dance-practices-data.php', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) throw new Error('request failed');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data.ok || !Array.isArray(data.items)) throw new Error('invalid data');
|
||||||
|
renderItems(data.items);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
renderEmpty('練舞清單暫時無法載入,請稍後再試。');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user