SettingsPage.tsx 93 KB

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