feat(avocet): easter eggs — hired confetti, century mark, clean sweep, midnight labeler, cursor trail
This commit is contained in:
parent
deddd763ea
commit
47973aeba6
2 changed files with 184 additions and 7 deletions
|
|
@ -41,3 +41,114 @@ export function useHackerMode() {
|
|||
|
||||
return { toggle, restore }
|
||||
}
|
||||
|
||||
/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */
|
||||
export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) {
|
||||
if (typeof requestAnimationFrame === 'undefined') return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;'
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
document.body.appendChild(canvas)
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
const COLORS = ['#2A6080','#B8622A','#5A9DBF','#D4854A','#FFC107','#4CAF50']
|
||||
const particles = Array.from({ length: 80 }, () => ({
|
||||
x: originX,
|
||||
y: originY,
|
||||
vx: (Math.random() - 0.5) * 14,
|
||||
vy: (Math.random() - 0.6) * 12,
|
||||
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
||||
size: 5 + Math.random() * 6,
|
||||
angle: Math.random() * Math.PI * 2,
|
||||
spin: (Math.random() - 0.5) * 0.3,
|
||||
life: 1.0,
|
||||
}))
|
||||
|
||||
let raf = 0
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
let alive = false
|
||||
for (const p of particles) {
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
p.vy += 0.35 // gravity
|
||||
p.vx *= 0.98 // air friction
|
||||
p.angle += p.spin
|
||||
p.life -= 0.016
|
||||
if (p.life <= 0) continue
|
||||
alive = true
|
||||
ctx.save()
|
||||
ctx.globalAlpha = p.life
|
||||
ctx.fillStyle = p.color
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate(p.angle)
|
||||
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6)
|
||||
ctx.restore()
|
||||
}
|
||||
if (alive) {
|
||||
raf = requestAnimationFrame(draw)
|
||||
} else {
|
||||
cancelAnimationFrame(raf)
|
||||
canvas.remove()
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
/** Enable cursor trail in hacker mode — returns a cleanup function. */
|
||||
export function useCursorTrail() {
|
||||
const DOT_COUNT = 10
|
||||
const dots: HTMLElement[] = []
|
||||
let positions: { x: number; y: number }[] = []
|
||||
let mouseX = 0
|
||||
let mouseY = 0
|
||||
let raf = 0
|
||||
|
||||
for (let i = 0; i < DOT_COUNT; i++) {
|
||||
const dot = document.createElement('div')
|
||||
dot.style.cssText = [
|
||||
'position:fixed',
|
||||
'pointer-events:none',
|
||||
'z-index:9998',
|
||||
'border-radius:50%',
|
||||
'background:#5A9DBF',
|
||||
'transition:opacity 0.1s',
|
||||
].join(';')
|
||||
document.body.appendChild(dot)
|
||||
dots.push(dot)
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
mouseX = e.clientX
|
||||
mouseY = e.clientY
|
||||
}
|
||||
|
||||
function animate() {
|
||||
positions.unshift({ x: mouseX, y: mouseY })
|
||||
if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT)
|
||||
|
||||
dots.forEach((dot, i) => {
|
||||
const pos = positions[i]
|
||||
if (!pos) { dot.style.opacity = '0'; return }
|
||||
const scale = 1 - i / DOT_COUNT
|
||||
const size = Math.round(8 * scale)
|
||||
dot.style.left = `${pos.x - size / 2}px`
|
||||
dot.style.top = `${pos.y - size / 2}px`
|
||||
dot.style.width = `${size}px`
|
||||
dot.style.height = `${size}px`
|
||||
dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}`
|
||||
})
|
||||
raf = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
raf = requestAnimationFrame(animate)
|
||||
|
||||
return function cleanup() {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
cancelAnimationFrame(raf)
|
||||
dots.forEach(d => d.remove())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@
|
|||
<template v-if="store.totalRemaining > 0">
|
||||
{{ store.totalRemaining }} remaining
|
||||
</template>
|
||||
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
|
||||
<span v-if="speedRound" class="badge badge-speed">⚡ Speed round!</span>
|
||||
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
|
||||
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
|
||||
<span v-if="speedRound" class="badge badge-speed">⚡ Speed round!</span>
|
||||
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
|
||||
<span v-if="centuryMark" class="badge badge-century">💯 Century!</span>
|
||||
<span v-if="cleanSweep" class="badge badge-sweep">🧹 Clean sweep!</span>
|
||||
<span v-if="midnightLabeler" class="badge badge-midnight">🦉 Midnight labeler!</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button @click="handleUndo" :disabled="!store.lastAction" class="btn-action">↩ Undo</button>
|
||||
|
|
@ -61,12 +64,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useLabelStore } from '../stores/label'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
import { useHaptics } from '../composables/useHaptics'
|
||||
import { useMotion } from '../composables/useMotion'
|
||||
import { useLabelKeyboard } from '../composables/useLabelKeyboard'
|
||||
import { fireConfetti, useCursorTrail } from '../composables/useEasterEgg'
|
||||
import EmailCardStack from '../components/EmailCardStack.vue'
|
||||
import LabelBucketGrid from '../components/LabelBucketGrid.vue'
|
||||
import UndoToast from '../components/UndoToast.vue'
|
||||
|
|
@ -88,6 +92,24 @@ const onRoll = ref(false)
|
|||
const speedRound = ref(false)
|
||||
const fiftyDeep = ref(false)
|
||||
|
||||
// New easter egg state
|
||||
const centuryMark = ref(false)
|
||||
const cleanSweep = ref(false)
|
||||
const midnightLabeler = ref(false)
|
||||
let midnightShownThisSession = false
|
||||
let trailCleanup: (() => void) | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
function syncCursorTrail() {
|
||||
const isHacker = document.documentElement.dataset.theme === 'hacker'
|
||||
if (isHacker && !trailCleanup) {
|
||||
trailCleanup = useCursorTrail()
|
||||
} else if (!isHacker && trailCleanup) {
|
||||
trailCleanup()
|
||||
trailCleanup = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBatch() {
|
||||
loading.value = true
|
||||
apiError.value = false
|
||||
|
|
@ -96,6 +118,12 @@ async function fetchBatch() {
|
|||
if (error || !data) { apiError.value = true; return }
|
||||
store.queue = data.items
|
||||
store.totalRemaining = data.total
|
||||
|
||||
// Clean sweep — queue exhausted in this batch
|
||||
if (data.total === 0 && data.items.length === 0 && store.sessionLabeled > 0) {
|
||||
cleanSweep.value = true
|
||||
setTimeout(() => { cleanSweep.value = false }, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
function checkSpeedRound(): boolean {
|
||||
|
|
@ -138,6 +166,27 @@ async function handleLabel(name: string) {
|
|||
setTimeout(() => { speedRound.value = false }, 2500)
|
||||
}
|
||||
|
||||
// Hired confetti
|
||||
if (name === 'hired') {
|
||||
fireConfetti()
|
||||
}
|
||||
|
||||
// Century mark
|
||||
if (store.sessionLabeled === 100) {
|
||||
centuryMark.value = true
|
||||
setTimeout(() => { centuryMark.value = false }, 4000)
|
||||
}
|
||||
|
||||
// Midnight labeler — once per session
|
||||
if (!midnightShownThisSession) {
|
||||
const h = new Date().getHours()
|
||||
if (h >= 0 && h < 3) {
|
||||
midnightShownThisSession = true
|
||||
midnightLabeler.value = true
|
||||
setTimeout(() => { midnightLabeler.value = false }, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
await useApiFetch('/api/label', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -207,6 +256,20 @@ onMounted(async () => {
|
|||
const { data } = await useApiFetch<any[]>('/api/config/labels')
|
||||
if (data) labels.value = data
|
||||
await fetchBatch()
|
||||
|
||||
// Cursor trail — activate immediately if already in hacker mode, then watch for changes
|
||||
syncCursorTrail()
|
||||
themeObserver = new MutationObserver(syncCursorTrail)
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
themeObserver?.disconnect()
|
||||
themeObserver = null
|
||||
if (trailCleanup) {
|
||||
trailCleanup()
|
||||
trailCleanup = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -253,9 +316,12 @@ onMounted(async () => {
|
|||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.badge-roll { background: #ff6b35; color: #fff; }
|
||||
.badge-speed { background: #7c3aed; color: #fff; }
|
||||
.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); }
|
||||
.badge-roll { background: #ff6b35; color: #fff; }
|
||||
.badge-speed { background: #7c3aed; color: #fff; }
|
||||
.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); }
|
||||
.badge-century { background: #ffd700; color: #1a2338; }
|
||||
.badge-sweep { background: var(--app-primary, #2A6080); color: #fff; }
|
||||
.badge-midnight { background: #1a1a2e; color: #7c9dcf; border: 1px solid #7c9dcf; }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Reference in a new issue