SettingsPage.tsx 88 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153
  1. import { useState, useEffect } from 'react'
  2. import { useSearchParams } from 'react-router-dom'
  3. import { toast } from 'sonner'
  4. import { apiClient } from '@/lib/apiClient'
  5. import { useOnBackendConnected } from '@/hooks/useBackendConnection'
  6. import { Button } from '@/components/ui/button'
  7. import { Input } from '@/components/ui/input'
  8. import { Label } from '@/components/ui/label'
  9. import { Separator } from '@/components/ui/separator'
  10. import { Switch } from '@/components/ui/switch'
  11. import { Alert, AlertDescription } from '@/components/ui/alert'
  12. import {
  13. Accordion,
  14. AccordionContent,
  15. AccordionItem,
  16. AccordionTrigger,
  17. } from '@/components/ui/accordion'
  18. import {
  19. Select,
  20. SelectContent,
  21. SelectGroup,
  22. SelectItem,
  23. SelectLabel,
  24. SelectTrigger,
  25. SelectValue,
  26. } from '@/components/ui/select'
  27. import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
  28. import { SearchableSelect } from '@/components/ui/searchable-select'
  29. // Types
  30. interface Settings {
  31. app_name?: string
  32. custom_logo?: string
  33. preferred_port?: string
  34. // Machine settings
  35. table_type_override?: string
  36. detected_table_type?: string
  37. effective_table_type?: string
  38. gear_ratio?: number
  39. x_steps_per_mm?: number
  40. y_steps_per_mm?: number
  41. available_table_types?: { value: string; label: string }[]
  42. // Homing settings
  43. homing_mode?: number
  44. angular_offset?: number
  45. auto_home_enabled?: boolean
  46. auto_home_after_patterns?: number
  47. // Pattern clearing settings
  48. clear_pattern_speed?: number
  49. custom_clear_from_in?: string
  50. custom_clear_from_out?: string
  51. }
  52. interface TimeSlot {
  53. start_time: string
  54. end_time: string
  55. days: 'daily' | 'weekdays' | 'weekends' | 'custom'
  56. custom_days?: string[]
  57. }
  58. interface StillSandsSettings {
  59. enabled: boolean
  60. finish_pattern: boolean
  61. control_wled: boolean
  62. timezone: string
  63. time_slots: TimeSlot[]
  64. }
  65. interface AutoPlaySettings {
  66. enabled: boolean
  67. playlist: string
  68. run_mode: 'single' | 'loop'
  69. pause_time: number
  70. clear_pattern: string
  71. shuffle: boolean
  72. }
  73. interface LedConfig {
  74. provider: 'none' | 'wled' | 'dw_leds'
  75. wled_ip?: string
  76. num_leds?: number
  77. gpio_pin?: number
  78. pixel_order?: string
  79. }
  80. interface MqttConfig {
  81. enabled: boolean
  82. broker?: string
  83. port?: number
  84. username?: string
  85. password?: string
  86. device_name?: string
  87. device_id?: string
  88. client_id?: string
  89. discovery_prefix?: string
  90. }
  91. export function SettingsPage() {
  92. const [searchParams, setSearchParams] = useSearchParams()
  93. const sectionParam = searchParams.get('section')
  94. // Connection state
  95. const [ports, setPorts] = useState<string[]>([])
  96. const [selectedPort, setSelectedPort] = useState('')
  97. const [isConnected, setIsConnected] = useState(false)
  98. const [connectionStatus, setConnectionStatus] = useState('Disconnected')
  99. // Settings state
  100. const [settings, setSettings] = useState<Settings>({})
  101. const [ledConfig, setLedConfig] = useState<LedConfig>({ provider: 'none', gpio_pin: 18 })
  102. const [numLedsInput, setNumLedsInput] = useState('60')
  103. const [mqttConfig, setMqttConfig] = useState<MqttConfig>({ enabled: false })
  104. // UI state
  105. const [isLoading, setIsLoading] = useState<string | null>(null)
  106. // Accordion state - controlled by URL params
  107. const [openSections, setOpenSections] = useState<string[]>(() => {
  108. if (sectionParam) return [sectionParam]
  109. return ['connection']
  110. })
  111. // Track which sections have been loaded (for lazy loading)
  112. const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set())
  113. // Auto-play state
  114. const [autoPlaySettings, setAutoPlaySettings] = useState<AutoPlaySettings>({
  115. enabled: false,
  116. playlist: '',
  117. run_mode: 'loop',
  118. pause_time: 5,
  119. clear_pattern: 'adaptive',
  120. shuffle: false,
  121. })
  122. const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
  123. const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
  124. const [autoPlayPauseInput, setAutoPlayPauseInput] = useState('5')
  125. const [playlists, setPlaylists] = useState<string[]>([])
  126. // Convert pause time from seconds to value + unit for display
  127. const secondsToDisplayPause = (seconds: number): { value: number; unit: 'sec' | 'min' | 'hr' } => {
  128. if (seconds >= 3600 && seconds % 3600 === 0) {
  129. return { value: seconds / 3600, unit: 'hr' }
  130. } else if (seconds >= 60 && seconds % 60 === 0) {
  131. return { value: seconds / 60, unit: 'min' }
  132. }
  133. return { value: seconds, unit: 'sec' }
  134. }
  135. // Convert display value + unit to seconds
  136. const displayPauseToSeconds = (value: number, unit: 'sec' | 'min' | 'hr'): number => {
  137. switch (unit) {
  138. case 'hr': return value * 3600
  139. case 'min': return value * 60
  140. default: return value
  141. }
  142. }
  143. // Still Sands state
  144. const [stillSandsSettings, setStillSandsSettings] = useState<StillSandsSettings>({
  145. enabled: false,
  146. finish_pattern: false,
  147. control_wled: false,
  148. timezone: '',
  149. time_slots: [],
  150. })
  151. // Pattern search state for clearing patterns
  152. const [patternFiles, setPatternFiles] = useState<string[]>([])
  153. // Version state
  154. const [versionInfo, setVersionInfo] = useState<{
  155. current: string
  156. latest: string
  157. update_available: boolean
  158. } | null>(null)
  159. // Helper to scroll to element with header offset
  160. const scrollToSection = (sectionId: string) => {
  161. const element = document.getElementById(`section-${sectionId}`)
  162. if (element) {
  163. const headerHeight = 80 // Header height + some padding
  164. const elementTop = element.getBoundingClientRect().top + window.scrollY
  165. window.scrollTo({ top: elementTop - headerHeight, behavior: 'smooth' })
  166. }
  167. }
  168. // Scroll to section and clear URL param after navigation
  169. useEffect(() => {
  170. if (sectionParam) {
  171. // Scroll to the section after a short delay to allow render
  172. setTimeout(() => {
  173. scrollToSection(sectionParam)
  174. // Clear the search param from URL
  175. setSearchParams({}, { replace: true })
  176. }, 100)
  177. }
  178. }, [sectionParam, setSearchParams])
  179. // Load section data when expanded (lazy loading)
  180. const loadSectionData = async (section: string) => {
  181. if (loadedSections.has(section)) return
  182. setLoadedSections((prev) => new Set(prev).add(section))
  183. switch (section) {
  184. case 'connection':
  185. await fetchPorts()
  186. // Also load settings for preferred port
  187. if (!loadedSections.has('_settings')) {
  188. setLoadedSections((prev) => new Set(prev).add('_settings'))
  189. await fetchSettings()
  190. }
  191. break
  192. case 'application':
  193. case 'mqtt':
  194. case 'autoplay':
  195. case 'stillsands':
  196. case 'machine':
  197. case 'homing':
  198. case 'clearing':
  199. // These all share settings data
  200. if (!loadedSections.has('_settings')) {
  201. setLoadedSections((prev) => new Set(prev).add('_settings'))
  202. await fetchSettings()
  203. }
  204. if ((section === 'autoplay' || section === 'clearing') && !loadedSections.has('_playlists')) {
  205. setLoadedSections((prev) => new Set(prev).add('_playlists'))
  206. await fetchPlaylists()
  207. }
  208. if (section === 'clearing' && !loadedSections.has('_patterns')) {
  209. setLoadedSections((prev) => new Set(prev).add('_patterns'))
  210. await fetchPatternFiles()
  211. }
  212. break
  213. case 'led':
  214. await fetchLedConfig()
  215. break
  216. case 'version':
  217. await fetchVersionInfo()
  218. break
  219. }
  220. }
  221. const fetchPatternFiles = async () => {
  222. try {
  223. const data = await apiClient.get<string[]>('/list_theta_rho_files')
  224. // Response is a flat array of file paths
  225. setPatternFiles(Array.isArray(data) ? data : [])
  226. } catch (error) {
  227. console.error('Error fetching pattern files:', error)
  228. }
  229. }
  230. const fetchVersionInfo = async () => {
  231. try {
  232. const data = await apiClient.get<{ current: string; latest: string; update_available: boolean }>('/api/version')
  233. setVersionInfo(data)
  234. } catch (error) {
  235. console.error('Failed to fetch version info:', error)
  236. }
  237. }
  238. // Handle accordion open/close and trigger data loading
  239. const handleAccordionChange = (values: string[]) => {
  240. // Find newly opened section
  241. const newlyOpened = values.find((v) => !openSections.includes(v))
  242. setOpenSections(values)
  243. // Load data for newly opened sections
  244. values.forEach((section) => {
  245. if (!loadedSections.has(section)) {
  246. loadSectionData(section)
  247. }
  248. })
  249. // Scroll newly opened section into view
  250. if (newlyOpened) {
  251. setTimeout(() => {
  252. scrollToSection(newlyOpened)
  253. }, 100)
  254. }
  255. }
  256. // Load initial section data
  257. useEffect(() => {
  258. openSections.forEach((section) => {
  259. loadSectionData(section)
  260. })
  261. }, [])
  262. const fetchPorts = async () => {
  263. try {
  264. // Fetch available ports
  265. const portsData = await apiClient.get<string[]>('/list_serial_ports')
  266. setPorts(portsData || [])
  267. // Fetch connection status
  268. const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
  269. setIsConnected(statusData.connected || false)
  270. setConnectionStatus(statusData.connected ? 'Connected' : 'Disconnected')
  271. if (statusData.port) {
  272. setSelectedPort(statusData.port)
  273. }
  274. } catch (error) {
  275. console.error('Error fetching ports:', error)
  276. }
  277. }
  278. // Always fetch ports on mount since connection is the default section
  279. useEffect(() => {
  280. fetchPorts()
  281. }, [])
  282. // Refetch when backend reconnects
  283. useOnBackendConnected(() => {
  284. fetchPorts()
  285. })
  286. const fetchSettings = async () => {
  287. try {
  288. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  289. const data = await apiClient.get<Record<string, any>>('/api/settings')
  290. // Map the nested API response to our flat Settings interface
  291. setSettings({
  292. app_name: data.app?.name,
  293. custom_logo: data.app?.custom_logo,
  294. preferred_port: data.connection?.preferred_port,
  295. // Machine settings
  296. table_type_override: data.machine?.table_type_override,
  297. detected_table_type: data.machine?.detected_table_type,
  298. effective_table_type: data.machine?.effective_table_type,
  299. gear_ratio: data.machine?.gear_ratio,
  300. x_steps_per_mm: data.machine?.x_steps_per_mm,
  301. y_steps_per_mm: data.machine?.y_steps_per_mm,
  302. available_table_types: data.machine?.available_table_types,
  303. // Homing settings
  304. homing_mode: data.homing?.mode,
  305. angular_offset: data.homing?.angular_offset_degrees,
  306. auto_home_enabled: data.homing?.auto_home_enabled,
  307. auto_home_after_patterns: data.homing?.auto_home_after_patterns,
  308. // Pattern clearing settings
  309. clear_pattern_speed: data.patterns?.clear_pattern_speed,
  310. custom_clear_from_in: data.patterns?.custom_clear_from_in,
  311. custom_clear_from_out: data.patterns?.custom_clear_from_out,
  312. })
  313. // Set auto-play settings
  314. if (data.auto_play) {
  315. const pauseSeconds = data.auto_play.pause_time ?? 300 // Default 5 minutes
  316. const { value, unit } = secondsToDisplayPause(pauseSeconds)
  317. setAutoPlayPauseValue(value)
  318. setAutoPlayPauseInput(String(value))
  319. setAutoPlayPauseUnit(unit)
  320. setAutoPlaySettings({
  321. enabled: data.auto_play.enabled || false,
  322. playlist: data.auto_play.playlist || '',
  323. run_mode: data.auto_play.run_mode || 'loop',
  324. pause_time: pauseSeconds,
  325. clear_pattern: data.auto_play.clear_pattern || 'adaptive',
  326. shuffle: data.auto_play.shuffle || false,
  327. })
  328. }
  329. // Set still sands settings
  330. if (data.scheduled_pause) {
  331. setStillSandsSettings({
  332. enabled: data.scheduled_pause.enabled || false,
  333. finish_pattern: data.scheduled_pause.finish_pattern || false,
  334. control_wled: data.scheduled_pause.control_wled || false,
  335. timezone: data.scheduled_pause.timezone || '',
  336. time_slots: data.scheduled_pause.time_slots || [],
  337. })
  338. }
  339. // Set MQTT config from the same response
  340. if (data.mqtt) {
  341. setMqttConfig({
  342. enabled: data.mqtt.enabled || false,
  343. broker: data.mqtt.broker,
  344. port: data.mqtt.port,
  345. username: data.mqtt.username,
  346. device_name: data.mqtt.device_name,
  347. device_id: data.mqtt.device_id,
  348. client_id: data.mqtt.client_id,
  349. discovery_prefix: data.mqtt.discovery_prefix,
  350. })
  351. }
  352. } catch (error) {
  353. console.error('Error fetching settings:', error)
  354. }
  355. }
  356. const fetchLedConfig = async () => {
  357. try {
  358. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  359. const data = await apiClient.get<Record<string, any>>('/get_led_config')
  360. setLedConfig({
  361. provider: data.provider || 'none',
  362. wled_ip: data.wled_ip,
  363. num_leds: data.dw_led_num_leds,
  364. gpio_pin: data.dw_led_gpio_pin,
  365. pixel_order: data.dw_led_pixel_order,
  366. })
  367. setNumLedsInput(String(data.dw_led_num_leds || 60))
  368. } catch (error) {
  369. console.error('Error fetching LED config:', error)
  370. }
  371. }
  372. const fetchPlaylists = async () => {
  373. try {
  374. const data = await apiClient.get('/list_all_playlists')
  375. // Backend returns array directly, not { playlists: [...] }
  376. setPlaylists(Array.isArray(data) ? data : [])
  377. } catch (error) {
  378. console.error('Error fetching playlists:', error)
  379. }
  380. }
  381. const handleConnect = async () => {
  382. if (!selectedPort) {
  383. toast.error('Please select a port')
  384. return
  385. }
  386. setIsLoading('connect')
  387. try {
  388. const data = await apiClient.post<{ success?: boolean; message?: string }>('/connect', { port: selectedPort })
  389. if (data.success) {
  390. setIsConnected(true)
  391. setConnectionStatus(`Connected to ${selectedPort}`)
  392. toast.success('Connected successfully')
  393. } else {
  394. throw new Error(data.message || 'Connection failed')
  395. }
  396. } catch (error) {
  397. toast.error('Failed to connect')
  398. } finally {
  399. setIsLoading(null)
  400. }
  401. }
  402. const handleDisconnect = async () => {
  403. setIsLoading('disconnect')
  404. try {
  405. const data = await apiClient.post<{ success?: boolean }>('/disconnect')
  406. if (data.success) {
  407. setIsConnected(false)
  408. setConnectionStatus('Disconnected')
  409. toast.success('Disconnected')
  410. }
  411. } catch (error) {
  412. toast.error('Failed to disconnect')
  413. } finally {
  414. setIsLoading(null)
  415. }
  416. }
  417. const handleSavePreferredPort = async () => {
  418. setIsLoading('preferredPort')
  419. try {
  420. // Send the actual value: __auto__, __none__, or specific port
  421. const portValue = settings.preferred_port || '__auto__'
  422. await apiClient.patch('/api/settings', {
  423. connection: { preferred_port: portValue },
  424. })
  425. if (!settings.preferred_port || settings.preferred_port === '__auto__') {
  426. toast.success('Auto-connect: Auto (first available port)')
  427. } else if (settings.preferred_port === '__none__') {
  428. toast.success('Auto-connect: Disabled')
  429. } else {
  430. toast.success(`Auto-connect: ${settings.preferred_port}`)
  431. }
  432. } catch (error) {
  433. toast.error('Failed to save auto-connect setting')
  434. } finally {
  435. setIsLoading(null)
  436. }
  437. }
  438. const handleSaveAppName = async () => {
  439. setIsLoading('appName')
  440. try {
  441. await apiClient.patch('/api/settings', { app: { name: settings.app_name } })
  442. toast.success('App name saved. Refresh to see changes.')
  443. } catch (error) {
  444. toast.error('Failed to save app name')
  445. } finally {
  446. setIsLoading(null)
  447. }
  448. }
  449. // Update favicon links in the document head and notify Layout to refresh
  450. const updateBranding = (customLogo: string | null) => {
  451. const timestamp = Date.now() // Cache buster
  452. // Update favicon links (use apiClient.getAssetUrl for multi-table support)
  453. const faviconIco = document.getElementById('favicon-ico') as HTMLLinkElement
  454. const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
  455. if (customLogo) {
  456. if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/custom/favicon.ico?v=${timestamp}`)
  457. if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/custom/${customLogo}?v=${timestamp}`)
  458. } else {
  459. if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/favicon.ico?v=${timestamp}`)
  460. if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/apple-touch-icon.png?v=${timestamp}`)
  461. }
  462. // Dispatch event for Layout to update header logo
  463. window.dispatchEvent(new CustomEvent('branding-updated'))
  464. }
  465. const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  466. const file = e.target.files?.[0]
  467. if (!file) return
  468. setIsLoading('logo')
  469. try {
  470. const data = await apiClient.uploadFile('/api/upload-logo', file, 'file') as { filename: string }
  471. setSettings({ ...settings, custom_logo: data.filename })
  472. updateBranding(data.filename)
  473. toast.success('Logo uploaded!')
  474. } catch (error) {
  475. toast.error(error instanceof Error ? error.message : 'Failed to upload logo')
  476. } finally {
  477. setIsLoading(null)
  478. // Reset the input
  479. e.target.value = ''
  480. }
  481. }
  482. const handleDeleteLogo = async () => {
  483. if (!confirm('Remove custom logo and revert to default?')) return
  484. setIsLoading('logo')
  485. try {
  486. await apiClient.delete('/api/custom-logo')
  487. setSettings({ ...settings, custom_logo: undefined })
  488. updateBranding(null)
  489. toast.success('Logo removed!')
  490. } catch (error) {
  491. toast.error('Failed to remove logo')
  492. } finally {
  493. setIsLoading(null)
  494. }
  495. }
  496. const handleSaveLedConfig = async () => {
  497. setIsLoading('led')
  498. try {
  499. // Use the /set_led_config endpoint (deprecated but still works)
  500. await apiClient.post('/set_led_config', {
  501. provider: ledConfig.provider,
  502. ip_address: ledConfig.wled_ip,
  503. num_leds: ledConfig.num_leds,
  504. gpio_pin: ledConfig.gpio_pin,
  505. pixel_order: ledConfig.pixel_order,
  506. })
  507. toast.success('LED configuration saved')
  508. } catch (error) {
  509. toast.error(error instanceof Error ? error.message : 'Failed to save LED config')
  510. } finally {
  511. setIsLoading(null)
  512. }
  513. }
  514. const handleSaveMqttConfig = async () => {
  515. setIsLoading('mqtt')
  516. try {
  517. await apiClient.patch('/api/settings', {
  518. mqtt: {
  519. enabled: mqttConfig.enabled,
  520. broker: mqttConfig.broker,
  521. port: mqttConfig.port,
  522. username: mqttConfig.username,
  523. password: mqttConfig.password,
  524. device_name: mqttConfig.device_name,
  525. device_id: mqttConfig.device_id,
  526. client_id: mqttConfig.client_id,
  527. discovery_prefix: mqttConfig.discovery_prefix,
  528. },
  529. })
  530. toast.success('MQTT configuration saved. Restart required.')
  531. } catch (error) {
  532. toast.error('Failed to save MQTT config')
  533. } finally {
  534. setIsLoading(null)
  535. }
  536. }
  537. const handleTestMqttConnection = async () => {
  538. if (!mqttConfig.broker) {
  539. toast.error('Please enter a broker address')
  540. return
  541. }
  542. setIsLoading('mqttTest')
  543. try {
  544. const data = await apiClient.post<{ success?: boolean; error?: string }>('/api/mqtt-test', {
  545. broker: mqttConfig.broker,
  546. port: mqttConfig.port || 1883,
  547. username: mqttConfig.username || '',
  548. password: mqttConfig.password || '',
  549. })
  550. if (data.success) {
  551. toast.success('MQTT connection successful!')
  552. } else {
  553. toast.error(data.error || 'Connection failed')
  554. }
  555. } catch (error) {
  556. toast.error('Failed to test MQTT connection')
  557. } finally {
  558. setIsLoading(null)
  559. }
  560. }
  561. const handleSaveMachineSettings = async () => {
  562. setIsLoading('machine')
  563. try {
  564. await apiClient.patch('/api/settings', {
  565. machine: {
  566. table_type_override: settings.table_type_override || '',
  567. },
  568. })
  569. toast.success('Machine settings saved')
  570. } catch (error) {
  571. toast.error('Failed to save machine settings')
  572. } finally {
  573. setIsLoading(null)
  574. }
  575. }
  576. const handleSaveHomingConfig = async () => {
  577. setIsLoading('homing')
  578. try {
  579. await apiClient.patch('/api/settings', {
  580. homing: {
  581. mode: settings.homing_mode,
  582. angular_offset_degrees: settings.angular_offset,
  583. auto_home_enabled: settings.auto_home_enabled,
  584. auto_home_after_patterns: settings.auto_home_after_patterns,
  585. },
  586. })
  587. toast.success('Homing configuration saved')
  588. } catch (error) {
  589. toast.error('Failed to save homing configuration')
  590. } finally {
  591. setIsLoading(null)
  592. }
  593. }
  594. const handleSaveClearingSettings = async () => {
  595. setIsLoading('clearing')
  596. try {
  597. await apiClient.patch('/api/settings', {
  598. patterns: {
  599. // Send 0 to indicate "reset to default" - backend interprets 0 or negative as None
  600. clear_pattern_speed: settings.clear_pattern_speed ?? 0,
  601. custom_clear_from_in: settings.custom_clear_from_in || null,
  602. custom_clear_from_out: settings.custom_clear_from_out || null,
  603. },
  604. })
  605. toast.success('Clearing settings saved')
  606. } catch (error) {
  607. toast.error('Failed to save clearing settings')
  608. } finally {
  609. setIsLoading(null)
  610. }
  611. }
  612. const handleSaveAutoPlaySettings = async () => {
  613. setIsLoading('autoplay')
  614. try {
  615. // Convert pause value + unit to seconds
  616. const pauseTimeSeconds = displayPauseToSeconds(autoPlayPauseValue, autoPlayPauseUnit)
  617. await apiClient.patch('/api/settings', {
  618. auto_play: {
  619. ...autoPlaySettings,
  620. pause_time: pauseTimeSeconds,
  621. },
  622. })
  623. toast.success('Auto-play settings saved')
  624. } catch (error) {
  625. toast.error('Failed to save auto-play settings')
  626. } finally {
  627. setIsLoading(null)
  628. }
  629. }
  630. const handleSaveStillSandsSettings = async () => {
  631. setIsLoading('stillsands')
  632. try {
  633. await apiClient.patch('/api/settings', {
  634. scheduled_pause: stillSandsSettings,
  635. })
  636. toast.success('Still Sands settings saved')
  637. } catch (error) {
  638. toast.error('Failed to save Still Sands settings')
  639. } finally {
  640. setIsLoading(null)
  641. }
  642. }
  643. const addTimeSlot = () => {
  644. setStillSandsSettings({
  645. ...stillSandsSettings,
  646. time_slots: [
  647. ...stillSandsSettings.time_slots,
  648. { start_time: '22:00', end_time: '06:00', days: 'daily' },
  649. ],
  650. })
  651. }
  652. const removeTimeSlot = (index: number) => {
  653. setStillSandsSettings({
  654. ...stillSandsSettings,
  655. time_slots: stillSandsSettings.time_slots.filter((_, i) => i !== index),
  656. })
  657. }
  658. const updateTimeSlot = (index: number, updates: Partial<TimeSlot>) => {
  659. const newSlots = [...stillSandsSettings.time_slots]
  660. newSlots[index] = { ...newSlots[index], ...updates }
  661. setStillSandsSettings({ ...stillSandsSettings, time_slots: newSlots })
  662. }
  663. return (
  664. <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
  665. {/* Page Header */}
  666. <div className="space-y-0.5 sm:space-y-1 pl-1">
  667. <h1 className="text-xl font-semibold tracking-tight">Settings</h1>
  668. <p className="text-xs text-muted-foreground">
  669. Configure your sand table
  670. </p>
  671. </div>
  672. <Separator />
  673. <Accordion
  674. type="multiple"
  675. value={openSections}
  676. onValueChange={handleAccordionChange}
  677. className="space-y-3"
  678. >
  679. {/* Device Connection */}
  680. <AccordionItem value="connection" id="section-connection" className="border rounded-lg px-4 overflow-visible bg-card">
  681. <AccordionTrigger className="hover:no-underline">
  682. <div className="flex items-center gap-3">
  683. <span className="material-icons-outlined text-muted-foreground">
  684. usb
  685. </span>
  686. <div className="text-left">
  687. <div className="font-semibold">Device Connection</div>
  688. <div className="text-sm text-muted-foreground font-normal">
  689. Serial port configuration
  690. </div>
  691. </div>
  692. </div>
  693. </AccordionTrigger>
  694. <AccordionContent className="pt-4 pb-6 space-y-6">
  695. {/* Connection Status */}
  696. <div className="flex items-center justify-between p-4 rounded-lg border">
  697. <div className="flex items-center gap-3">
  698. <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
  699. <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
  700. {isConnected ? 'usb' : 'usb_off'}
  701. </span>
  702. </div>
  703. <div>
  704. <p className="font-medium">Status</p>
  705. <p className={`text-sm ${isConnected ? 'text-green-600' : 'text-destructive'}`}>
  706. {connectionStatus}
  707. </p>
  708. </div>
  709. </div>
  710. {isConnected && (
  711. <Button
  712. variant="destructive"
  713. size="sm"
  714. onClick={handleDisconnect}
  715. disabled={isLoading === 'disconnect'}
  716. >
  717. Disconnect
  718. </Button>
  719. )}
  720. </div>
  721. {/* Port Selection */}
  722. <div className="space-y-3">
  723. <Label>Available Serial Ports</Label>
  724. <div className="flex gap-3">
  725. <Select value={selectedPort} onValueChange={setSelectedPort}>
  726. <SelectTrigger className="flex-1">
  727. <SelectValue placeholder="Select a port..." />
  728. </SelectTrigger>
  729. <SelectContent>
  730. {ports.length === 0 ? (
  731. <div className="py-6 text-center text-sm text-muted-foreground">
  732. No serial ports found
  733. </div>
  734. ) : (
  735. ports.map((port) => (
  736. <SelectItem key={port} value={port}>
  737. {port}
  738. </SelectItem>
  739. ))
  740. )}
  741. </SelectContent>
  742. </Select>
  743. <Button
  744. onClick={handleConnect}
  745. disabled={isLoading === 'connect' || !selectedPort || isConnected}
  746. className="gap-2"
  747. >
  748. {isLoading === 'connect' ? (
  749. <span className="material-icons-outlined animate-spin">sync</span>
  750. ) : (
  751. <span className="material-icons-outlined">cable</span>
  752. )}
  753. Connect
  754. </Button>
  755. </div>
  756. <p className="text-xs text-muted-foreground">
  757. Select a port and click 'Connect' to establish a connection.
  758. </p>
  759. </div>
  760. <Separator />
  761. {/* Preferred Port for Auto-Connect */}
  762. <div className="space-y-3">
  763. <Label>Auto-Connect</Label>
  764. <div className="flex gap-3">
  765. <Select
  766. value={settings.preferred_port || '__auto__'}
  767. onValueChange={(value) =>
  768. setSettings({ ...settings, preferred_port: value === '__auto__' ? undefined : value })
  769. }
  770. >
  771. <SelectTrigger className="flex-1">
  772. <SelectValue placeholder="Select auto-connect option..." />
  773. </SelectTrigger>
  774. <SelectContent>
  775. <SelectItem value="__auto__">Auto (pick first available)</SelectItem>
  776. <SelectItem value="__none__">Disabled (no auto-connect)</SelectItem>
  777. {ports.length > 0 && (
  778. <>
  779. <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Available Ports</div>
  780. {ports.map((port) => (
  781. <SelectItem key={port} value={port}>
  782. {port}
  783. </SelectItem>
  784. ))}
  785. </>
  786. )}
  787. </SelectContent>
  788. </Select>
  789. <Button
  790. onClick={handleSavePreferredPort}
  791. disabled={isLoading === 'preferredPort'}
  792. className="gap-2"
  793. >
  794. {isLoading === 'preferredPort' ? (
  795. <span className="material-icons-outlined animate-spin">sync</span>
  796. ) : (
  797. <span className="material-icons-outlined">save</span>
  798. )}
  799. Save
  800. </Button>
  801. </div>
  802. <p className="text-xs text-muted-foreground">
  803. Choose how the system connects on startup: Auto picks the first available port, Disabled requires manual connection, or select a specific port.
  804. </p>
  805. </div>
  806. </AccordionContent>
  807. </AccordionItem>
  808. {/* Machine Settings */}
  809. <AccordionItem value="machine" id="section-machine" className="border rounded-lg px-4 overflow-visible bg-card">
  810. <AccordionTrigger className="hover:no-underline">
  811. <div className="flex items-center gap-3">
  812. <span className="material-icons-outlined text-muted-foreground">
  813. precision_manufacturing
  814. </span>
  815. <div className="text-left">
  816. <div className="font-semibold">Machine Settings</div>
  817. <div className="text-sm text-muted-foreground font-normal">
  818. Table type and hardware configuration
  819. </div>
  820. </div>
  821. </div>
  822. </AccordionTrigger>
  823. <AccordionContent className="pt-4 pb-6 space-y-6">
  824. {/* Hardware Parameters */}
  825. <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
  826. <div className="p-3 rounded-lg bg-muted/50">
  827. <p className="text-xs text-muted-foreground">Detected Type</p>
  828. <p className="font-medium text-sm">{settings.detected_table_type || 'Unknown'}</p>
  829. </div>
  830. <div className="p-3 rounded-lg bg-muted/50">
  831. <p className="text-xs text-muted-foreground">Gear Ratio</p>
  832. <p className="font-medium text-sm">{settings.gear_ratio ?? '—'}</p>
  833. </div>
  834. <div className="p-3 rounded-lg bg-muted/50">
  835. <p className="text-xs text-muted-foreground">X Steps/mm</p>
  836. <p className="font-medium text-sm">{settings.x_steps_per_mm ?? '—'}</p>
  837. </div>
  838. <div className="p-3 rounded-lg bg-muted/50">
  839. <p className="text-xs text-muted-foreground">Y Steps/mm</p>
  840. <p className="font-medium text-sm">{settings.y_steps_per_mm ?? '—'}</p>
  841. </div>
  842. </div>
  843. {/* Table Type Override */}
  844. <div className="space-y-3">
  845. <Label>Table Type Override</Label>
  846. <div className="flex gap-3">
  847. <Select
  848. value={settings.table_type_override || 'auto'}
  849. onValueChange={(value) =>
  850. setSettings({ ...settings, table_type_override: value === 'auto' ? undefined : value })
  851. }
  852. >
  853. <SelectTrigger className="flex-1">
  854. <SelectValue placeholder="Auto-detect (use detected type)" />
  855. </SelectTrigger>
  856. <SelectContent>
  857. <SelectItem value="auto">Auto-detect (use detected type)</SelectItem>
  858. {settings.available_table_types?.map((type) => (
  859. <SelectItem key={type.value} value={type.value}>
  860. {type.label}
  861. </SelectItem>
  862. ))}
  863. </SelectContent>
  864. </Select>
  865. <Button
  866. onClick={handleSaveMachineSettings}
  867. disabled={isLoading === 'machine'}
  868. className="gap-2"
  869. >
  870. {isLoading === 'machine' ? (
  871. <span className="material-icons-outlined animate-spin">sync</span>
  872. ) : (
  873. <span className="material-icons-outlined">save</span>
  874. )}
  875. Save
  876. </Button>
  877. </div>
  878. <p className="text-xs text-muted-foreground">
  879. Override the automatically detected table type. This affects gear ratio calculations and homing behavior.
  880. </p>
  881. </div>
  882. <Alert className="flex items-start">
  883. <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
  884. <AlertDescription>
  885. Table type is normally detected automatically from GRBL settings. Use override if auto-detection is incorrect for your hardware.
  886. </AlertDescription>
  887. </Alert>
  888. </AccordionContent>
  889. </AccordionItem>
  890. {/* Homing Configuration */}
  891. <AccordionItem value="homing" id="section-homing" className="border rounded-lg px-4 overflow-visible bg-card">
  892. <AccordionTrigger className="hover:no-underline">
  893. <div className="flex items-center gap-3">
  894. <span className="material-icons-outlined text-muted-foreground">
  895. home
  896. </span>
  897. <div className="text-left">
  898. <div className="font-semibold">Homing Configuration</div>
  899. <div className="text-sm text-muted-foreground font-normal">
  900. Homing mode and auto-home settings
  901. </div>
  902. </div>
  903. </div>
  904. </AccordionTrigger>
  905. <AccordionContent className="pt-4 pb-6 space-y-6">
  906. {/* Homing Mode Selection */}
  907. <div className="space-y-3">
  908. <Label>Homing Mode</Label>
  909. <RadioGroup
  910. value={String(settings.homing_mode || 0)}
  911. onValueChange={(value) =>
  912. setSettings({ ...settings, homing_mode: parseInt(value) })
  913. }
  914. className="space-y-3"
  915. >
  916. <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
  917. <RadioGroupItem value="0" id="homing-crash" className="mt-0.5" />
  918. <div className="flex-1">
  919. <Label htmlFor="homing-crash" className="font-medium cursor-pointer">
  920. Crash Homing
  921. </Label>
  922. <p className="text-xs text-muted-foreground mt-1">
  923. Y axis moves until physical stop, then theta and rho set to 0
  924. </p>
  925. </div>
  926. </div>
  927. <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
  928. <RadioGroupItem value="1" id="homing-sensor" className="mt-0.5" />
  929. <div className="flex-1">
  930. <Label htmlFor="homing-sensor" className="font-medium cursor-pointer">
  931. Sensor Homing
  932. </Label>
  933. <p className="text-xs text-muted-foreground mt-1">
  934. Homes both X and Y axes using sensors
  935. </p>
  936. </div>
  937. </div>
  938. </RadioGroup>
  939. </div>
  940. {/* Sensor Offset (only visible for sensor mode) */}
  941. {settings.homing_mode === 1 && (
  942. <div className="space-y-3">
  943. <Label htmlFor="angular-offset">Sensor Offset (degrees)</Label>
  944. <Input
  945. id="angular-offset"
  946. type="number"
  947. min="0"
  948. max="360"
  949. step="0.1"
  950. value={settings.angular_offset ?? ''}
  951. onChange={(e) =>
  952. setSettings({
  953. ...settings,
  954. angular_offset: e.target.value === '' ? undefined : parseFloat(e.target.value),
  955. })
  956. }
  957. placeholder="0.0"
  958. />
  959. <p className="text-xs text-muted-foreground">
  960. Set the angle (in degrees) where your radial arm should be offset. Choose a value so the radial arm points East.
  961. </p>
  962. </div>
  963. )}
  964. {/* Auto-Home During Playlists */}
  965. <div className="p-4 rounded-lg border space-y-3">
  966. <div className="flex items-center justify-between">
  967. <div>
  968. <p className="font-medium flex items-center gap-2">
  969. <span className="material-icons-outlined text-base">autorenew</span>
  970. Auto-Home During Playlists
  971. </p>
  972. <p className="text-xs text-muted-foreground mt-1">
  973. Perform homing after a set number of patterns to maintain accuracy
  974. </p>
  975. </div>
  976. <Switch
  977. checked={settings.auto_home_enabled || false}
  978. onCheckedChange={(checked) =>
  979. setSettings({ ...settings, auto_home_enabled: checked })
  980. }
  981. />
  982. </div>
  983. {settings.auto_home_enabled && (
  984. <div className="space-y-3">
  985. <Label htmlFor="auto-home-patterns">Home after every X patterns</Label>
  986. <Input
  987. id="auto-home-patterns"
  988. type="number"
  989. min="1"
  990. max="100"
  991. value={settings.auto_home_after_patterns || 5}
  992. onChange={(e) =>
  993. setSettings({
  994. ...settings,
  995. auto_home_after_patterns: parseInt(e.target.value) || 5,
  996. })
  997. }
  998. />
  999. <p className="text-xs text-muted-foreground">
  1000. Homing occurs after each main pattern completes (clear patterns don't count).
  1001. </p>
  1002. </div>
  1003. )}
  1004. </div>
  1005. <Button
  1006. onClick={handleSaveHomingConfig}
  1007. disabled={isLoading === 'homing'}
  1008. className="gap-2"
  1009. >
  1010. {isLoading === 'homing' ? (
  1011. <span className="material-icons-outlined animate-spin">sync</span>
  1012. ) : (
  1013. <span className="material-icons-outlined">save</span>
  1014. )}
  1015. Save Homing Configuration
  1016. </Button>
  1017. </AccordionContent>
  1018. </AccordionItem>
  1019. {/* Application Settings */}
  1020. <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible bg-card">
  1021. <AccordionTrigger className="hover:no-underline">
  1022. <div className="flex items-center gap-3">
  1023. <span className="material-icons-outlined text-muted-foreground">
  1024. tune
  1025. </span>
  1026. <div className="text-left">
  1027. <div className="font-semibold">Application Settings</div>
  1028. <div className="text-sm text-muted-foreground font-normal">
  1029. Customize app name and branding
  1030. </div>
  1031. </div>
  1032. </div>
  1033. </AccordionTrigger>
  1034. <AccordionContent className="pt-4 pb-6 space-y-6">
  1035. {/* Custom Logo */}
  1036. <div className="space-y-3">
  1037. <Label>Custom Logo</Label>
  1038. <div className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 rounded-lg border">
  1039. <div className="flex items-center gap-4">
  1040. <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
  1041. {settings.custom_logo ? (
  1042. <img
  1043. src={apiClient.getAssetUrl(`/static/custom/${settings.custom_logo}`)}
  1044. alt="Custom Logo"
  1045. className="w-full h-full object-cover"
  1046. />
  1047. ) : (
  1048. <img
  1049. src={apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
  1050. alt="Default Logo"
  1051. className="w-full h-full object-cover"
  1052. />
  1053. )}
  1054. </div>
  1055. <div className="flex-1">
  1056. <p className="font-medium">
  1057. {settings.custom_logo ? 'Custom logo active' : 'Using default logo'}
  1058. </p>
  1059. <p className="text-sm text-muted-foreground">
  1060. PNG, JPG, GIF, WebP or SVG (max 5MB)
  1061. </p>
  1062. </div>
  1063. </div>
  1064. <div className="flex gap-2 sm:ml-auto">
  1065. <Button
  1066. variant="secondary"
  1067. size="sm"
  1068. className="gap-2"
  1069. disabled={isLoading === 'logo'}
  1070. onClick={() => document.getElementById('logo-upload')?.click()}
  1071. >
  1072. {isLoading === 'logo' ? (
  1073. <span className="material-icons-outlined animate-spin text-base">sync</span>
  1074. ) : (
  1075. <span className="material-icons-outlined text-base">upload</span>
  1076. )}
  1077. Upload
  1078. </Button>
  1079. {settings.custom_logo && (
  1080. <Button
  1081. variant="secondary"
  1082. size="sm"
  1083. className="gap-2 text-destructive hover:text-destructive"
  1084. disabled={isLoading === 'logo'}
  1085. onClick={handleDeleteLogo}
  1086. >
  1087. <span className="material-icons-outlined text-base">delete</span>
  1088. </Button>
  1089. )}
  1090. </div>
  1091. <input
  1092. id="logo-upload"
  1093. type="file"
  1094. accept=".png,.jpg,.jpeg,.gif,.webp,.svg"
  1095. className="hidden"
  1096. onChange={handleLogoUpload}
  1097. />
  1098. </div>
  1099. <p className="text-xs text-muted-foreground">
  1100. A favicon will be automatically generated from your logo.
  1101. </p>
  1102. </div>
  1103. <Separator />
  1104. {/* App Name */}
  1105. <div className="space-y-3">
  1106. <Label htmlFor="appName">Application Name</Label>
  1107. <div className="flex gap-3">
  1108. <div className="relative flex-1">
  1109. <Input
  1110. id="appName"
  1111. value={settings.app_name || ''}
  1112. onChange={(e) =>
  1113. setSettings({ ...settings, app_name: e.target.value })
  1114. }
  1115. placeholder="e.g., Dune Weaver"
  1116. />
  1117. <Button
  1118. variant="ghost"
  1119. size="sm"
  1120. className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
  1121. onClick={() => setSettings({ ...settings, app_name: 'Dune Weaver' })}
  1122. >
  1123. <span className="material-icons text-base">restart_alt</span>
  1124. </Button>
  1125. </div>
  1126. <Button
  1127. onClick={handleSaveAppName}
  1128. disabled={isLoading === 'appName'}
  1129. className="gap-2"
  1130. >
  1131. {isLoading === 'appName' ? (
  1132. <span className="material-icons-outlined animate-spin">sync</span>
  1133. ) : (
  1134. <span className="material-icons-outlined">save</span>
  1135. )}
  1136. Save
  1137. </Button>
  1138. </div>
  1139. <p className="text-xs text-muted-foreground">
  1140. This name appears in the browser tab and header.
  1141. </p>
  1142. </div>
  1143. </AccordionContent>
  1144. </AccordionItem>
  1145. {/* Pattern Clearing */}
  1146. <AccordionItem value="clearing" id="section-clearing" className="border rounded-lg px-4 overflow-visible bg-card">
  1147. <AccordionTrigger className="hover:no-underline">
  1148. <div className="flex items-center gap-3">
  1149. <span className="material-icons-outlined text-muted-foreground">
  1150. cleaning_services
  1151. </span>
  1152. <div className="text-left">
  1153. <div className="font-semibold">Pattern Clearing</div>
  1154. <div className="text-sm text-muted-foreground font-normal">
  1155. Customize clearing speed and patterns
  1156. </div>
  1157. </div>
  1158. </div>
  1159. </AccordionTrigger>
  1160. <AccordionContent className="pt-4 pb-6 space-y-6">
  1161. <p className="text-sm text-muted-foreground">
  1162. Customize the clearing behavior used when transitioning between patterns.
  1163. </p>
  1164. {/* Clearing Speed */}
  1165. <div className="p-4 rounded-lg border space-y-3">
  1166. <h4 className="font-medium">Clearing Speed</h4>
  1167. <p className="text-sm text-muted-foreground">
  1168. Set a custom speed for clearing patterns. Leave empty to use the default pattern speed.
  1169. </p>
  1170. <div className="space-y-3">
  1171. <Label htmlFor="clear-speed">Speed (steps per minute)</Label>
  1172. <Input
  1173. id="clear-speed"
  1174. type="number"
  1175. min="50"
  1176. max="2000"
  1177. step="50"
  1178. value={settings.clear_pattern_speed || ''}
  1179. onChange={(e) =>
  1180. setSettings({
  1181. ...settings,
  1182. clear_pattern_speed: e.target.value ? parseInt(e.target.value) : undefined,
  1183. })
  1184. }
  1185. placeholder="Default (use pattern speed)"
  1186. />
  1187. </div>
  1188. </div>
  1189. {/* Custom Clear Patterns */}
  1190. <div className="p-4 rounded-lg border space-y-3">
  1191. <h4 className="font-medium">Custom Clear Patterns</h4>
  1192. <p className="text-sm text-muted-foreground">
  1193. Choose specific patterns to use when clearing. Leave empty for default behavior.
  1194. </p>
  1195. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1196. <div className="space-y-3">
  1197. <Label htmlFor="clear-from-in">Clear From Center Pattern</Label>
  1198. <SearchableSelect
  1199. value={settings.custom_clear_from_in || '__default__'}
  1200. onValueChange={(value) =>
  1201. setSettings({ ...settings, custom_clear_from_in: value === '__default__' ? undefined : value })
  1202. }
  1203. options={[
  1204. { value: '__default__', label: 'Default (built-in)' },
  1205. ...patternFiles.map((file) => ({ value: file, label: file })),
  1206. ]}
  1207. placeholder="Default (built-in)"
  1208. searchPlaceholder="Search patterns..."
  1209. emptyMessage="No patterns found"
  1210. />
  1211. <p className="text-xs text-muted-foreground">
  1212. Pattern used when clearing from center outward.
  1213. </p>
  1214. </div>
  1215. <div className="space-y-3">
  1216. <Label htmlFor="clear-from-out">Clear From Perimeter Pattern</Label>
  1217. <SearchableSelect
  1218. value={settings.custom_clear_from_out || '__default__'}
  1219. onValueChange={(value) =>
  1220. setSettings({ ...settings, custom_clear_from_out: value === '__default__' ? undefined : value })
  1221. }
  1222. options={[
  1223. { value: '__default__', label: 'Default (built-in)' },
  1224. ...patternFiles.map((file) => ({ value: file, label: file })),
  1225. ]}
  1226. placeholder="Default (built-in)"
  1227. searchPlaceholder="Search patterns..."
  1228. emptyMessage="No patterns found"
  1229. />
  1230. <p className="text-xs text-muted-foreground">
  1231. Pattern used when clearing from perimeter inward.
  1232. </p>
  1233. </div>
  1234. </div>
  1235. </div>
  1236. <Button
  1237. onClick={handleSaveClearingSettings}
  1238. disabled={isLoading === 'clearing'}
  1239. className="gap-2"
  1240. >
  1241. {isLoading === 'clearing' ? (
  1242. <span className="material-icons-outlined animate-spin">sync</span>
  1243. ) : (
  1244. <span className="material-icons-outlined">save</span>
  1245. )}
  1246. Save Clearing Settings
  1247. </Button>
  1248. </AccordionContent>
  1249. </AccordionItem>
  1250. {/* LED Controller Configuration */}
  1251. <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible bg-card">
  1252. <AccordionTrigger className="hover:no-underline">
  1253. <div className="flex items-center gap-3">
  1254. <span className="material-icons-outlined text-muted-foreground">
  1255. lightbulb
  1256. </span>
  1257. <div className="text-left">
  1258. <div className="font-semibold">LED Controller</div>
  1259. <div className="text-sm text-muted-foreground font-normal">
  1260. WLED or local GPIO LED control
  1261. </div>
  1262. </div>
  1263. </div>
  1264. </AccordionTrigger>
  1265. <AccordionContent className="pt-4 pb-6 space-y-6">
  1266. {/* LED Provider Selection */}
  1267. <div className="space-y-3">
  1268. <Label>LED Provider</Label>
  1269. <RadioGroup
  1270. value={ledConfig.provider}
  1271. onValueChange={(value) =>
  1272. setLedConfig({ ...ledConfig, provider: value as LedConfig['provider'] })
  1273. }
  1274. className="flex gap-4"
  1275. >
  1276. <div className="flex items-center space-x-2">
  1277. <RadioGroupItem value="none" id="led-none" />
  1278. <Label htmlFor="led-none" className="font-normal">None</Label>
  1279. </div>
  1280. <div className="flex items-center space-x-2">
  1281. <RadioGroupItem value="wled" id="led-wled" />
  1282. <Label htmlFor="led-wled" className="font-normal">WLED</Label>
  1283. </div>
  1284. <div className="flex items-center space-x-2">
  1285. <RadioGroupItem value="dw_leds" id="led-dw" />
  1286. <Label htmlFor="led-dw" className="font-normal">DW LEDs (GPIO)</Label>
  1287. </div>
  1288. </RadioGroup>
  1289. </div>
  1290. {/* WLED Config */}
  1291. {ledConfig.provider === 'wled' && (
  1292. <div className="space-y-3 p-4 rounded-lg border">
  1293. <Label htmlFor="wledIp">WLED IP Address</Label>
  1294. <Input
  1295. id="wledIp"
  1296. value={ledConfig.wled_ip || ''}
  1297. onChange={(e) =>
  1298. setLedConfig({ ...ledConfig, wled_ip: e.target.value })
  1299. }
  1300. placeholder="e.g., 192.168.1.100"
  1301. />
  1302. <p className="text-xs text-muted-foreground">
  1303. Enter the IP address of your WLED controller
  1304. </p>
  1305. </div>
  1306. )}
  1307. {/* DW LEDs Config */}
  1308. {ledConfig.provider === 'dw_leds' && (
  1309. <div className="space-y-3 p-4 rounded-lg border">
  1310. <Alert className="flex items-start">
  1311. <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
  1312. <AlertDescription>
  1313. Supports WS2812, WS2812B, SK6812 and other WS281x LED strips
  1314. </AlertDescription>
  1315. </Alert>
  1316. <div className="grid grid-cols-2 gap-4">
  1317. <div className="space-y-3">
  1318. <Label htmlFor="numLeds">Number of LEDs</Label>
  1319. <Input
  1320. id="numLeds"
  1321. type="text"
  1322. inputMode="numeric"
  1323. value={numLedsInput}
  1324. onChange={(e) => {
  1325. const val = e.target.value.replace(/[^0-9]/g, '')
  1326. setNumLedsInput(val)
  1327. }}
  1328. onBlur={() => {
  1329. const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
  1330. setLedConfig({ ...ledConfig, num_leds: num })
  1331. setNumLedsInput(String(num))
  1332. }}
  1333. onKeyDown={(e) => {
  1334. if (e.key === 'Enter') {
  1335. const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
  1336. setLedConfig({ ...ledConfig, num_leds: num })
  1337. setNumLedsInput(String(num))
  1338. }
  1339. }}
  1340. />
  1341. </div>
  1342. <div className="space-y-3">
  1343. <Label htmlFor="gpioPin">GPIO Pin</Label>
  1344. <Select
  1345. value={String(ledConfig.gpio_pin || 18)}
  1346. onValueChange={(value) =>
  1347. setLedConfig({ ...ledConfig, gpio_pin: parseInt(value) })
  1348. }
  1349. >
  1350. <SelectTrigger>
  1351. <SelectValue />
  1352. </SelectTrigger>
  1353. <SelectContent>
  1354. <SelectItem value="12">GPIO 12 (PWM0)</SelectItem>
  1355. <SelectItem value="13">GPIO 13 (PWM1)</SelectItem>
  1356. <SelectItem value="18">GPIO 18 (PWM0)</SelectItem>
  1357. <SelectItem value="19">GPIO 19 (PWM1)</SelectItem>
  1358. </SelectContent>
  1359. </Select>
  1360. </div>
  1361. </div>
  1362. <div className="space-y-3">
  1363. <Label htmlFor="pixelOrder">Pixel Color Order</Label>
  1364. <Select
  1365. value={ledConfig.pixel_order || 'RGB'}
  1366. onValueChange={(value) =>
  1367. setLedConfig({ ...ledConfig, pixel_order: value })
  1368. }
  1369. >
  1370. <SelectTrigger>
  1371. <SelectValue />
  1372. </SelectTrigger>
  1373. <SelectContent>
  1374. <SelectGroup>
  1375. <SelectLabel>RGB Strips (3-channel)</SelectLabel>
  1376. <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
  1377. <SelectItem value="GRB">GRB - WS2812/WS2812B (common)</SelectItem>
  1378. <SelectItem value="BGR">BGR - Some WS2811 variants</SelectItem>
  1379. <SelectItem value="RBG">RBG - Rare variant</SelectItem>
  1380. <SelectItem value="GBR">GBR - Rare variant</SelectItem>
  1381. <SelectItem value="BRG">BRG - Rare variant</SelectItem>
  1382. </SelectGroup>
  1383. <SelectGroup>
  1384. <SelectLabel>RGBW Strips (4-channel)</SelectLabel>
  1385. <SelectItem value="GRBW">GRBW - SK6812 RGBW (common)</SelectItem>
  1386. <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
  1387. </SelectGroup>
  1388. </SelectContent>
  1389. </Select>
  1390. </div>
  1391. </div>
  1392. )}
  1393. <Button
  1394. onClick={handleSaveLedConfig}
  1395. disabled={isLoading === 'led'}
  1396. className="gap-2"
  1397. >
  1398. {isLoading === 'led' ? (
  1399. <span className="material-icons-outlined animate-spin">sync</span>
  1400. ) : (
  1401. <span className="material-icons-outlined">save</span>
  1402. )}
  1403. Save LED Configuration
  1404. </Button>
  1405. </AccordionContent>
  1406. </AccordionItem>
  1407. {/* Home Assistant Integration */}
  1408. <AccordionItem value="mqtt" id="section-mqtt" className="border rounded-lg px-4 overflow-visible bg-card">
  1409. <AccordionTrigger className="hover:no-underline">
  1410. <div className="flex items-center gap-3">
  1411. <span className="material-icons-outlined text-muted-foreground">
  1412. home
  1413. </span>
  1414. <div className="text-left">
  1415. <div className="font-semibold">Home Assistant Integration</div>
  1416. <div className="text-sm text-muted-foreground font-normal">
  1417. MQTT configuration for smart home control
  1418. </div>
  1419. </div>
  1420. </div>
  1421. </AccordionTrigger>
  1422. <AccordionContent className="pt-4 pb-6 space-y-6">
  1423. {/* Enable Toggle */}
  1424. <div className="flex items-center justify-between p-4 rounded-lg border">
  1425. <div>
  1426. <p className="font-medium">Enable MQTT</p>
  1427. <p className="text-sm text-muted-foreground">
  1428. Connect to Home Assistant via MQTT
  1429. </p>
  1430. </div>
  1431. <Switch
  1432. checked={mqttConfig.enabled}
  1433. onCheckedChange={(checked) =>
  1434. setMqttConfig({ ...mqttConfig, enabled: checked })
  1435. }
  1436. />
  1437. </div>
  1438. {mqttConfig.enabled && (
  1439. <div className="space-y-3">
  1440. {/* Broker Settings */}
  1441. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1442. <div className="space-y-3">
  1443. <Label htmlFor="mqttBroker">
  1444. Broker Address <span className="text-destructive">*</span>
  1445. </Label>
  1446. <Input
  1447. id="mqttBroker"
  1448. value={mqttConfig.broker || ''}
  1449. onChange={(e) =>
  1450. setMqttConfig({ ...mqttConfig, broker: e.target.value })
  1451. }
  1452. placeholder="e.g., 192.168.1.100"
  1453. />
  1454. </div>
  1455. <div className="space-y-3">
  1456. <Label htmlFor="mqttPort">Port</Label>
  1457. <Input
  1458. id="mqttPort"
  1459. type="number"
  1460. value={mqttConfig.port || 1883}
  1461. onChange={(e) =>
  1462. setMqttConfig({ ...mqttConfig, port: parseInt(e.target.value) })
  1463. }
  1464. placeholder="1883"
  1465. />
  1466. </div>
  1467. </div>
  1468. {/* Authentication */}
  1469. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1470. <div className="space-y-3">
  1471. <Label htmlFor="mqttUser">Username</Label>
  1472. <Input
  1473. id="mqttUser"
  1474. value={mqttConfig.username || ''}
  1475. onChange={(e) =>
  1476. setMqttConfig({ ...mqttConfig, username: e.target.value })
  1477. }
  1478. placeholder="Optional"
  1479. />
  1480. </div>
  1481. <div className="space-y-3">
  1482. <Label htmlFor="mqttPass">Password</Label>
  1483. <Input
  1484. id="mqttPass"
  1485. type="password"
  1486. value={mqttConfig.password || ''}
  1487. onChange={(e) =>
  1488. setMqttConfig({ ...mqttConfig, password: e.target.value })
  1489. }
  1490. placeholder="Optional"
  1491. />
  1492. </div>
  1493. </div>
  1494. <Separator />
  1495. {/* Device Settings */}
  1496. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1497. <div className="space-y-3">
  1498. <Label htmlFor="mqttDeviceName">Device Name</Label>
  1499. <Input
  1500. id="mqttDeviceName"
  1501. value={mqttConfig.device_name || 'Dune Weaver'}
  1502. onChange={(e) =>
  1503. setMqttConfig({ ...mqttConfig, device_name: e.target.value })
  1504. }
  1505. />
  1506. </div>
  1507. <div className="space-y-3">
  1508. <Label htmlFor="mqttDeviceId">Device ID</Label>
  1509. <Input
  1510. id="mqttDeviceId"
  1511. value={mqttConfig.device_id || 'dune_weaver'}
  1512. onChange={(e) =>
  1513. setMqttConfig({ ...mqttConfig, device_id: e.target.value })
  1514. }
  1515. />
  1516. </div>
  1517. </div>
  1518. <Alert className="flex items-start">
  1519. <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
  1520. <AlertDescription>
  1521. MQTT configuration changes require a restart to take effect.
  1522. </AlertDescription>
  1523. </Alert>
  1524. </div>
  1525. )}
  1526. <div className="flex gap-3">
  1527. <Button
  1528. onClick={handleSaveMqttConfig}
  1529. disabled={isLoading === 'mqtt'}
  1530. className="gap-2"
  1531. >
  1532. {isLoading === 'mqtt' ? (
  1533. <span className="material-icons-outlined animate-spin">sync</span>
  1534. ) : (
  1535. <span className="material-icons-outlined">save</span>
  1536. )}
  1537. Save MQTT Configuration
  1538. </Button>
  1539. {mqttConfig.enabled && mqttConfig.broker && (
  1540. <Button
  1541. variant="secondary"
  1542. onClick={handleTestMqttConnection}
  1543. disabled={isLoading === 'mqttTest'}
  1544. className="gap-2"
  1545. >
  1546. {isLoading === 'mqttTest' ? (
  1547. <span className="material-icons-outlined animate-spin">sync</span>
  1548. ) : (
  1549. <span className="material-icons-outlined">wifi_tethering</span>
  1550. )}
  1551. Test Connection
  1552. </Button>
  1553. )}
  1554. </div>
  1555. </AccordionContent>
  1556. </AccordionItem>
  1557. {/* Auto-play on Boot */}
  1558. <AccordionItem value="autoplay" id="section-autoplay" className="border rounded-lg px-4 overflow-visible bg-card">
  1559. <AccordionTrigger className="hover:no-underline">
  1560. <div className="flex items-center gap-3">
  1561. <span className="material-icons-outlined text-muted-foreground">
  1562. play_circle
  1563. </span>
  1564. <div className="text-left">
  1565. <div className="font-semibold">Auto-play on Boot</div>
  1566. <div className="text-sm text-muted-foreground font-normal">
  1567. Start a playlist automatically on startup
  1568. </div>
  1569. </div>
  1570. </div>
  1571. </AccordionTrigger>
  1572. <AccordionContent className="pt-4 pb-6 space-y-6">
  1573. <div className="flex items-center justify-between p-4 rounded-lg border">
  1574. <div>
  1575. <p className="font-medium">Enable Auto-play</p>
  1576. <p className="text-sm text-muted-foreground">
  1577. Automatically start playing when the system boots
  1578. </p>
  1579. </div>
  1580. <Switch
  1581. checked={autoPlaySettings.enabled}
  1582. onCheckedChange={(checked) =>
  1583. setAutoPlaySettings({ ...autoPlaySettings, enabled: checked })
  1584. }
  1585. />
  1586. </div>
  1587. {autoPlaySettings.enabled && (
  1588. <div className="space-y-3 p-4 rounded-lg border">
  1589. <div className="space-y-3">
  1590. <Label>Startup Playlist</Label>
  1591. <Select
  1592. value={autoPlaySettings.playlist || undefined}
  1593. onValueChange={(value) =>
  1594. setAutoPlaySettings({ ...autoPlaySettings, playlist: value })
  1595. }
  1596. >
  1597. <SelectTrigger>
  1598. <SelectValue placeholder="Select a playlist..." />
  1599. </SelectTrigger>
  1600. <SelectContent>
  1601. {playlists.length === 0 ? (
  1602. <div className="py-6 text-center text-sm text-muted-foreground">
  1603. No playlists found
  1604. </div>
  1605. ) : (
  1606. playlists.map((playlist) => (
  1607. <SelectItem key={playlist} value={playlist}>
  1608. {playlist}
  1609. </SelectItem>
  1610. ))
  1611. )}
  1612. </SelectContent>
  1613. </Select>
  1614. <p className="text-xs text-muted-foreground">
  1615. Choose which playlist to play when the system starts.
  1616. </p>
  1617. </div>
  1618. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1619. <div className="space-y-3">
  1620. <Label>Run Mode</Label>
  1621. <Select
  1622. value={autoPlaySettings.run_mode}
  1623. onValueChange={(value) =>
  1624. setAutoPlaySettings({
  1625. ...autoPlaySettings,
  1626. run_mode: value as 'single' | 'loop',
  1627. })
  1628. }
  1629. >
  1630. <SelectTrigger>
  1631. <SelectValue />
  1632. </SelectTrigger>
  1633. <SelectContent>
  1634. <SelectItem value="single">Single (play once)</SelectItem>
  1635. <SelectItem value="loop">Loop (repeat forever)</SelectItem>
  1636. </SelectContent>
  1637. </Select>
  1638. </div>
  1639. <div className="space-y-3">
  1640. <Label>Pause Between Patterns</Label>
  1641. <div className="flex gap-2">
  1642. <Input
  1643. type="text"
  1644. inputMode="numeric"
  1645. value={autoPlayPauseInput}
  1646. onChange={(e) => {
  1647. const val = e.target.value.replace(/[^0-9]/g, '')
  1648. setAutoPlayPauseInput(val)
  1649. }}
  1650. onBlur={() => {
  1651. const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
  1652. setAutoPlayPauseValue(num)
  1653. setAutoPlayPauseInput(String(num))
  1654. }}
  1655. onKeyDown={(e) => {
  1656. if (e.key === 'Enter') {
  1657. const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
  1658. setAutoPlayPauseValue(num)
  1659. setAutoPlayPauseInput(String(num))
  1660. }
  1661. }}
  1662. className="w-20"
  1663. />
  1664. <Select
  1665. value={autoPlayPauseUnit}
  1666. onValueChange={(v) => setAutoPlayPauseUnit(v as 'sec' | 'min' | 'hr')}
  1667. >
  1668. <SelectTrigger className="w-20">
  1669. <SelectValue />
  1670. </SelectTrigger>
  1671. <SelectContent>
  1672. <SelectItem value="sec">sec</SelectItem>
  1673. <SelectItem value="min">min</SelectItem>
  1674. <SelectItem value="hr">hr</SelectItem>
  1675. </SelectContent>
  1676. </Select>
  1677. </div>
  1678. </div>
  1679. </div>
  1680. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1681. <div className="space-y-3">
  1682. <Label>Clear Pattern</Label>
  1683. <Select
  1684. value={autoPlaySettings.clear_pattern}
  1685. onValueChange={(value) =>
  1686. setAutoPlaySettings({ ...autoPlaySettings, clear_pattern: value })
  1687. }
  1688. >
  1689. <SelectTrigger>
  1690. <SelectValue />
  1691. </SelectTrigger>
  1692. <SelectContent>
  1693. <SelectItem value="none">None</SelectItem>
  1694. <SelectItem value="adaptive">Adaptive</SelectItem>
  1695. <SelectItem value="clear_from_in">Clear From Center</SelectItem>
  1696. <SelectItem value="clear_from_out">Clear From Perimeter</SelectItem>
  1697. <SelectItem value="clear_sideway">Clear Sideway</SelectItem>
  1698. <SelectItem value="random">Random</SelectItem>
  1699. </SelectContent>
  1700. </Select>
  1701. <p className="text-xs text-muted-foreground">
  1702. Pattern to run before each main pattern.
  1703. </p>
  1704. </div>
  1705. <div className="flex items-center justify-between">
  1706. <div className="flex-1">
  1707. <p className="text-sm font-medium">Shuffle Playlist</p>
  1708. <p className="text-xs text-muted-foreground">
  1709. Randomize pattern order
  1710. </p>
  1711. </div>
  1712. <Switch
  1713. checked={autoPlaySettings.shuffle}
  1714. onCheckedChange={(checked) =>
  1715. setAutoPlaySettings({ ...autoPlaySettings, shuffle: checked })
  1716. }
  1717. />
  1718. </div>
  1719. </div>
  1720. </div>
  1721. )}
  1722. <Button
  1723. onClick={handleSaveAutoPlaySettings}
  1724. disabled={isLoading === 'autoplay'}
  1725. className="gap-2"
  1726. >
  1727. {isLoading === 'autoplay' ? (
  1728. <span className="material-icons-outlined animate-spin">sync</span>
  1729. ) : (
  1730. <span className="material-icons-outlined">save</span>
  1731. )}
  1732. Save Auto-play Settings
  1733. </Button>
  1734. </AccordionContent>
  1735. </AccordionItem>
  1736. {/* Still Sands */}
  1737. <AccordionItem value="stillsands" id="section-stillsands" className="border rounded-lg px-4 overflow-visible bg-card">
  1738. <AccordionTrigger className="hover:no-underline">
  1739. <div className="flex items-center gap-3">
  1740. <span className="material-icons-outlined text-muted-foreground">
  1741. bedtime
  1742. </span>
  1743. <div className="text-left">
  1744. <div className="font-semibold">Still Sands</div>
  1745. <div className="text-sm text-muted-foreground font-normal">
  1746. Schedule quiet periods for your table
  1747. </div>
  1748. </div>
  1749. </div>
  1750. </AccordionTrigger>
  1751. <AccordionContent className="pt-4 pb-6 space-y-6">
  1752. <div className="flex items-center justify-between p-4 rounded-lg border">
  1753. <div>
  1754. <p className="font-medium">Enable Still Sands</p>
  1755. <p className="text-sm text-muted-foreground">
  1756. Pause the table during specified time periods
  1757. </p>
  1758. </div>
  1759. <Switch
  1760. checked={stillSandsSettings.enabled}
  1761. onCheckedChange={(checked) =>
  1762. setStillSandsSettings({ ...stillSandsSettings, enabled: checked })
  1763. }
  1764. />
  1765. </div>
  1766. {stillSandsSettings.enabled && (
  1767. <div className="space-y-3">
  1768. {/* Options */}
  1769. <div className="p-4 rounded-lg border space-y-3">
  1770. <div className="flex items-center justify-between">
  1771. <div className="flex items-center gap-2">
  1772. <span className="material-icons-outlined text-base text-muted-foreground">
  1773. hourglass_bottom
  1774. </span>
  1775. <div>
  1776. <p className="text-sm font-medium">Finish Current Pattern</p>
  1777. <p className="text-xs text-muted-foreground">
  1778. Let the current pattern complete before entering still mode
  1779. </p>
  1780. </div>
  1781. </div>
  1782. <Switch
  1783. checked={stillSandsSettings.finish_pattern}
  1784. onCheckedChange={(checked) =>
  1785. setStillSandsSettings({ ...stillSandsSettings, finish_pattern: checked })
  1786. }
  1787. />
  1788. </div>
  1789. <Separator />
  1790. <div className="flex items-center justify-between">
  1791. <div className="flex items-center gap-2">
  1792. <span className="material-icons-outlined text-base text-muted-foreground">
  1793. lightbulb
  1794. </span>
  1795. <div>
  1796. <p className="text-sm font-medium">Control LED Lights</p>
  1797. <p className="text-xs text-muted-foreground">
  1798. Turn off LED lights during still periods
  1799. </p>
  1800. </div>
  1801. </div>
  1802. <Switch
  1803. checked={stillSandsSettings.control_wled}
  1804. onCheckedChange={(checked) =>
  1805. setStillSandsSettings({ ...stillSandsSettings, control_wled: checked })
  1806. }
  1807. />
  1808. </div>
  1809. {/* Timezone */}
  1810. <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pt-3 border-t">
  1811. <div className="flex items-center gap-3">
  1812. <span className="material-icons-outlined text-muted-foreground">
  1813. schedule
  1814. </span>
  1815. <div>
  1816. <p className="text-sm font-medium">Timezone</p>
  1817. <p className="text-xs text-muted-foreground">
  1818. Select a timezone for scheduling
  1819. </p>
  1820. </div>
  1821. </div>
  1822. <SearchableSelect
  1823. value={stillSandsSettings.timezone || ''}
  1824. onValueChange={(value) =>
  1825. setStillSandsSettings({ ...stillSandsSettings, timezone: value })
  1826. }
  1827. placeholder="System Default"
  1828. searchPlaceholder="Search timezones..."
  1829. className="w-full sm:w-[200px]"
  1830. options={[
  1831. { value: '', label: 'System Default' },
  1832. { value: 'Etc/GMT+12', label: 'UTC-12' },
  1833. { value: 'Etc/GMT+11', label: 'UTC-11' },
  1834. { value: 'Etc/GMT+10', label: 'UTC-10' },
  1835. { value: 'Etc/GMT+9', label: 'UTC-9' },
  1836. { value: 'Etc/GMT+8', label: 'UTC-8' },
  1837. { value: 'Etc/GMT+7', label: 'UTC-7' },
  1838. { value: 'Etc/GMT+6', label: 'UTC-6' },
  1839. { value: 'Etc/GMT+5', label: 'UTC-5' },
  1840. { value: 'Etc/GMT+4', label: 'UTC-4' },
  1841. { value: 'Etc/GMT+3', label: 'UTC-3' },
  1842. { value: 'Etc/GMT+2', label: 'UTC-2' },
  1843. { value: 'Etc/GMT+1', label: 'UTC-1' },
  1844. { value: 'UTC', label: 'UTC' },
  1845. { value: 'Etc/GMT-1', label: 'UTC+1' },
  1846. { value: 'Etc/GMT-2', label: 'UTC+2' },
  1847. { value: 'Etc/GMT-3', label: 'UTC+3' },
  1848. { value: 'Etc/GMT-4', label: 'UTC+4' },
  1849. { value: 'Etc/GMT-5', label: 'UTC+5' },
  1850. { value: 'Etc/GMT-6', label: 'UTC+6' },
  1851. { value: 'Etc/GMT-7', label: 'UTC+7' },
  1852. { value: 'Etc/GMT-8', label: 'UTC+8' },
  1853. { value: 'Etc/GMT-9', label: 'UTC+9' },
  1854. { value: 'Etc/GMT-10', label: 'UTC+10' },
  1855. { value: 'Etc/GMT-11', label: 'UTC+11' },
  1856. { value: 'Etc/GMT-12', label: 'UTC+12' },
  1857. { value: 'America/New_York', label: 'America/New_York (Eastern)' },
  1858. { value: 'America/Chicago', label: 'America/Chicago (Central)' },
  1859. { value: 'America/Denver', label: 'America/Denver (Mountain)' },
  1860. { value: 'America/Los_Angeles', label: 'America/Los_Angeles (Pacific)' },
  1861. { value: 'Europe/London', label: 'Europe/London' },
  1862. { value: 'Europe/Paris', label: 'Europe/Paris' },
  1863. { value: 'Europe/Berlin', label: 'Europe/Berlin' },
  1864. { value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
  1865. { value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
  1866. { value: 'Asia/Singapore', label: 'Asia/Singapore' },
  1867. { value: 'Australia/Sydney', label: 'Australia/Sydney' },
  1868. ]}
  1869. />
  1870. </div>
  1871. </div>
  1872. {/* Time Slots */}
  1873. <div className="p-4 rounded-lg border space-y-3">
  1874. <div className="flex items-center justify-between">
  1875. <h4 className="font-medium">Still Periods</h4>
  1876. <Button onClick={addTimeSlot} size="sm" variant="secondary" className="gap-1">
  1877. <span className="material-icons text-base">add</span>
  1878. Add Period
  1879. </Button>
  1880. </div>
  1881. <p className="text-sm text-muted-foreground">
  1882. Define time periods when the sands should rest.
  1883. </p>
  1884. {stillSandsSettings.time_slots.length === 0 ? (
  1885. <div className="text-center py-6 text-muted-foreground">
  1886. <span className="material-icons text-3xl mb-2">schedule</span>
  1887. <p className="text-sm">No still periods configured</p>
  1888. <p className="text-xs">Click "Add Period" to create one</p>
  1889. </div>
  1890. ) : (
  1891. <div className="space-y-3">
  1892. {stillSandsSettings.time_slots.map((slot, index) => (
  1893. <div
  1894. key={index}
  1895. className="p-3 border rounded-lg bg-muted/50 space-y-3 overflow-hidden"
  1896. >
  1897. <div className="flex items-center justify-between -mr-1">
  1898. <span className="text-sm font-medium">Period {index + 1}</span>
  1899. <Button
  1900. variant="ghost"
  1901. size="icon"
  1902. onClick={() => removeTimeSlot(index)}
  1903. className="h-7 w-7 text-destructive hover:text-destructive"
  1904. >
  1905. <span className="material-icons text-lg">delete</span>
  1906. </Button>
  1907. </div>
  1908. <div className="grid grid-cols-2 gap-3">
  1909. <div className="space-y-1.5 min-w-0">
  1910. <Label className="text-xs">Start Time</Label>
  1911. <Input
  1912. type="time"
  1913. value={slot.start_time}
  1914. onChange={(e) =>
  1915. updateTimeSlot(index, { start_time: e.target.value })
  1916. }
  1917. className="text-xs"
  1918. />
  1919. </div>
  1920. <div className="space-y-1.5 min-w-0">
  1921. <Label className="text-xs">End Time</Label>
  1922. <Input
  1923. type="time"
  1924. value={slot.end_time}
  1925. onChange={(e) =>
  1926. updateTimeSlot(index, { end_time: e.target.value })
  1927. }
  1928. className="text-xs"
  1929. />
  1930. </div>
  1931. </div>
  1932. <div className="space-y-1.5">
  1933. <Label className="text-xs">Days</Label>
  1934. <Select
  1935. value={slot.days}
  1936. onValueChange={(value) =>
  1937. updateTimeSlot(index, {
  1938. days: value as TimeSlot['days'],
  1939. })
  1940. }
  1941. >
  1942. <SelectTrigger>
  1943. <SelectValue />
  1944. </SelectTrigger>
  1945. <SelectContent>
  1946. <SelectItem value="daily">Daily</SelectItem>
  1947. <SelectItem value="weekdays">Weekdays</SelectItem>
  1948. <SelectItem value="weekends">Weekends</SelectItem>
  1949. <SelectItem value="custom">Custom</SelectItem>
  1950. </SelectContent>
  1951. </Select>
  1952. </div>
  1953. </div>
  1954. ))}
  1955. </div>
  1956. )}
  1957. </div>
  1958. <Alert className="flex items-start">
  1959. <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
  1960. <AlertDescription>
  1961. Times are based on the timezone selected above (or system default). Still
  1962. periods that span midnight (e.g., 22:00 to 06:00) are supported. Patterns
  1963. resume automatically when still periods end.
  1964. </AlertDescription>
  1965. </Alert>
  1966. </div>
  1967. )}
  1968. <Button
  1969. onClick={handleSaveStillSandsSettings}
  1970. disabled={isLoading === 'stillsands'}
  1971. className="gap-2"
  1972. >
  1973. {isLoading === 'stillsands' ? (
  1974. <span className="material-icons-outlined animate-spin">sync</span>
  1975. ) : (
  1976. <span className="material-icons-outlined">save</span>
  1977. )}
  1978. Save Still Sands Settings
  1979. </Button>
  1980. </AccordionContent>
  1981. </AccordionItem>
  1982. {/* Software Version */}
  1983. <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
  1984. <AccordionTrigger className="hover:no-underline">
  1985. <div className="flex items-center gap-3">
  1986. <span className="material-icons-outlined text-muted-foreground">
  1987. info
  1988. </span>
  1989. <div className="text-left">
  1990. <div className="font-semibold">Software Version</div>
  1991. <div className="text-sm text-muted-foreground font-normal">
  1992. Updates and system information
  1993. </div>
  1994. </div>
  1995. </div>
  1996. </AccordionTrigger>
  1997. <AccordionContent className="pt-4 pb-6 space-y-3">
  1998. <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
  1999. <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
  2000. <span className="material-icons text-muted-foreground">terminal</span>
  2001. </div>
  2002. <div className="flex-1">
  2003. <p className="font-medium">Current Version</p>
  2004. <p className="text-sm text-muted-foreground">
  2005. {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
  2006. </p>
  2007. </div>
  2008. </div>
  2009. <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
  2010. <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
  2011. <span className="material-icons text-muted-foreground">system_update</span>
  2012. </div>
  2013. <div className="flex-1">
  2014. <p className="font-medium">Latest Version</p>
  2015. <p className={`text-sm ${versionInfo?.update_available ? 'text-green-600 dark:text-green-400 font-medium' : 'text-muted-foreground'}`}>
  2016. {versionInfo?.latest ? `v${versionInfo.latest}` : 'Checking...'}
  2017. {versionInfo?.update_available && ' (Update available!)'}
  2018. </p>
  2019. </div>
  2020. </div>
  2021. {versionInfo?.update_available && (
  2022. <Alert className="flex items-start">
  2023. <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
  2024. <AlertDescription>
  2025. To update, run <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">dw update</code> from the host machine.
  2026. </AlertDescription>
  2027. </Alert>
  2028. )}
  2029. </AccordionContent>
  2030. </AccordionItem>
  2031. </Accordion>
  2032. </div>
  2033. )
  2034. }