feat: Hall of Chaos easter egg -- HallOfChaosView + long-press trigger
Adds the Hall of Chaos overlay component (recipe blooper gallery with static CSS tilts, chaos level counter, panel-local overlay) and wires the 800ms long-press trigger on the Bloopers filter tab in CommunityFeedPanel. Pairs with the backend /community/hall-of-chaos endpoint and test added in Task 10.
This commit is contained in:
parent
f92ac7a509
commit
9246935fd7
2 changed files with 208 additions and 0 deletions
|
|
@ -12,6 +12,9 @@
|
|||
:class="['btn', 'tab-btn', activeFilter === f.id ? 'btn-primary' : 'btn-secondary']"
|
||||
@click="setFilter(f.id)"
|
||||
@keydown="onFilterKeydown"
|
||||
@pointerdown="f.id === 'recipe_blooper' ? onBlooperPointerDown($event) : undefined"
|
||||
@pointerup="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
|
||||
@pointerleave="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -103,6 +106,12 @@
|
|||
@published="onPlanPublished"
|
||||
/>
|
||||
|
||||
<!-- Hall of Chaos easter egg: hold Bloopers tab for 800ms -->
|
||||
<HallOfChaosView
|
||||
v-if="showHallOfChaos"
|
||||
@close="showHallOfChaos = false"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -111,6 +120,7 @@ import { ref, onMounted } from 'vue'
|
|||
import { useCommunityStore } from '../stores/community'
|
||||
import CommunityPostCard from './CommunityPostCard.vue'
|
||||
import PublishPlanModal from './PublishPlanModal.vue'
|
||||
import HallOfChaosView from './HallOfChaosView.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'plan-forked': [payload: { plan_id: number; week_start: string }]
|
||||
|
|
@ -120,6 +130,22 @@ const store = useCommunityStore()
|
|||
|
||||
const activeFilter = ref('all')
|
||||
const showPublishPlan = ref(false)
|
||||
const showHallOfChaos = ref(false)
|
||||
let blooperHoldTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onBlooperPointerDown(_e: PointerEvent) {
|
||||
blooperHoldTimer = setTimeout(() => {
|
||||
showHallOfChaos.value = true
|
||||
blooperHoldTimer = null
|
||||
}, 800)
|
||||
}
|
||||
|
||||
function onBlooperPointerCancel() {
|
||||
if (blooperHoldTimer !== null) {
|
||||
clearTimeout(blooperHoldTimer)
|
||||
blooperHoldTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All' },
|
||||
|
|
|
|||
182
frontend/src/components/HallOfChaosView.vue
Normal file
182
frontend/src/components/HallOfChaosView.vue
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<template>
|
||||
<div class="hall-of-chaos-overlay" role="dialog" aria-modal="true" aria-label="Hall of Chaos">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="chaos-header">
|
||||
<h2 class="chaos-title">HALL OF CHAOS</h2>
|
||||
<p class="chaos-subtitle text-sm">
|
||||
Chaos Level: <span class="chaos-level">{{ chaosLevel }}</span>
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-secondary chaos-exit-btn"
|
||||
aria-label="Exit Hall of Chaos"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Escape the chaos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="chaos-loading text-center text-secondary" aria-busy="true">
|
||||
Assembling the chaos...
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="chaos-empty text-center text-secondary" role="alert">
|
||||
The chaos is temporarily indisposed.
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="posts.length === 0" class="chaos-empty text-center text-secondary">
|
||||
<p>No bloopers yet. Be the first to make a glorious mistake.</p>
|
||||
</div>
|
||||
|
||||
<!-- Blooper cards -->
|
||||
<div v-else class="chaos-grid" aria-label="Blooper posts">
|
||||
<article
|
||||
v-for="(post, index) in posts"
|
||||
:key="post.slug"
|
||||
class="chaos-card"
|
||||
:class="`chaos-card--tilt-${(index % 5) + 1}`"
|
||||
:style="{ '--chaos-border-color': borderColors[index % borderColors.length] }"
|
||||
>
|
||||
<p class="chaos-card-author text-xs text-muted">{{ post.pseudonym }}</p>
|
||||
<h3 class="chaos-card-title text-base font-semibold">{{ post.title }}</h3>
|
||||
<p v-if="post.outcome_notes" class="chaos-card-notes text-sm text-secondary">
|
||||
{{ post.outcome_notes }}
|
||||
</p>
|
||||
<p v-if="post.recipe_name" class="chaos-card-recipe text-xs text-muted mt-xs">
|
||||
Recipe: {{ post.recipe_name }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../services/api'
|
||||
import type { CommunityPost } from '../stores/community'
|
||||
|
||||
defineEmits<{ close: [] }>()
|
||||
|
||||
const posts = ref<CommunityPost[]>([])
|
||||
const chaosLevel = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
// CSS custom property strings -- no hardcoded hex
|
||||
const borderColors = [
|
||||
'var(--color-warning)',
|
||||
'var(--color-info)',
|
||||
'var(--color-success)',
|
||||
'var(--color-error)',
|
||||
'var(--color-warning)',
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await api.get<{ posts: CommunityPost[]; chaos_level: number }>(
|
||||
'/community/hall-of-chaos'
|
||||
)
|
||||
posts.value = response.data.posts
|
||||
chaosLevel.value = response.data.chaos_level
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hall-of-chaos-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: var(--color-bg-primary);
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.chaos-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.chaos-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-warning);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chaos-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.chaos-level {
|
||||
font-weight: 700;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.chaos-exit-btn {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.chaos-loading,
|
||||
.chaos-empty {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.chaos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Static tilts applied once at render -- not animations, no reduced-motion concern */
|
||||
.chaos-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 2px solid var(--chaos-border-color, var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.chaos-card--tilt-1 { transform: rotate(-3deg); }
|
||||
.chaos-card--tilt-2 { transform: rotate(2deg); }
|
||||
.chaos-card--tilt-3 { transform: rotate(-1.5deg); }
|
||||
.chaos-card--tilt-4 { transform: rotate(4deg); }
|
||||
.chaos-card--tilt-5 { transform: rotate(-4.5deg); }
|
||||
|
||||
.chaos-card-title {
|
||||
margin: var(--spacing-xs) 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.chaos-card-author,
|
||||
.chaos-card-notes,
|
||||
.chaos-card-recipe {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chaos-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chaos-card--tilt-1,
|
||||
.chaos-card--tilt-2,
|
||||
.chaos-card--tilt-3,
|
||||
.chaos-card--tilt-4,
|
||||
.chaos-card--tilt-5 {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue