fuzzyworld

nuts / goo / files / src / ui.ts

Branch goo · default branch · all branches

ui.ts

13.5 KB · history · blame

1import { CONFIG } from './config'
2import { dbg } from './debug'
3import QRCode from 'qrcode'
4import { hasVibration } from './compat'
5import { setVibrateEnabled, isVibrateEnabled, setVibrateItems, isVibrateItems, setVibrateEdges, isVibrateEdges } from './vibration'
6
7export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
8export type GravityMode = 'device' | 'tumbler' | 'rock'
9
10const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball']
11const KIND_ICON: Record<ItemKind, string> = {
12 random: '?',
13 nut: '🌰',
14 gem: '💎',
15 balloon: '🎈',
16 metalnut: '🔩',
17 rock: '🪨',
18 stressball: '🟠',
19}
20
21export interface UICallbacks {
22 initialRadius: number
23 initialLocked: boolean
24 initialGravityMode: GravityMode
25 initialVibrateEnabled: boolean
26 initialVibrateItems: boolean
27 initialVibrateEdges: boolean
28 initialKind: ItemKind
29 initialRandomPool: ItemKind[]
30 onSizeChange: (radius: number) => void
31 onClear: () => void
32 onLockChange: (locked: boolean) => void
33 onKindChange: (kind: ItemKind) => void
34 onGravityModeChange: (mode: GravityMode) => void
35 onVibrateChange: (enabled: boolean) => void
36 onVibrateItemsChange: (enabled: boolean) => void
37 onVibrateEdgesChange: (enabled: boolean) => void
38 onRandomPoolChange: (pool: ItemKind[]) => void
39 onSettingsOpen: () => void
40 onSettingsClose: () => void
41 getShareURL: () => string
42}
43
44export function initUI(callbacks: UICallbacks): void {
45 const toolbar = document.getElementById('toolbar') as HTMLDivElement
46 const kindBtn = document.getElementById('kind-btn') as HTMLButtonElement
47 const sizeBtn = document.getElementById('size-btn') as HTMLButtonElement
48 const freezeBtn = document.getElementById('freeze-btn') as HTMLButtonElement
49 const gravityBtn = document.getElementById('gravity-btn') as HTMLButtonElement
50 const clearTopBtn = document.getElementById('clear-top-btn') as HTMLButtonElement
51 const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement
52 const sizePill = document.getElementById('size-pill') as HTMLDivElement
53 const nutSizeInput = document.getElementById('nut-size') as HTMLInputElement
54 const nutSizeDisplay = document.getElementById('nut-size-display') as HTMLSpanElement
55
56 const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement
57 const testVibrateBtn = document.getElementById('test-vibrate-btn') as HTMLButtonElement
58 const vibrateToggleBtn = document.getElementById('vibrate-toggle-btn') as HTMLButtonElement
59 const closeBtn = document.getElementById('close-settings') as HTMLButtonElement
60 const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement
61 const qrCanvas = document.getElementById('qr-canvas') as HTMLCanvasElement
62 const copyLinkBtn = document.getElementById('copy-link-btn') as HTMLButtonElement
63
64 // ── Size slider ──────────────────────────────────────────────────────────
65 nutSizeInput.min = String(CONFIG.nutMinRadius)
66 nutSizeInput.max = String(CONFIG.nutMaxRadius)
67 nutSizeInput.value = String(callbacks.initialRadius)
68 nutSizeDisplay.textContent = String(callbacks.initialRadius)
69
70 let sizePillOpen = false
71
72 sizeBtn.addEventListener('click', (e) => {
73 e.stopPropagation()
74 sizePillOpen = !sizePillOpen
75 sizePill.hidden = !sizePillOpen
76 sizeBtn.classList.toggle('active', sizePillOpen)
77 })
78
79 // Close pill when tapping outside (bubble phase is fine — we don't block propagation)
80 document.addEventListener('pointerdown', (e) => {
81 if (!sizePillOpen) return
82 if (!sizePill.contains(e.target as Node) && e.target !== sizeBtn) {
83 sizePillOpen = false
84 sizePill.hidden = true
85 sizeBtn.classList.remove('active')
86 }
87 })
88
89 nutSizeInput.addEventListener('input', () => {
90 const radius = parseInt(nutSizeInput.value, 10)
91 nutSizeDisplay.textContent = String(radius)
92 callbacks.onSizeChange(radius)
93 })
94
95 // ── Kind toggle ──────────────────────────────────────────────────────────
96 let kindIndex = KIND_SEQUENCE.indexOf(callbacks.initialKind)
97 if (kindIndex === -1) kindIndex = 0
98 kindBtn.textContent = KIND_ICON[KIND_SEQUENCE[kindIndex]]
99 kindBtn.className = `toolbar-btn kind-${KIND_SEQUENCE[kindIndex]}`
100
101 kindBtn.addEventListener('click', () => {
102 kindIndex = (kindIndex + 1) % KIND_SEQUENCE.length
103 const kind = KIND_SEQUENCE[kindIndex]
104 kindBtn.textContent = KIND_ICON[kind]
105 kindBtn.className = `toolbar-btn kind-${kind}`
106 callbacks.onKindChange(kind)
107 })
108
109 // ── Freeze ───────────────────────────────────────────────────────────────
110 let locked = callbacks.initialLocked
111 freezeBtn.classList.toggle('active', locked)
112 freezeBtn.textContent = locked ? '▶' : '⏸'
113
114 freezeBtn.addEventListener('click', () => {
115 locked = !locked
116 freezeBtn.classList.toggle('active', locked)
117 freezeBtn.textContent = locked ? '▶' : '⏸'
118 callbacks.onLockChange(locked)
119 })
120
121 // ── Clear ────────────────────────────────────────────────────────────────
122 clearTopBtn.addEventListener('click', () => {
123 callbacks.onClear()
124 })
125
126 // ── Gravity mode ─────────────────────────────────────────────────────────
127 const GRAVITY_SEQUENCE: GravityMode[] = ['device', 'tumbler', 'rock']
128 const GRAVITY_ICON: Record<GravityMode, string> = { device: '📱', tumbler: '🪨', rock: '🌀' }
129 let gravityModeIndex = GRAVITY_SEQUENCE.indexOf(callbacks.initialGravityMode)
130 if (gravityModeIndex < 0) gravityModeIndex = 0
131 const initMode = GRAVITY_SEQUENCE[gravityModeIndex]
132 gravityBtn.textContent = GRAVITY_ICON[initMode]
133 gravityBtn.classList.toggle('active', initMode !== 'device')
134
135 gravityBtn.addEventListener('click', () => {
136 gravityModeIndex = (gravityModeIndex + 1) % GRAVITY_SEQUENCE.length
137 const mode = GRAVITY_SEQUENCE[gravityModeIndex]
138 gravityBtn.textContent = GRAVITY_ICON[mode]
139 gravityBtn.classList.toggle('active', mode !== 'device')
140 callbacks.onGravityModeChange(mode)
141 })
142
143 // ── Settings panel ───────────────────────────────────────────────────────
144 toolbar.hidden = false
145
146 // Hide vibration controls on unsupported platforms
147 if (!hasVibration()) {
148 testVibrateBtn.hidden = true
149 vibrateToggleBtn.hidden = true
150 const noVibNote = document.createElement('p')
151 noVibNote.className = 'no-vibration-note'
152 noVibNote.textContent = '📳 Haptic feedback is available on Android and some other devices.'
153 vibrateToggleBtn.insertAdjacentElement('afterend', noVibNote)
154 } else {
155 // Restore persisted vibration state
156 setVibrateEnabled(callbacks.initialVibrateEnabled)
157 setVibrateItems(callbacks.initialVibrateItems)
158 setVibrateEdges(callbacks.initialVibrateEdges)
159 vibrateToggleBtn.textContent = callbacks.initialVibrateEnabled ? 'Vibration: On' : 'Vibration: Off'
160 vibrateToggleBtn.classList.toggle('active', callbacks.initialVibrateEnabled)
161 }
162
163 settingsBtn.addEventListener('click', () => {
164 settingsPanel.hidden = false
165 callbacks.onSettingsOpen()
166 refreshQR()
167 })
168
169 closeBtn.addEventListener('click', closePanel)
170 backdrop.addEventListener('click', closePanel)
171
172 testVibrateBtn.addEventListener('click', () => {
173 const ok = navigator.vibrate(300)
174 dbg('test vibrate 300ms ->', ok)
175 const orig = testVibrateBtn.textContent
176 testVibrateBtn.textContent = ok ? '✓ Sent' : '✗ Failed'
177 setTimeout(() => { testVibrateBtn.textContent = orig }, 1500)
178 })
179
180 vibrateToggleBtn.addEventListener('click', () => {
181 const on = !isVibrateEnabled()
182 setVibrateEnabled(on)
183 vibrateToggleBtn.textContent = on ? 'Vibration: On' : 'Vibration: Off'
184 vibrateToggleBtn.classList.toggle('active', on)
185 callbacks.onVibrateChange(on)
186 })
187
188 // ── Vibration sub-options (only shown when vibration available) ──────────
189 if (hasVibration()) {
190 const vibrateItemsBtn = document.createElement('button')
191 vibrateItemsBtn.className = 'action-btn' + (callbacks.initialVibrateItems ? ' active' : '')
192 vibrateItemsBtn.textContent = callbacks.initialVibrateItems ? 'Item hits: On' : 'Item hits: Off'
193 vibrateItemsBtn.addEventListener('click', () => {
194 const on = !isVibrateItems()
195 setVibrateItems(on)
196 vibrateItemsBtn.textContent = on ? 'Item hits: On' : 'Item hits: Off'
197 vibrateItemsBtn.classList.toggle('active', on)
198 callbacks.onVibrateItemsChange(on)
199 })
200
201 const vibrateEdgesBtn = document.createElement('button')
202 vibrateEdgesBtn.className = 'action-btn' + (callbacks.initialVibrateEdges ? ' active' : '')
203 vibrateEdgesBtn.textContent = callbacks.initialVibrateEdges ? 'Edge hits: On' : 'Edge hits: Off'
204 vibrateEdgesBtn.addEventListener('click', () => {
205 const on = !isVibrateEdges()
206 setVibrateEdges(on)
207 vibrateEdgesBtn.textContent = on ? 'Edge hits: On' : 'Edge hits: Off'
208 vibrateEdgesBtn.classList.toggle('active', on)
209 callbacks.onVibrateEdgesChange(on)
210 })
211
212 // Insert after vibrateToggleBtn
213 vibrateToggleBtn.insertAdjacentElement('afterend', vibrateEdgesBtn)
214 vibrateToggleBtn.insertAdjacentElement('afterend', vibrateItemsBtn)
215 }
216
217 // ── Random pool toggles ──────────────────────────────────────────────────
218 const RANDOM_KINDS: Array<{ kind: Exclude<ItemKind, 'random'>; label: string }> = [
219 { kind: 'nut', label: '🌰 Nuts' },
220 { kind: 'gem', label: '💎 Gems' },
221 { kind: 'balloon', label: '🎈 Balloons' },
222 { kind: 'metalnut', label: '🔩 Metal nuts' },
223 { kind: 'rock', label: '🪨 Rocks' },
224 { kind: 'stressball', label: '🟠 Stress balls' },
225 ]
226 let randomPool = new Set<ItemKind>(callbacks.initialRandomPool)
227
228 const poolSection = document.createElement('div')
229 poolSection.className = 'pool-section'
230 const poolLabel = document.createElement('p')
231 poolLabel.className = 'pool-label'
232 poolLabel.textContent = '? random includes'
233 poolSection.appendChild(poolLabel)
234 const poolGrid = document.createElement('div')
235 poolGrid.className = 'pool-grid'
236
237 for (const { kind, label } of RANDOM_KINDS) {
238 const btn = document.createElement('button')
239 btn.className = 'pool-btn' + (randomPool.has(kind) ? ' active' : '')
240 btn.textContent = label
241 btn.addEventListener('click', () => {
242 if (randomPool.has(kind)) {
243 if (randomPool.size <= 1) return // keep at least one
244 randomPool.delete(kind)
245 btn.classList.remove('active')
246 } else {
247 randomPool.add(kind)
248 btn.classList.add('active')
249 }
250 callbacks.onRandomPoolChange([...randomPool])
251 })
252 poolGrid.appendChild(btn)
253 }
254 poolSection.appendChild(poolGrid)
255
256 // Insert before the close button
257 const closeBtn2 = settingsPanel.querySelector('#close-settings') as HTMLButtonElement
258 closeBtn2.parentElement!.insertBefore(poolSection, closeBtn2)
259
260 copyLinkBtn.addEventListener('click', () => {
261 const url = callbacks.getShareURL()
262 const orig = copyLinkBtn.textContent
263 if (navigator.share) {
264 navigator.share({ title: 'My collection', url }).catch(() => { /* cancelled */ })
265 } else {
266 navigator.clipboard.writeText(url).then(() => {
267 copyLinkBtn.textContent = '✓ Copied!'
268 setTimeout(() => { copyLinkBtn.textContent = orig }, 1800)
269 }).catch(() => {
270 copyLinkBtn.textContent = '✗ Failed'
271 setTimeout(() => { copyLinkBtn.textContent = orig }, 1800)
272 })
273 }
274 })
275
276 async function refreshQR(): Promise<void> {
277 const url = callbacks.getShareURL()
278 try {
279 await QRCode.toCanvas(qrCanvas, url, {
280 width: 160,
281 margin: 1,
282 color: { dark: '#111111', light: '#ffffff' },
283 })
284 } catch { /* ignore */ }
285 }
286
287 function closePanel(): void {
288 settingsPanel.hidden = true
289 callbacks.onSettingsClose()
290 }
291}
292
293/** Remove the start overlay (call after user interaction / permission grant). */
294export function removeStartOverlay(): void {
295 const overlay = document.getElementById('start-overlay')
296 if (!overlay) return
297 overlay.style.pointerEvents = 'none'
298 overlay.style.transition = 'opacity 0.4s'
299 overlay.style.opacity = '0'
300 setTimeout(() => overlay.remove(), 420)
301}