SettingsPage.tsx 116 KB

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