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']"
|
:class="['btn', 'tab-btn', activeFilter === f.id ? 'btn-primary' : 'btn-secondary']"
|
||||||
@click="setFilter(f.id)"
|
@click="setFilter(f.id)"
|
||||||
@keydown="onFilterKeydown"
|
@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>
|
>{{ f.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -103,6 +106,12 @@
|
||||||
@published="onPlanPublished"
|
@published="onPlanPublished"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Hall of Chaos easter egg: hold Bloopers tab for 800ms -->
|
||||||
|
<HallOfChaosView
|
||||||
|
v-if="showHallOfChaos"
|
||||||
|
@close="showHallOfChaos = false"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -111,6 +120,7 @@ import { ref, onMounted } from 'vue'
|
||||||
import { useCommunityStore } from '../stores/community'
|
import { useCommunityStore } from '../stores/community'
|
||||||
import CommunityPostCard from './CommunityPostCard.vue'
|
import CommunityPostCard from './CommunityPostCard.vue'
|
||||||
import PublishPlanModal from './PublishPlanModal.vue'
|
import PublishPlanModal from './PublishPlanModal.vue'
|
||||||
|
import HallOfChaosView from './HallOfChaosView.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'plan-forked': [payload: { plan_id: number; week_start: string }]
|
'plan-forked': [payload: { plan_id: number; week_start: string }]
|
||||||
|
|
@ -120,6 +130,22 @@ const store = useCommunityStore()
|
||||||
|
|
||||||
const activeFilter = ref('all')
|
const activeFilter = ref('all')
|
||||||
const showPublishPlan = ref(false)
|
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 = [
|
const filters = [
|
||||||
{ id: 'all', label: 'All' },
|
{ 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