fuzzyworld

nuts / files / src / ui.ts

ui.ts

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