SettingsPage.tsx 105 KB

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