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 }
|
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">
|
<template v-if="store.totalRemaining > 0">
|
||||||
{{ store.totalRemaining }} remaining
|
{{ store.totalRemaining }} remaining
|
||||||
</template>
|
</template>
|
||||||
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</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="speedRound" class="badge badge-speed">⚡ Speed round!</span>
|
||||||
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</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>
|
</span>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button @click="handleUndo" :disabled="!store.lastAction" class="btn-action">↩ Undo</button>
|
<button @click="handleUndo" :disabled="!store.lastAction" class="btn-action">↩ Undo</button>
|
||||||
|
|
@ -61,12 +64,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useLabelStore } from '../stores/label'
|
import { useLabelStore } from '../stores/label'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
import { useHaptics } from '../composables/useHaptics'
|
import { useHaptics } from '../composables/useHaptics'
|
||||||
import { useMotion } from '../composables/useMotion'
|
import { useMotion } from '../composables/useMotion'
|
||||||
import { useLabelKeyboard } from '../composables/useLabelKeyboard'
|
import { useLabelKeyboard } from '../composables/useLabelKeyboard'
|
||||||
|
import { fireConfetti, useCursorTrail } from '../composables/useEasterEgg'
|
||||||
import EmailCardStack from '../components/EmailCardStack.vue'
|
import EmailCardStack from '../components/EmailCardStack.vue'
|
||||||
import LabelBucketGrid from '../components/LabelBucketGrid.vue'
|
import LabelBucketGrid from '../components/LabelBucketGrid.vue'
|
||||||
import UndoToast from '../components/UndoToast.vue'
|
import UndoToast from '../components/UndoToast.vue'
|
||||||
|
|
@ -88,6 +92,24 @@ const onRoll = ref(false)
|
||||||
const speedRound = ref(false)
|
const speedRound = ref(false)
|
||||||
const fiftyDeep = 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() {
|
async function fetchBatch() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
apiError.value = false
|
apiError.value = false
|
||||||
|
|
@ -96,6 +118,12 @@ async function fetchBatch() {
|
||||||
if (error || !data) { apiError.value = true; return }
|
if (error || !data) { apiError.value = true; return }
|
||||||
store.queue = data.items
|
store.queue = data.items
|
||||||
store.totalRemaining = data.total
|
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 {
|
function checkSpeedRound(): boolean {
|
||||||
|
|
@ -138,6 +166,27 @@ async function handleLabel(name: string) {
|
||||||
setTimeout(() => { speedRound.value = false }, 2500)
|
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', {
|
await useApiFetch('/api/label', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -207,6 +256,20 @@ onMounted(async () => {
|
||||||
const { data } = await useApiFetch<any[]>('/api/config/labels')
|
const { data } = await useApiFetch<any[]>('/api/config/labels')
|
||||||
if (data) labels.value = data
|
if (data) labels.value = data
|
||||||
await fetchBatch()
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -253,9 +316,12 @@ onMounted(async () => {
|
||||||
to { transform: scale(1); opacity: 1; }
|
to { transform: scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-roll { background: #ff6b35; color: #fff; }
|
.badge-roll { background: #ff6b35; color: #fff; }
|
||||||
.badge-speed { background: #7c3aed; color: #fff; }
|
.badge-speed { background: #7c3aed; color: #fff; }
|
||||||
.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); }
|
.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 {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue