fuzzyworld

nuts / goo / files / src / ui.ts

Branch goo · default branch · all branches

ui.ts

Blame: the commit that last changed each line · file · history

44eb5832 2026-04-12 Pavel Safronov1import { CONFIG } from './config'
f1262fd9 2026-04-12 Pavel Safronov2import { dbg } from './debug'
3import QRCode from 'qrcode'
4ea6baf7 2026-04-12 Pavel Safronov4import { hasVibration } from './compat'
98ba5504 2026-04-13 Pavel Safronov5import { setVibrateEnabled, isVibrateEnabled, setVibrateItems, isVibrateItems, setVibrateEdges, isVibrateEdges } from './vibration'
44eb5832 2026-04-12 Pavel Safronov6
cfe80f49 2026-04-13 Pavel Safronov7export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
937f9abc 2026-04-12 Pavel Safronov8export type GravityMode = 'device' | 'tumbler' | 'rock'
48f320c1 2026-04-12 Pavel Safronov9
cfe80f49 2026-04-13 Pavel Safronov10const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball']
48f320c1 2026-04-12 Pavel Safronov11const KIND_ICON: Record<ItemKind, string> = {
12 random: '?',
13 nut: '🌰',
14 gem: '💎',
15 balloon: '🎈',
a2b3b1f4 2026-04-12 Pavel Safronov16 metalnut: '🔩',
74d506c6 2026-04-12 Pavel Safronov17 rock: '🪨',
cfe80f49 2026-04-13 Pavel Safronov18 stressball: '🟠',
48f320c1 2026-04-12 Pavel Safronov19}
20
44eb5832 2026-04-12 Pavel Safronov21export interface UICallbacks {
f1262fd9 2026-04-12 Pavel Safronov22 initialRadius: number
23 initialLocked: boolean
937f9abc 2026-04-12 Pavel Safronov24 initialGravityMode: GravityMode
25 initialVibrateEnabled: boolean
98ba5504 2026-04-13 Pavel Safronov26 initialVibrateItems: boolean
27 initialVibrateEdges: boolean
74d506c6 2026-04-12 Pavel Safronov28 initialKind: ItemKind
98ba5504 2026-04-13 Pavel Safronov29 initialRandomPool: ItemKind[]
f1262fd9 2026-04-12 Pavel Safronov30 onSizeChange: (radius: number) => void
31 onClear: () => void
32 onLockChange: (locked: boolean) => void
48f320c1 2026-04-12 Pavel Safronov33 onKindChange: (kind: ItemKind) => void
937f9abc 2026-04-12 Pavel Safronov34 onGravityModeChange: (mode: GravityMode) => void
35 onVibrateChange: (enabled: boolean) => void
98ba5504 2026-04-13 Pavel Safronov36 onVibrateItemsChange: (enabled: boolean) => void
37 onVibrateEdgesChange: (enabled: boolean) => void
38 onRandomPoolChange: (pool: ItemKind[]) => void
39 onSettingsOpen: () => void
40 onSettingsClose: () => void
f1262fd9 2026-04-12 Pavel Safronov41 getShareURL: () => string
44eb5832 2026-04-12 Pavel Safronov42}
43
44export function initUI(callbacks: UICallbacks): void {
48f320c1 2026-04-12 Pavel Safronov45 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
937f9abc 2026-04-12 Pavel Safronov49 const gravityBtn = document.getElementById('gravity-btn') as HTMLButtonElement
48f320c1 2026-04-12 Pavel Safronov50 const clearTopBtn = document.getElementById('clear-top-btn') as HTMLButtonElement
44eb5832 2026-04-12 Pavel Safronov51 const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement
48f320c1 2026-04-12 Pavel Safronov52 const sizePill = document.getElementById('size-pill') as HTMLDivElement
f1262fd9 2026-04-12 Pavel Safronov53 const nutSizeInput = document.getElementById('nut-size') as HTMLInputElement
54 const nutSizeDisplay = document.getElementById('nut-size-display') as HTMLSpanElement
48f320c1 2026-04-12 Pavel Safronov55
56 const settingsPanel = document.getElementById('settings-panel') as HTMLDivElement
f1262fd9 2026-04-12 Pavel Safronov57 const testVibrateBtn = document.getElementById('test-vibrate-btn') as HTMLButtonElement
4ea6baf7 2026-04-12 Pavel Safronov58 const vibrateToggleBtn = document.getElementById('vibrate-toggle-btn') as HTMLButtonElement
44eb5832 2026-04-12 Pavel Safronov59 const closeBtn = document.getElementById('close-settings') as HTMLButtonElement
60 const backdrop = settingsPanel.querySelector('.panel-backdrop') as HTMLDivElement
f1262fd9 2026-04-12 Pavel Safronov61 const qrCanvas = document.getElementById('qr-canvas') as HTMLCanvasElement
62 const copyLinkBtn = document.getElementById('copy-link-btn') as HTMLButtonElement
44eb5832 2026-04-12 Pavel Safronov63
48f320c1 2026-04-12 Pavel Safronov64 // ── Size slider ──────────────────────────────────────────────────────────
f1262fd9 2026-04-12 Pavel Safronov65 nutSizeInput.min = String(CONFIG.nutMinRadius)
66 nutSizeInput.max = String(CONFIG.nutMaxRadius)
67 nutSizeInput.value = String(callbacks.initialRadius)
68 nutSizeDisplay.textContent = String(callbacks.initialRadius)
69
48f320c1 2026-04-12 Pavel Safronov70 let sizePillOpen = false
44eb5832 2026-04-12 Pavel Safronov71
48f320c1 2026-04-12 Pavel Safronov72 sizeBtn.addEventListener('click', (e) => {
73 e.stopPropagation()
74 sizePillOpen = !sizePillOpen
75 sizePill.hidden = !sizePillOpen
76 sizeBtn.classList.toggle('active', sizePillOpen)
44eb5832 2026-04-12 Pavel Safronov77 })
78
48f320c1 2026-04-12 Pavel Safronov79 // 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 })
44eb5832 2026-04-12 Pavel Safronov88
f1262fd9 2026-04-12 Pavel Safronov89 nutSizeInput.addEventListener('input', () => {
90 const radius = parseInt(nutSizeInput.value, 10)
91 nutSizeDisplay.textContent = String(radius)
92 callbacks.onSizeChange(radius)
44eb5832 2026-04-12 Pavel Safronov93 })
94
48f320c1 2026-04-12 Pavel Safronov95 // ── Kind toggle ──────────────────────────────────────────────────────────
74d506c6 2026-04-12 Pavel Safronov96 let kindIndex = KIND_SEQUENCE.indexOf(callbacks.initialKind)
97 if (kindIndex === -1) kindIndex = 0
48f320c1 2026-04-12 Pavel Safronov98 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', () => {
f1262fd9 2026-04-12 Pavel Safronov115 locked = !locked
48f320c1 2026-04-12 Pavel Safronov116 freezeBtn.classList.toggle('active', locked)
117 freezeBtn.textContent = locked ? '▶' : '⏸'
f1262fd9 2026-04-12 Pavel Safronov118 callbacks.onLockChange(locked)
44eb5832 2026-04-12 Pavel Safronov119 })
120
48f320c1 2026-04-12 Pavel Safronov121 // ── Clear ────────────────────────────────────────────────────────────────
122 clearTopBtn.addEventListener('click', () => {
f1262fd9 2026-04-12 Pavel Safronov123 callbacks.onClear()
124 })
125
937f9abc 2026-04-12 Pavel Safronov126 // ── 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
48f320c1 2026-04-12 Pavel Safronov143 // ── Settings panel ───────────────────────────────────────────────────────
144 toolbar.hidden = false
145
4ea6baf7 2026-04-12 Pavel Safronov146 // Hide vibration controls on unsupported platforms
147 if (!hasVibration()) {
148 testVibrateBtn.hidden = true
149 vibrateToggleBtn.hidden = true
98ba5504 2026-04-13 Pavel Safronov150 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)
937f9abc 2026-04-12 Pavel Safronov154 } else {
155 // Restore persisted vibration state
156 setVibrateEnabled(callbacks.initialVibrateEnabled)
98ba5504 2026-04-13 Pavel Safronov157 setVibrateItems(callbacks.initialVibrateItems)
158 setVibrateEdges(callbacks.initialVibrateEdges)
937f9abc 2026-04-12 Pavel Safronov159 vibrateToggleBtn.textContent = callbacks.initialVibrateEnabled ? 'Vibration: On' : 'Vibration: Off'
160 vibrateToggleBtn.classList.toggle('active', callbacks.initialVibrateEnabled)
4ea6baf7 2026-04-12 Pavel Safronov161 }
162
48f320c1 2026-04-12 Pavel Safronov163 settingsBtn.addEventListener('click', () => {
164 settingsPanel.hidden = false
98ba5504 2026-04-13 Pavel Safronov165 callbacks.onSettingsOpen()
48f320c1 2026-04-12 Pavel Safronov166 refreshQR()
167 })
168
169 closeBtn.addEventListener('click', closePanel)
170 backdrop.addEventListener('click', closePanel)
171
f1262fd9 2026-04-12 Pavel Safronov172 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
4ea6baf7 2026-04-12 Pavel Safronov180 vibrateToggleBtn.addEventListener('click', () => {
181 const on = !isVibrateEnabled()
182 setVibrateEnabled(on)
183 vibrateToggleBtn.textContent = on ? 'Vibration: On' : 'Vibration: Off'
184 vibrateToggleBtn.classList.toggle('active', on)
937f9abc 2026-04-12 Pavel Safronov185 callbacks.onVibrateChange(on)
4ea6baf7 2026-04-12 Pavel Safronov186 })
187
98ba5504 2026-04-13 Pavel Safronov188 // ── 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' },
cfe80f49 2026-04-13 Pavel Safronov224 { kind: 'stressball', label: '🟠 Stress balls' },
98ba5504 2026-04-13 Pavel Safronov225 ]
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
f1262fd9 2026-04-12 Pavel Safronov260 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
44eb5832 2026-04-12 Pavel Safronov287 function closePanel(): void {
288 settingsPanel.hidden = true
98ba5504 2026-04-13 Pavel Safronov289 callbacks.onSettingsClose()
44eb5832 2026-04-12 Pavel Safronov290 }
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
48f320c1 2026-04-12 Pavel Safronov297 overlay.style.pointerEvents = 'none'
44eb5832 2026-04-12 Pavel Safronov298 overlay.style.transition = 'opacity 0.4s'
299 overlay.style.opacity = '0'
300 setTimeout(() => overlay.remove(), 420)
301}