snipe/web/src/composables/useSnipeMode.ts
pyr0ball 7a704441a6 feat(snipe): Vue 3 frontend scaffold + Docker web service
- web/: Vue 3 + Vite + UnoCSS + Pinia, dark tactical theme (amber/#0d1117)
- AppNav, ListingCard, SearchView with filters/sort, composables
  (useSnipeMode, useKonamiCode, useMotion), Pinia search store
- Steal shimmer, auction countdown, Snipe Mode easter egg all native in Vue
- docker/web/: nginx + multi-stage Dockerfile (node build → nginx serve)
- compose.yml: api (8510) + web (8509) services
- Dockerfile CMD updated to uvicorn for upcoming FastAPI layer
- Clean build: 0 TS errors, 380 modules
2026-03-25 15:11:35 -07:00

82 lines
2.7 KiB
TypeScript

import { ref } from 'vue'
const LS_KEY = 'cf-snipe-mode'
const DATA_ATTR = 'snipeMode'
// Module-level ref so state is shared across all callers
const active = ref(false)
/**
* Snipe Mode easter egg — activated by Konami code.
*
* When active:
* - Sets data-snipe-mode="active" on <html> (triggers CSS theme override in theme.css)
* - Persists to localStorage
* - Plays a snipe sound via Web Audio API (if audioEnabled is true)
*
* Audio synthesis mirrors the Streamlit version:
* 1. High-frequency sine blip (targeting beep)
* 2. Lower resonant hit with decay (impact)
*/
export function useSnipeMode(audioEnabled = true) {
function _playSnipeSound() {
if (!audioEnabled) return
try {
const ctx = new AudioContext()
// Phase 1: targeting blip — short high sine
const blip = ctx.createOscillator()
const blipGain = ctx.createGain()
blip.type = 'sine'
blip.frequency.setValueAtTime(880, ctx.currentTime)
blip.frequency.linearRampToValueAtTime(1200, ctx.currentTime + 0.05)
blipGain.gain.setValueAtTime(0.25, ctx.currentTime)
blipGain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.08)
blip.connect(blipGain)
blipGain.connect(ctx.destination)
blip.start(ctx.currentTime)
blip.stop(ctx.currentTime + 0.08)
// Phase 2: resonant hit — lower freq with exponential decay
const hit = ctx.createOscillator()
const hitGain = ctx.createGain()
hit.type = 'sine'
hit.frequency.setValueAtTime(440, ctx.currentTime + 0.08)
hit.frequency.exponentialRampToValueAtTime(110, ctx.currentTime + 0.45)
hitGain.gain.setValueAtTime(0.4, ctx.currentTime + 0.08)
hitGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5)
hit.connect(hitGain)
hitGain.connect(ctx.destination)
hit.start(ctx.currentTime + 0.08)
hit.stop(ctx.currentTime + 0.5)
// Close context after sound finishes
setTimeout(() => ctx.close(), 600)
} catch {
// Web Audio API unavailable — silently skip
}
}
function activate() {
active.value = true
document.documentElement.dataset[DATA_ATTR] = 'active'
localStorage.setItem(LS_KEY, 'active')
_playSnipeSound()
}
function deactivate() {
active.value = false
delete document.documentElement.dataset[DATA_ATTR]
localStorage.removeItem(LS_KEY)
}
/** Re-apply from localStorage on hard reload (call from App.vue onMounted). */
function restore() {
if (localStorage.getItem(LS_KEY) === 'active') {
active.value = true
document.documentElement.dataset[DATA_ATTR] = 'active'
}
}
return { active, activate, deactivate, restore }
}