feat: 熱舞社報名後台
This commit is contained in:
@@ -72,10 +72,10 @@
|
|||||||
|
|
||||||
<!-- 每次對話結束後更新這個區塊 -->
|
<!-- 每次對話結束後更新這個區塊 -->
|
||||||
|
|
||||||
**最後更新:** 2026-07-28
|
**最後更新:** 2026-08-02
|
||||||
|
|
||||||
**進行中:**
|
**進行中:**
|
||||||
- 推廣部內容管理系統第一階段:已建立 PHP/PDO/MySQL 後臺骨架,待在 XAMPP 匯入 schema 後進行瀏覽器整合測試
|
- 水晶熱舞社重啟:30TH 應援報名已接上資料庫與後臺,下一階段可再做活動/練習影片內容管理
|
||||||
|
|
||||||
**待確認:**
|
**待確認:**
|
||||||
- developer-rules 健檢尚未執行
|
- developer-rules 健檢尚未執行
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
- portal-nav 新增世紀級寶藏,正確放入淪陷瞬間子選單
|
- portal-nav 新增世紀級寶藏,正確放入淪陷瞬間子選單
|
||||||
- `extra/site-guide.html` 同步更新:全員發瘋 → 世紀級寶藏
|
- `extra/site-guide.html` 同步更新:全員發瘋 → 世紀級寶藏
|
||||||
- 新增 `admin/` 推廣部後臺第一階段:登入、控制台、管理者帳號、通用 CRUD、媒體上傳及公開狀態總覽
|
- 新增 `admin/` 推廣部後臺第一階段:登入、控制台、管理者帳號、通用 CRUD、媒體上傳及公開狀態總覽
|
||||||
|
- 水晶熱舞社 30TH 募集表單已接上 PHP/PDO/MySQL,可儲存報名、參與內容與參考檔案
|
||||||
|
- 後臺新增「30TH 應援報名」清單、搜尋、狀態處理、內部備註與受登入保護的檔案下載
|
||||||
|
- 架構決定:水晶熱舞社暫時共用推廣部後臺;偶像培訓維持獨立資料邊界,之後另拆專案與登入系統
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -88,6 +88,10 @@ tbody tr:hover { background: rgba(255, 204, 0, .06); }
|
|||||||
.status--coming_soon { 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--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); }
|
.status--archived { color: var(--color-text-muted); background: rgba(77, 77, 77, .12); }
|
||||||
|
.status--new { color: var(--color-text-dark); background: var(--color-brand); }
|
||||||
|
.status--contacted { color: var(--color-brand-dark); background: var(--color-brand-light); }
|
||||||
|
.status--confirmed { color: var(--color-brand-dark); background: var(--color-brand-light); border: 1px solid var(--color-brand); }
|
||||||
|
.status--declined { color: var(--color-text-muted); background: rgba(77, 77, 77, .12); }
|
||||||
.pagination { display: flex; justify-content: center; gap: 7px; padding: 18px; }
|
.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 { 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; }
|
.pagination a.active { color: var(--color-text-dark); background: var(--color-brand); border-color: var(--color-brand); font-weight: 800; }
|
||||||
@@ -108,6 +112,15 @@ tbody tr:hover { background: rgba(255, 204, 0, .06); }
|
|||||||
.inline-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; align-items: end; gap: 14px; padding: 22px; }
|
.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; }
|
.inline-form label { display: flex; flex-direction: column; gap: 6px; color: var(--color-text-muted); font-size: .82rem; font-weight: 700; }
|
||||||
|
|
||||||
|
.application-detail .panel__head { align-items: center; gap: 16px; }
|
||||||
|
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: rgba(78, 73, 43, .14); }
|
||||||
|
.detail-item { min-width: 0; padding: 18px 22px; background: var(--color-surface); }
|
||||||
|
.detail-item--wide { grid-column: 1 / -1; }
|
||||||
|
.detail-item > span { display: block; margin-bottom: 7px; color: var(--color-text-muted); font-size: .78rem; font-weight: 800; }
|
||||||
|
.detail-item strong, .detail-item a, .detail-value { color: var(--color-text-dark); overflow-wrap: anywhere; }
|
||||||
|
.detail-value { line-height: 1.75; }
|
||||||
|
.detail-download { width: fit-content; max-width: 100%; white-space: normal; text-align: left; }
|
||||||
|
|
||||||
.auth-page { display: grid; place-items: center; padding: 24px; color: var(--color-text-inverse); background: var(--color-bg); background-image: radial-gradient(circle at 20% 10%, rgba(255, 204, 0, .16), transparent 32%), radial-gradient(circle at 80% 90%, rgba(252, 238, 167, .1), transparent 34%); }
|
.auth-page { display: grid; place-items: center; padding: 24px; color: var(--color-text-inverse); background: var(--color-bg); background-image: radial-gradient(circle at 20% 10%, rgba(255, 204, 0, .16), transparent 32%), radial-gradient(circle at 80% 90%, rgba(252, 238, 167, .1), transparent 34%); }
|
||||||
.auth-card { position: relative; width: min(500px, 100%); padding: clamp(28px, 6vw, 48px); overflow: hidden; border: 1px solid rgba(255, 204, 0, .35); border-radius: 20px; background: var(--color-bg-raised); box-shadow: 0 26px 80px rgba(0, 0, 0, .45); }
|
.auth-card { position: relative; width: min(500px, 100%); padding: clamp(28px, 6vw, 48px); overflow: hidden; border: 1px solid rgba(255, 204, 0, .35); border-radius: 20px; background: var(--color-bg-raised); box-shadow: 0 26px 80px rgba(0, 0, 0, .45); }
|
||||||
.auth-card::before { content: ""; position: absolute; inset: 0 0 auto; height: 4px; background: linear-gradient(90deg, transparent, var(--color-brand), transparent); }
|
.auth-card::before { content: ""; position: absolute; inset: 0 0 auto; height: 4px; background: linear-gradient(90deg, transparent, var(--color-brand), transparent); }
|
||||||
@@ -140,6 +153,6 @@ tbody tr:hover { background: rgba(255, 204, 0, .06); }
|
|||||||
.toolbar { align-items: stretch; flex-direction: column; }
|
.toolbar { align-items: stretch; flex-direction: column; }
|
||||||
.filters { display: grid; grid-template-columns: 1fr; }
|
.filters { display: grid; grid-template-columns: 1fr; }
|
||||||
.filters input, .filters select { width: 100%; }
|
.filters input, .filters select { width: 100%; }
|
||||||
.stat-grid, .module-grid, .form-grid, .inline-form { grid-template-columns: 1fr; }
|
.stat-grid, .module-grid, .form-grid, .inline-form, .detail-grid { grid-template-columns: 1fr; }
|
||||||
.field--wide { grid-column: auto; }
|
.field--wide, .detail-item--wide { grid-column: auto; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,51 @@ function status_label(string $status): string
|
|||||||
return ['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存', 'available' => '可使用'][$status] ?? $status;
|
return ['draft' => '草稿', 'coming_soon' => '籌備中', 'published' => '公開', 'archived' => '封存', 'available' => '可使用'][$status] ?? $status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dance_application_statuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'new' => '新報名',
|
||||||
|
'contacted' => '已聯絡',
|
||||||
|
'confirmed' => '確認參與',
|
||||||
|
'declined' => '暫不參與',
|
||||||
|
'archived' => '已封存',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dance_attendance_label(?string $value): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'attended' => '參加過 20TH',
|
||||||
|
'watched_video' => '看過 20TH 影片',
|
||||||
|
'first_time' => '第一次知道',
|
||||||
|
][$value ?? ''] ?? '未填寫';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dance_song_label(?string $value): string
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'couple' => 'Couple',
|
||||||
|
'comeback' => "Com' Back",
|
||||||
|
'road_fighter' => 'Road Fighter',
|
||||||
|
'pom_saeng_pom_sa' => 'Pom Saeng Pom Sa',
|
||||||
|
'other' => '其他',
|
||||||
|
][$value ?? ''] ?? '未選擇';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dance_content_labels(?string $value): array
|
||||||
|
{
|
||||||
|
$labels = [
|
||||||
|
'cover' => '翻跳',
|
||||||
|
'medley' => '串燒聯舞',
|
||||||
|
'blessing' => '祝福影片',
|
||||||
|
'graphic' => '圖文應援',
|
||||||
|
'editing' => '影片剪輯',
|
||||||
|
'archive' => '資料整理',
|
||||||
|
];
|
||||||
|
$selected = array_filter(array_map('trim', explode(',', (string) $value)));
|
||||||
|
return array_map(static fn (string $item): string => $labels[$item] ?? $item, $selected);
|
||||||
|
}
|
||||||
|
|
||||||
function audit(string $action, string $entityType, ?int $entityId, array $details = []): void
|
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 = db()->prepare('INSERT INTO audit_logs (admin_user_id, action_name, entity_type, entity_id, details_json, ip_address) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
@@ -220,6 +265,8 @@ function render_header(string $title, string $active = ''): void
|
|||||||
<?php foreach ($mods as $key => $mod): ?>
|
<?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>
|
<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>
|
||||||
|
<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>
|
||||||
<a class="<?= $active === 'admins' ? 'active' : '' ?>" href="admins.php"><i class="bi bi-shield-lock"></i>管理者帳號</a>
|
<a class="<?= $active === 'admins' ? 'active' : '' ?>" href="admins.php"><i class="bi bi-shield-lock"></i>管理者帳號</a>
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ return [
|
|||||||
'upload_dir' => dirname(__DIR__) . '/assets/uploads',
|
'upload_dir' => dirname(__DIR__) . '/assets/uploads',
|
||||||
'upload_url' => '../assets/uploads',
|
'upload_url' => '../assets/uploads',
|
||||||
'max_upload_bytes' => 8 * 1024 * 1024,
|
'max_upload_bytes' => 8 * 1024 * 1024,
|
||||||
|
'dance_upload_dir' => dirname(__DIR__) . '/storage/dance-applications',
|
||||||
|
'dance_max_upload_bytes' => 8 * 1024 * 1024,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
$stmt = db()->prepare('SELECT reference_file_name, reference_original_name, reference_mime_type FROM dance_applications WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$file = $stmt->fetch();
|
||||||
|
if (!$file || !$file['reference_file_name']) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('找不到檔案。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedName = basename((string) $file['reference_file_name']);
|
||||||
|
$path = (string) $config['dance_upload_dir'] . DIRECTORY_SEPARATOR . $storedName;
|
||||||
|
if (!is_file($path)) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('檔案不存在。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$downloadName = basename((string) ($file['reference_original_name'] ?: $storedName));
|
||||||
|
$downloadName = str_replace(["\r", "\n", '"'], '', $downloadName);
|
||||||
|
audit('download', 'dance_applications', $id, ['file' => $downloadName]);
|
||||||
|
|
||||||
|
header('Content-Type: ' . ((string) $file['reference_mime_type'] ?: 'application/octet-stream'));
|
||||||
|
header('Content-Length: ' . filesize($path));
|
||||||
|
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($downloadName));
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
readfile($path);
|
||||||
|
exit;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$id = (int) ($_GET['id'] ?? $_POST['id'] ?? 0);
|
||||||
|
$statusOptions = dance_application_statuses();
|
||||||
|
$stmt = db()->prepare('SELECT * FROM dance_applications WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$application = $stmt->fetch();
|
||||||
|
if (!$application) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('找不到這筆報名資料。');
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
verify_csrf();
|
||||||
|
$status = trim((string) ($_POST['status'] ?? ''));
|
||||||
|
$adminNotes = mb_substr(trim((string) ($_POST['admin_notes'] ?? '')), 0, 5000, 'UTF-8');
|
||||||
|
if (!isset($statusOptions[$status])) {
|
||||||
|
$error = '請選擇有效的處理狀態。';
|
||||||
|
} else {
|
||||||
|
$update = db()->prepare('UPDATE dance_applications SET status = ?, admin_notes = ? WHERE id = ?');
|
||||||
|
$update->execute([$status, $adminNotes !== '' ? $adminNotes : null, $id]);
|
||||||
|
audit('update', 'dance_applications', $id, ['status' => $status, 'admin_notes' => $adminNotes]);
|
||||||
|
flash('success', '報名資料的處理狀態已更新。');
|
||||||
|
redirect('dance-application.php?id=' . $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_header('處理熱舞社報名 #' . $id, 'dance-applications');
|
||||||
|
?>
|
||||||
|
<?php if ($error): ?><div class="notice notice--error"><?= h($error) ?></div><?php endif; ?>
|
||||||
|
<div class="toolbar"><a class="button" href="dance-applications.php"><i class="bi bi-arrow-left"></i>回報名清單</a></div>
|
||||||
|
|
||||||
|
<section class="panel application-detail">
|
||||||
|
<div class="panel__head"><div><h2><?= h($application['nickname']) ?></h2><p>送出於 <?= h($application['created_at']) ?></p></div><span class="status status--<?= h($application['status']) ?>"><?= h($statusOptions[$application['status']] ?? $application['status']) ?></span></div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item"><span>暱稱</span><strong><?= h($application['nickname']) ?></strong></div>
|
||||||
|
<div class="detail-item"><span>Email</span><a href="mailto:<?= h($application['email']) ?>"><?= h($application['email']) ?></a></div>
|
||||||
|
<div class="detail-item"><span>練舞經驗</span><strong><?= $application['dance_years'] !== null ? (int) $application['dance_years'] . ' 年' : '未填寫' ?></strong></div>
|
||||||
|
<div class="detail-item"><span>可參與日期</span><strong><?= h($application['available_date'] ?: '未填寫') ?></strong></div>
|
||||||
|
<div class="detail-item"><span>20TH 經驗</span><strong><?= h(dance_attendance_label($application['attended_20th'])) ?></strong></div>
|
||||||
|
<div class="detail-item"><span>想練的歌曲</span><strong><?= h(dance_song_label($application['song'])) ?></strong></div>
|
||||||
|
<div class="detail-item detail-item--wide"><span>想參與的內容</span><strong><?= h(implode('、', dance_content_labels($application['participate_content']))) ?></strong></div>
|
||||||
|
<div class="detail-item detail-item--wide"><span>30TH 留言</span><div class="detail-value"><?= $application['message_30th'] ? nl2br(h($application['message_30th'])) : '未填寫' ?></div></div>
|
||||||
|
<div class="detail-item detail-item--wide"><span>參考檔案</span><?php if ($application['reference_file_name']): ?><a class="button detail-download" href="dance-application-file.php?id=<?= $id ?>"><i class="bi bi-download"></i><?= h($application['reference_original_name'] ?: '下載檔案') ?><?php if ($application['reference_file_size']): ?>(<?= h(number_format((int) $application['reference_file_size'] / 1024 / 1024, 2)) ?> MB)<?php endif; ?></a><?php else: ?><strong>未上傳</strong><?php endif; ?></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="panel editor" method="post">
|
||||||
|
<input type="hidden" name="csrf" value="<?= h(csrf_token()) ?>">
|
||||||
|
<input type="hidden" name="id" value="<?= $id ?>">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field"><span>處理狀態 *</span><select name="status" required><?php foreach ($statusOptions as $value => $label): ?><option value="<?= h($value) ?>" <?= $application['status'] === $value ? 'selected' : '' ?>><?= h($label) ?></option><?php endforeach; ?></select></label>
|
||||||
|
<label class="field field--wide"><span>內部備註</span><textarea name="admin_notes" rows="6" maxlength="5000" placeholder="例如:已寄信、等待確認練習日期……"><?= h((string) $application['admin_notes']) ?></textarea></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><a class="button" href="dance-applications.php">取消</a><button class="button button--primary" type="submit">儲存處理狀態</button></div>
|
||||||
|
</form>
|
||||||
|
<?php render_footer();
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/bootstrap.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||||
|
$perPage = 15;
|
||||||
|
$query = trim((string) ($_GET['q'] ?? ''));
|
||||||
|
$status = trim((string) ($_GET['status'] ?? ''));
|
||||||
|
$statusOptions = dance_application_statuses();
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($query !== '') {
|
||||||
|
$where[] = '(nickname LIKE ? OR email LIKE ? OR message_30th LIKE ?)';
|
||||||
|
$searchValue = '%' . $query . '%';
|
||||||
|
$params = [$searchValue, $searchValue, $searchValue];
|
||||||
|
}
|
||||||
|
if ($status !== '' && isset($statusOptions[$status])) {
|
||||||
|
$where[] = 'status = ?';
|
||||||
|
$params[] = $status;
|
||||||
|
} else {
|
||||||
|
$status = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereSql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
$countStmt = db()->prepare('SELECT COUNT(*) FROM dance_applications' . $whereSql);
|
||||||
|
$countStmt->execute($params);
|
||||||
|
$total = (int) $countStmt->fetchColumn();
|
||||||
|
$pages = max(1, (int) ceil($total / $perPage));
|
||||||
|
$page = min($page, $pages);
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$stmt = db()->prepare('SELECT * FROM dance_applications' . $whereSql . " ORDER BY created_at DESC, id DESC LIMIT $perPage OFFSET $offset");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
render_header('30TH 應援報名', 'dance-applications');
|
||||||
|
?>
|
||||||
|
<div class="toolbar">
|
||||||
|
<form class="filters" method="get">
|
||||||
|
<label class="search"><i class="bi bi-search"></i><input name="q" value="<?= h($query) ?>" placeholder="搜尋暱稱、Email 或留言"></label>
|
||||||
|
<select name="status" aria-label="處理狀態">
|
||||||
|
<option value="">全部狀態</option>
|
||||||
|
<?php foreach ($statusOptions as $value => $label): ?>
|
||||||
|
<option value="<?= h($value) ?>" <?= $status === $value ? 'selected' : '' ?>><?= h($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button class="button" type="submit">篩選</button>
|
||||||
|
</form>
|
||||||
|
<a class="button" href="../yellow-note/crystal-dance-survey.html#form-30th" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i>查看募集表單</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel__head"><div><h2>報名清單</h2><p>共 <?= $total ?> 筆;Email 與上傳檔案僅供企劃聯絡使用</p></div></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>編號</th><th>暱稱</th><th>Email</th><th>想參與</th><th>歌曲</th><th>狀態</th><th>送出時間</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<tr>
|
||||||
|
<td>#<?= (int) $row['id'] ?></td>
|
||||||
|
<td><?= h($row['nickname']) ?></td>
|
||||||
|
<td><?= h(mb_strimwidth((string) $row['email'], 0, 34, '…', 'UTF-8')) ?></td>
|
||||||
|
<td><?= h(implode('、', dance_content_labels($row['participate_content']))) ?></td>
|
||||||
|
<td><?= h(dance_song_label($row['song'])) ?></td>
|
||||||
|
<td><span class="status status--<?= h($row['status']) ?>"><?= h($statusOptions[$row['status']] ?? $row['status']) ?></span></td>
|
||||||
|
<td><?= h($row['created_at']) ?></td>
|
||||||
|
<td class="actions"><a href="dance-application.php?id=<?= (int) $row['id'] ?>">查看/處理</a></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!$rows): ?><tr><td colspan="8" 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="?q=<?= urlencode($query) ?>&status=<?= urlencode($status) ?>&page=<?= $i ?>"><?= $i ?></a>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</nav>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
<?php render_footer();
|
||||||
@@ -114,6 +114,30 @@ CREATE TABLE IF NOT EXISTS locations (
|
|||||||
CONSTRAINT fk_location_media FOREIGN KEY (media_id) REFERENCES media_assets(id) ON DELETE SET NULL
|
CONSTRAINT fk_location_media FOREIGN KEY (media_id) REFERENCES media_assets(id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dance_applications (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nickname VARCHAR(80) NOT NULL,
|
||||||
|
email VARCHAR(190) NOT NULL,
|
||||||
|
dance_years TINYINT UNSIGNED NULL,
|
||||||
|
available_date DATE NULL,
|
||||||
|
attended_20th ENUM('attended','watched_video','first_time') NULL,
|
||||||
|
participate_content VARCHAR(255) NOT NULL,
|
||||||
|
song VARCHAR(80) NULL,
|
||||||
|
reference_file_name VARCHAR(100) NULL,
|
||||||
|
reference_original_name VARCHAR(255) NULL,
|
||||||
|
reference_mime_type VARCHAR(100) NULL,
|
||||||
|
reference_file_size INT UNSIGNED NULL,
|
||||||
|
message_30th TEXT NULL,
|
||||||
|
status ENUM('new','contacted','confirmed','declined','archived') NOT NULL DEFAULT 'new',
|
||||||
|
admin_notes TEXT NULL,
|
||||||
|
submitted_ip VARCHAR(45) NULL,
|
||||||
|
user_agent VARCHAR(255) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_dance_applications_status_created (status, created_at),
|
||||||
|
INDEX idx_dance_applications_email (email)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><title>Not Found</title>
|
||||||
@@ -330,6 +330,38 @@
|
|||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.crystal-dance-page .summer-form__feedback {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border: 1px solid rgba(78, 73, 43, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--color-text-dark);
|
||||||
|
background: var(--color-surface-soft);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.crystal-dance-page .summer-form__feedback--success {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: var(--color-brand-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.crystal-dance-page .summer-form__feedback--error {
|
||||||
|
border-color: var(--color-brand-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.crystal-dance-page .summer-form__trap {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
body.crystal-dance-page .summer-form .btn-dance {
|
body.crystal-dance-page .summer-form .btn-dance {
|
||||||
background: var(--color-brand);
|
background: var(--color-brand);
|
||||||
color: var(--color-brand-dark);
|
color: var(--color-brand-dark);
|
||||||
@@ -483,13 +515,16 @@
|
|||||||
<h2 class="section-title">30TH 應援募集表單</h2>
|
<h2 class="section-title">30TH 應援募集表單</h2>
|
||||||
<p class="form-lead">這份表單是水晶熱舞社為 2027/04/15 30TH 預備的應援募集模組,用來整理參與者想練的歌曲、可支援的內容與想留下的祝福。</p>
|
<p class="form-lead">這份表單是水晶熱舞社為 2027/04/15 30TH 預備的應援募集模組,用來整理參與者想練的歌曲、可支援的內容與想留下的祝福。</p>
|
||||||
|
|
||||||
<form class="summer-form" action="#">
|
<div class="summer-form__feedback" data-form-feedback hidden tabindex="-1" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<form class="summer-form" action="dance-application-submit.php" method="post" enctype="multipart/form-data" data-dance-application-form>
|
||||||
|
<label class="summer-form__trap" aria-hidden="true">網站<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
|
||||||
|
|
||||||
<div class="summer-form__stage">
|
<div class="summer-form__stage">
|
||||||
<h3 class="summer-form__stage-title">填寫須知</h3>
|
<h3 class="summer-form__stage-title">填寫須知</h3>
|
||||||
<div class="summer-form__stage-body summer-form__stage-body--notice">
|
<div class="summer-form__stage-body summer-form__stage-body--notice">
|
||||||
<ul class="info-list">
|
<ul class="info-list">
|
||||||
<li>📎 檔案格式:影片或圖片皆可,建議單檔 100MB 以內</li>
|
<li>📎 檔案格式:影片或圖片皆可,單檔上限 8 MB</li>
|
||||||
<li>🔒 這份資料僅供水晶熱舞社內部整理 30TH 應援企劃使用</li>
|
<li>🔒 這份資料僅供水晶熱舞社內部整理 30TH 應援企劃使用</li>
|
||||||
<li>📅 送出後若確定參與,之後會另行公告練習與錄製時間</li>
|
<li>📅 送出後若確定參與,之後會另行公告練習與錄製時間</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -504,12 +539,12 @@
|
|||||||
<div class="col-12 col-sm-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label for="nickname" class="form-label">暱稱</label>
|
<label for="nickname" class="form-label">暱稱</label>
|
||||||
<input type="text" class="form-control" id="nickname" name="nickname"
|
<input type="text" class="form-control" id="nickname" name="nickname"
|
||||||
placeholder="例:熱舞小黃" required>
|
maxlength="80" placeholder="例:熱舞小黃" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label for="email" class="form-label">Email</label>
|
<label for="email" class="form-label">Email</label>
|
||||||
<input type="email" class="form-control" id="email" name="email"
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
placeholder="dance@example.com" required>
|
maxlength="190" placeholder="dance@example.com" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label for="dance_years" class="form-label">練舞經驗年數</label>
|
<label for="dance_years" class="form-label">練舞經驗年數</label>
|
||||||
@@ -532,7 +567,7 @@
|
|||||||
<label class="form-label d-block">是否參加過 20TH</label>
|
<label class="form-label d-block">是否參加過 20TH</label>
|
||||||
<div class="summer-form__option-row">
|
<div class="summer-form__option-row">
|
||||||
<input type="radio" class="btn-check" name="attended_20th"
|
<input type="radio" class="btn-check" name="attended_20th"
|
||||||
id="attended_20th_yes" value="attended" autocomplete="off">
|
id="attended_20th_yes" value="attended" autocomplete="off" required>
|
||||||
<label class="btn btn-outline-dance" for="attended_20th_yes">參加過 20TH</label>
|
<label class="btn btn-outline-dance" for="attended_20th_yes">參加過 20TH</label>
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="attended_20th"
|
<input type="radio" class="btn-check" name="attended_20th"
|
||||||
@@ -547,27 +582,27 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label d-block">想參與的 30TH 內容(可複選)</label>
|
<label class="form-label d-block">想參與的 30TH 內容(可複選)</label>
|
||||||
<div class="summer-form__option-row">
|
<div class="summer-form__option-row">
|
||||||
<input type="checkbox" class="btn-check" name="participate_content"
|
<input type="checkbox" class="btn-check" name="participate_content[]"
|
||||||
id="content_cover" value="cover" autocomplete="off">
|
id="content_cover" value="cover" autocomplete="off">
|
||||||
<label class="btn btn-outline-dance" for="content_cover">翻跳</label>
|
<label class="btn btn-outline-dance" for="content_cover">翻跳</label>
|
||||||
|
|
||||||
<input type="checkbox" class="btn-check" name="participate_content"
|
<input type="checkbox" class="btn-check" name="participate_content[]"
|
||||||
id="content_medley" value="medley" autocomplete="off">
|
id="content_medley" value="medley" autocomplete="off">
|
||||||
<label class="btn btn-outline-dance" for="content_medley">串燒聯舞</label>
|
<label class="btn btn-outline-dance" for="content_medley">串燒聯舞</label>
|
||||||
|
|
||||||
<input type="checkbox" class="btn-check" name="participate_content"
|
<input type="checkbox" class="btn-check" name="participate_content[]"
|
||||||
id="content_blessing" value="blessing" autocomplete="off">
|
id="content_blessing" value="blessing" autocomplete="off">
|
||||||
<label class="btn btn-outline-dance" for="content_blessing">祝福影片</label>
|
<label class="btn btn-outline-dance" for="content_blessing">祝福影片</label>
|
||||||
|
|
||||||
<input type="checkbox" class="btn-check" name="participate_content"
|
<input type="checkbox" class="btn-check" name="participate_content[]"
|
||||||
id="content_graphic" value="graphic" autocomplete="off">
|
id="content_graphic" value="graphic" autocomplete="off">
|
||||||
<label class="btn btn-outline-dance" for="content_graphic">圖文應援</label>
|
<label class="btn btn-outline-dance" for="content_graphic">圖文應援</label>
|
||||||
|
|
||||||
<input type="checkbox" class="btn-check" name="participate_content"
|
<input type="checkbox" class="btn-check" name="participate_content[]"
|
||||||
id="content_editing" value="editing" autocomplete="off">
|
id="content_editing" value="editing" autocomplete="off">
|
||||||
<label class="btn btn-outline-dance" for="content_editing">影片剪輯</label>
|
<label class="btn btn-outline-dance" for="content_editing">影片剪輯</label>
|
||||||
|
|
||||||
<input type="checkbox" class="btn-check" name="participate_content"
|
<input type="checkbox" class="btn-check" name="participate_content[]"
|
||||||
id="content_archive" value="archive" autocomplete="off">
|
id="content_archive" value="archive" autocomplete="off">
|
||||||
<label class="btn btn-outline-dance" for="content_archive">資料整理</label>
|
<label class="btn btn-outline-dance" for="content_archive">資料整理</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -599,7 +634,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label for="message_30th" class="form-label">想對 30TH 說的話</label>
|
<label for="message_30th" class="form-label">想對 30TH 說的話</label>
|
||||||
<textarea class="form-control" id="message_30th" name="message_30th" rows="4"
|
<textarea class="form-control" id="message_30th" name="message_30th" rows="4" maxlength="5000"
|
||||||
placeholder="想對水晶熱舞社說的話、想貢獻的部分、想見到誰、想留下的祝福……"></textarea>
|
placeholder="想對水晶熱舞社說的話、想貢獻的部分、想見到誰、想留下的祝福……"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -608,7 +643,7 @@
|
|||||||
|
|
||||||
<div class="summer-form__actions">
|
<div class="summer-form__actions">
|
||||||
<button type="reset" class="btn btn-reset-dance">重新填寫</button>
|
<button type="reset" class="btn btn-reset-dance">重新填寫</button>
|
||||||
<button type="submit" class="btn btn-dance">送出應援表單 💛</button>
|
<button type="submit" class="btn btn-dance" data-submit-button>送出應援表單 💛</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -637,6 +672,50 @@
|
|||||||
: '💛 30TH · 2027/04/15 已經到來,一起再集合!';
|
: '💛 30TH · 2027/04/15 已經到來,一起再集合!';
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var form = document.querySelector('[data-dance-application-form]');
|
||||||
|
var feedback = document.querySelector('[data-form-feedback]');
|
||||||
|
var submitButton = document.querySelector('[data-submit-button]');
|
||||||
|
var result = new URLSearchParams(window.location.search).get('form');
|
||||||
|
var messages = {
|
||||||
|
success: ['success', '已收到你的應援報名!水晶熱舞社整理後會依你留下的 Email 聯絡。'],
|
||||||
|
invalid: ['error', '有欄位尚未完成或格式不正確,請檢查後再送出。'],
|
||||||
|
file_too_large: ['error', '上傳檔案超過 8 MB,請縮小檔案後再送出。'],
|
||||||
|
file_type: ['error', '檔案格式不支援,請改用圖片、MP4、WebM 或 MOV。'],
|
||||||
|
too_many: ['error', '短時間內送出次數較多,請稍候兩分鐘再試。'],
|
||||||
|
unavailable: ['error', '目前暫時無法儲存報名,請稍後再試。']
|
||||||
|
};
|
||||||
|
|
||||||
|
function showFeedback(type, message) {
|
||||||
|
if (!feedback) return;
|
||||||
|
feedback.hidden = false;
|
||||||
|
feedback.className = 'summer-form__feedback summer-form__feedback--' + type;
|
||||||
|
feedback.textContent = message;
|
||||||
|
feedback.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result && messages[result]) {
|
||||||
|
showFeedback(messages[result][0], messages[result][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (event) {
|
||||||
|
var selectedContent = form.querySelectorAll('input[name="participate_content[]"]:checked');
|
||||||
|
if (!selectedContent.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
showFeedback('error', '請至少選擇一項想參與的 30TH 內容。');
|
||||||
|
var firstContent = form.querySelector('input[name="participate_content[]"]');
|
||||||
|
if (firstContent) firstContent.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = '送出中…';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../admin/config.php';
|
||||||
|
|
||||||
|
function return_to_dance_form(string $result): never
|
||||||
|
{
|
||||||
|
header('Location: crystal-dance-survey.html?form=' . rawurlencode($result) . '#form-30th', true, 303);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function post_value(string $key, int $maxLength): string
|
||||||
|
{
|
||||||
|
$value = trim((string) ($_POST[$key] ?? ''));
|
||||||
|
return mb_substr($value, 0, $maxLength, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
header('Allow: POST');
|
||||||
|
exit('Method Not Allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxUploadBytes = (int) $config['dance_max_upload_bytes'];
|
||||||
|
$contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||||
|
if ($contentLength > $maxUploadBytes + 1024 * 1024) {
|
||||||
|
return_to_dance_form('file_too_large');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隱藏欄位有內容時視為機器送件;不透露攔截規則。
|
||||||
|
if (trim((string) ($_POST['website'] ?? '')) !== '') {
|
||||||
|
return_to_dance_form('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
$nickname = post_value('nickname', 80);
|
||||||
|
$email = mb_strtolower(post_value('email', 190), 'UTF-8');
|
||||||
|
$danceYearsRaw = trim((string) ($_POST['dance_years'] ?? ''));
|
||||||
|
$availableDate = trim((string) ($_POST['available_date'] ?? ''));
|
||||||
|
$attended20th = trim((string) ($_POST['attended_20th'] ?? ''));
|
||||||
|
$song = trim((string) ($_POST['song'] ?? ''));
|
||||||
|
$message30th = post_value('message_30th', 5000);
|
||||||
|
|
||||||
|
$allowedAttendance = ['attended', 'watched_video', 'first_time'];
|
||||||
|
$allowedContent = ['cover', 'medley', 'blessing', 'graphic', 'editing', 'archive'];
|
||||||
|
$allowedSongs = ['', 'couple', 'comeback', 'road_fighter', 'pom_saeng_pom_sa', 'other'];
|
||||||
|
|
||||||
|
$selectedContent = $_POST['participate_content'] ?? [];
|
||||||
|
if (!is_array($selectedContent)) {
|
||||||
|
$selectedContent = [$selectedContent];
|
||||||
|
}
|
||||||
|
$selectedContent = array_values(array_unique(array_intersect(
|
||||||
|
$allowedContent,
|
||||||
|
array_map(static fn ($value): string => trim((string) $value), $selectedContent)
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($nickname === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || !$selectedContent) {
|
||||||
|
return_to_dance_form('invalid');
|
||||||
|
}
|
||||||
|
if ($attended20th !== '' && !in_array($attended20th, $allowedAttendance, true)) {
|
||||||
|
return_to_dance_form('invalid');
|
||||||
|
}
|
||||||
|
if (!in_array($song, $allowedSongs, true)) {
|
||||||
|
return_to_dance_form('invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
$danceYears = null;
|
||||||
|
if ($danceYearsRaw !== '') {
|
||||||
|
$danceYearsFilter = filter_var($danceYearsRaw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 30]]);
|
||||||
|
if ($danceYearsFilter === false) {
|
||||||
|
return_to_dance_form('invalid');
|
||||||
|
}
|
||||||
|
$danceYears = (int) $danceYearsFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($availableDate !== '') {
|
||||||
|
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $availableDate);
|
||||||
|
if (!$date || $date->format('Y-m-d') !== $availableDate) {
|
||||||
|
return_to_dance_form('invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
} catch (PDOException) {
|
||||||
|
return_to_dance_form('unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
$submittedIp = mb_substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 45, 'UTF-8');
|
||||||
|
if ($submittedIp !== '') {
|
||||||
|
$rate = $pdo->prepare('SELECT COUNT(*) FROM dance_applications WHERE submitted_ip = ? AND created_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE)');
|
||||||
|
$rate->execute([$submittedIp]);
|
||||||
|
if ((int) $rate->fetchColumn() >= 3) {
|
||||||
|
return_to_dance_form('too_many');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedFileName = null;
|
||||||
|
$originalFileName = null;
|
||||||
|
$mimeType = null;
|
||||||
|
$fileSize = null;
|
||||||
|
$file = $_FILES['reference_file'] ?? null;
|
||||||
|
if (is_array($file) && (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||||
|
if ((int) $file['error'] !== UPLOAD_ERR_OK || (int) $file['size'] > $maxUploadBytes) {
|
||||||
|
return_to_dance_form('file_too_large');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedMimeTypes = [
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
'video/mp4' => 'mp4',
|
||||||
|
'video/webm' => 'webm',
|
||||||
|
'video/quicktime' => 'mov',
|
||||||
|
];
|
||||||
|
$mimeType = (new finfo(FILEINFO_MIME_TYPE))->file((string) $file['tmp_name']) ?: '';
|
||||||
|
if (!isset($allowedMimeTypes[$mimeType])) {
|
||||||
|
return_to_dance_form('file_type');
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadDir = (string) $config['dance_upload_dir'];
|
||||||
|
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0750, true) && !is_dir($uploadDir)) {
|
||||||
|
return_to_dance_form('unavailable');
|
||||||
|
}
|
||||||
|
$storedFileName = bin2hex(random_bytes(20)) . '.' . $allowedMimeTypes[$mimeType];
|
||||||
|
if (!move_uploaded_file((string) $file['tmp_name'], $uploadDir . DIRECTORY_SEPARATOR . $storedFileName)) {
|
||||||
|
return_to_dance_form('unavailable');
|
||||||
|
}
|
||||||
|
$originalFileName = mb_substr(basename((string) $file['name']), 0, 255, 'UTF-8');
|
||||||
|
$fileSize = (int) $file['size'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO dance_applications
|
||||||
|
(nickname, email, dance_years, available_date, attended_20th, participate_content, song,
|
||||||
|
reference_file_name, reference_original_name, reference_mime_type, reference_file_size,
|
||||||
|
message_30th, submitted_ip, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$nickname,
|
||||||
|
$email,
|
||||||
|
$danceYears,
|
||||||
|
$availableDate !== '' ? $availableDate : null,
|
||||||
|
$attended20th !== '' ? $attended20th : null,
|
||||||
|
implode(',', $selectedContent),
|
||||||
|
$song !== '' ? $song : null,
|
||||||
|
$storedFileName,
|
||||||
|
$originalFileName,
|
||||||
|
$mimeType,
|
||||||
|
$fileSize,
|
||||||
|
$message30th !== '' ? $message30th : null,
|
||||||
|
$submittedIp !== '' ? $submittedIp : null,
|
||||||
|
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255, 'UTF-8'),
|
||||||
|
]);
|
||||||
|
} catch (PDOException) {
|
||||||
|
if ($storedFileName !== null) {
|
||||||
|
@unlink((string) $config['dance_upload_dir'] . DIRECTORY_SEPARATOR . $storedFileName);
|
||||||
|
}
|
||||||
|
return_to_dance_form('unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return_to_dance_form('success');
|
||||||
Reference in New Issue
Block a user