|
|
@@ -12,25 +12,54 @@ $sql = "
|
|
|
ag.sortOrder AS groupSort,
|
|
|
a.activityID,
|
|
|
a.activityName,
|
|
|
- a.sortOrder AS actSort
|
|
|
+ a.sortOrder AS actSort,
|
|
|
+ a.hidden
|
|
|
FROM activity_groups ag
|
|
|
JOIN activities a ON a.groupID = ag.groupID
|
|
|
ORDER BY ag.sortOrder, ag.groupID, a.sortOrder, a.activityID
|
|
|
";
|
|
|
$rows = $pdo->query($sql)->fetchAll();
|
|
|
|
|
|
-// Group by category
|
|
|
-$groups = [];
|
|
|
+// Group by category. Hidden activities are easter eggs: they are rendered into
|
|
|
+// a section that stays hidden until the page script reveals it.
|
|
|
+$byVisibility = ['visible' => [], 'secret' => []];
|
|
|
foreach ($rows as $row) {
|
|
|
$gid = $row['groupID'];
|
|
|
- if (!isset($groups[$gid])) {
|
|
|
- $groups[$gid] = ['name' => $row['groupName'], 'activities' => []];
|
|
|
+ $vis = $row['hidden'] ? 'secret' : 'visible';
|
|
|
+ if (!isset($byVisibility[$vis][$gid])) {
|
|
|
+ $byVisibility[$vis][$gid] = ['name' => $row['groupName'], 'activities' => []];
|
|
|
}
|
|
|
- $groups[$gid]['activities'][] = [
|
|
|
+ $byVisibility[$vis][$gid]['activities'][] = [
|
|
|
'id' => $row['activityID'],
|
|
|
'name' => $row['activityName'],
|
|
|
];
|
|
|
}
|
|
|
+$groups = $byVisibility['visible'];
|
|
|
+$secretGroups = $byVisibility['secret'];
|
|
|
+
|
|
|
+function render_activity_group(array $group): void { ?>
|
|
|
+ <div class="activity-group">
|
|
|
+ <div class="section-title"><?= htmlspecialchars($group['name']) ?></div>
|
|
|
+ <div class="activity-grid">
|
|
|
+ <?php foreach ($group['activities'] as $act): ?>
|
|
|
+ <div class="activity-card">
|
|
|
+ <input type="checkbox" name="aID[]"
|
|
|
+ id="act_<?= $act['id'] ?>"
|
|
|
+ value="<?= (int)$act['id'] ?>"
|
|
|
+ onchange="updateCount()">
|
|
|
+ <label for="act_<?= $act['id'] ?>">
|
|
|
+ <span class="check-icon">
|
|
|
+ <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
|
|
|
+ <path d="M1 4l3 3 5-6" stroke="#0f1117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
|
+ </svg>
|
|
|
+ </span>
|
|
|
+ <?= htmlspecialchars($act['name']) ?>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ <?php endforeach; ?>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+<?php }
|
|
|
?>
|
|
|
<!DOCTYPE html>
|
|
|
<html lang="en">
|
|
|
@@ -219,10 +248,35 @@ foreach ($rows as $row) {
|
|
|
border-radius: var(--radius);
|
|
|
cursor: pointer;
|
|
|
transition: background .15s, transform .1s, opacity .15s;
|
|
|
+ touch-action: manipulation; /* rapid taps must not turn into double-tap zoom */
|
|
|
}
|
|
|
.btn-primary:hover { background: #f0d260; transform: translateY(-1px); }
|
|
|
.btn-primary:active { transform: translateY(0); }
|
|
|
- .btn-primary:disabled { opacity: .35; cursor: not-allowed; transform: none; }
|
|
|
+ .btn-primary[aria-disabled="true"] { opacity: .35; cursor: not-allowed; transform: none; }
|
|
|
+ .btn-primary.nope { animation: nope .3s; }
|
|
|
+ @keyframes nope {
|
|
|
+ 20%, 60% { transform: translateX(-4px); }
|
|
|
+ 40%, 80% { transform: translateX(4px); }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Easter egg: hidden activities flicker in like a failing fluorescent tube. */
|
|
|
+ .secret-activities .section-title { color: var(--danger); }
|
|
|
+ .secret-activities .activity-card input:checked + label {
|
|
|
+ border-color: var(--danger);
|
|
|
+ background: rgba(224,85,85,.08);
|
|
|
+ }
|
|
|
+ .secret-activities .activity-card input:checked + label .check-icon {
|
|
|
+ background: var(--danger);
|
|
|
+ border-color: var(--danger);
|
|
|
+ }
|
|
|
+ .secret-activities.revealing { animation: flicker 1.2s linear; }
|
|
|
+ @keyframes flicker {
|
|
|
+ 0%, 12%, 22%, 48% { opacity: 0; }
|
|
|
+ 10%, 20%, 45%, 100% { opacity: 1; }
|
|
|
+ }
|
|
|
+ @media (prefers-reduced-motion: reduce) {
|
|
|
+ .btn-primary.nope, .secret-activities.revealing { animation: none; }
|
|
|
+ }
|
|
|
|
|
|
/* select-all row */
|
|
|
.controls {
|
|
|
@@ -271,36 +325,22 @@ foreach ($rows as $row) {
|
|
|
</div>
|
|
|
|
|
|
<form id="actForm" action="list.php" method="GET">
|
|
|
- <?php foreach ($groups as $gid => $group): ?>
|
|
|
- <div class="activity-group">
|
|
|
- <div class="section-title"><?= htmlspecialchars($group['name']) ?></div>
|
|
|
- <div class="activity-grid">
|
|
|
- <?php foreach ($group['activities'] as $act): ?>
|
|
|
- <div class="activity-card">
|
|
|
- <input type="checkbox" name="aID[]"
|
|
|
- id="act_<?= $act['id'] ?>"
|
|
|
- value="<?= (int)$act['id'] ?>"
|
|
|
- onchange="updateCount()">
|
|
|
- <label for="act_<?= $act['id'] ?>">
|
|
|
- <span class="check-icon">
|
|
|
- <svg width="10" height="8" viewBox="0 0 10 8" fill="none">
|
|
|
- <path d="M1 4l3 3 5-6" stroke="#0f1117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
|
- </svg>
|
|
|
- </span>
|
|
|
- <?= htmlspecialchars($act['name']) ?>
|
|
|
- </label>
|
|
|
- </div>
|
|
|
- <?php endforeach; ?>
|
|
|
- </div>
|
|
|
+ <?php foreach ($groups as $group) render_activity_group($group); ?>
|
|
|
+
|
|
|
+ <?php if ($secretGroups): ?>
|
|
|
+ <div id="secretActivities" class="secret-activities" hidden>
|
|
|
+ <?php foreach ($secretGroups as $group) render_activity_group($group); ?>
|
|
|
</div>
|
|
|
- <?php endforeach; ?>
|
|
|
+ <?php endif; ?>
|
|
|
</form>
|
|
|
</main>
|
|
|
|
|
|
<div class="action-bar">
|
|
|
<div class="selection-count"><strong id="cnt">0</strong> activities selected</div>
|
|
|
- <button class="btn-primary" id="packBtn" disabled
|
|
|
- onclick="document.getElementById('actForm').submit()">
|
|
|
+ <!-- aria-disabled rather than disabled: a disabled button swallows clicks,
|
|
|
+ and the clicks on an empty selection are what reveal the easter egg. -->
|
|
|
+ <button class="btn-primary" id="packBtn" type="button" aria-disabled="true"
|
|
|
+ onclick="packIt()">
|
|
|
Pack it! →
|
|
|
</button>
|
|
|
</div>
|
|
|
@@ -308,16 +348,69 @@ foreach ($rows as $row) {
|
|
|
<footer><a href="admin/">Admin</a></footer>
|
|
|
|
|
|
<script>
|
|
|
+function selectedCount() {
|
|
|
+ return document.querySelectorAll('input[name="aID[]"]:checked').length;
|
|
|
+}
|
|
|
function updateCount() {
|
|
|
- const n = document.querySelectorAll('input[name="aID[]"]:checked').length;
|
|
|
+ const n = selectedCount();
|
|
|
document.getElementById('cnt').textContent = n;
|
|
|
- document.getElementById('packBtn').disabled = n === 0;
|
|
|
+ document.getElementById('packBtn').setAttribute('aria-disabled', n === 0 ? 'true' : 'false');
|
|
|
}
|
|
|
function toggleAll(on) {
|
|
|
- document.querySelectorAll('input[name="aID[]"]').forEach(cb => cb.checked = on);
|
|
|
+ // Secret activities stay out of "Select all" until they have been found.
|
|
|
+ document.querySelectorAll('input[name="aID[]"]').forEach(cb => {
|
|
|
+ if (!cb.closest('[hidden]')) cb.checked = on;
|
|
|
+ });
|
|
|
updateCount();
|
|
|
}
|
|
|
|
|
|
+// Easter egg: hammering the button with nothing selected reveals the hidden
|
|
|
+// activities. Each click must follow the previous one within EGG_MAX_GAP_MS.
|
|
|
+const EGG_CLICKS = 10;
|
|
|
+const EGG_MAX_GAP_MS = 500;
|
|
|
+let eggClicks = 0;
|
|
|
+let eggLastClick = 0;
|
|
|
+
|
|
|
+function packIt() {
|
|
|
+ if (selectedCount() > 0) {
|
|
|
+ document.getElementById('actForm').submit();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const btn = document.getElementById('packBtn');
|
|
|
+ btn.classList.remove('nope');
|
|
|
+ void btn.offsetWidth; // restart the shake on every click
|
|
|
+ btn.classList.add('nope');
|
|
|
+
|
|
|
+ const now = Date.now();
|
|
|
+ eggClicks = now - eggLastClick <= EGG_MAX_GAP_MS ? eggClicks + 1 : 1;
|
|
|
+ eggLastClick = now;
|
|
|
+ if (eggClicks >= EGG_CLICKS) {
|
|
|
+ eggClicks = 0;
|
|
|
+ revealSecrets(true);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function revealSecrets(select) {
|
|
|
+ const secret = document.getElementById('secretActivities');
|
|
|
+ if (!secret || !secret.hidden) return;
|
|
|
+ secret.hidden = false;
|
|
|
+ if (select) {
|
|
|
+ secret.querySelectorAll('input[name="aID[]"]').forEach(cb => cb.checked = true);
|
|
|
+ secret.classList.add('revealing');
|
|
|
+ secret.addEventListener('animationend', () => secret.classList.remove('revealing'), { once: true });
|
|
|
+ secret.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
+ }
|
|
|
+ updateCount();
|
|
|
+}
|
|
|
+
|
|
|
+// Back/forward navigation restores ticked checkboxes, including a secret one
|
|
|
+// that would otherwise be ticked but invisible.
|
|
|
+window.addEventListener('pageshow', () => {
|
|
|
+ if (document.querySelector('#secretActivities input:checked')) revealSecrets(false);
|
|
|
+ updateCount();
|
|
|
+});
|
|
|
+
|
|
|
// Offline support: installs the worker that keeps visited lists readable
|
|
|
// without signal.
|
|
|
if ('serviceWorker' in navigator) {
|