SettingsPage.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  2. import { Save, Loader2, Check, Plus, Plug, AlertTriangle } from 'lucide-react';
  3. import { api } from '../api/client';
  4. import type { AppSettings, SmartPlug } from '../api/client';
  5. import { Card, CardContent, CardHeader } from '../components/Card';
  6. import { Button } from '../components/Button';
  7. import { SmartPlugCard } from '../components/SmartPlugCard';
  8. import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
  9. import { useState, useEffect } from 'react';
  10. export function SettingsPage() {
  11. const queryClient = useQueryClient();
  12. const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
  13. const [hasChanges, setHasChanges] = useState(false);
  14. const [showSaved, setShowSaved] = useState(false);
  15. const [showPlugModal, setShowPlugModal] = useState(false);
  16. const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
  17. const { data: settings, isLoading } = useQuery({
  18. queryKey: ['settings'],
  19. queryFn: api.getSettings,
  20. });
  21. const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
  22. queryKey: ['smart-plugs'],
  23. queryFn: api.getSmartPlugs,
  24. });
  25. const { data: ffmpegStatus } = useQuery({
  26. queryKey: ['ffmpeg-status'],
  27. queryFn: api.checkFfmpeg,
  28. });
  29. // Sync local state when settings load
  30. useEffect(() => {
  31. if (settings && !localSettings) {
  32. setLocalSettings(settings);
  33. }
  34. }, [settings, localSettings]);
  35. // Track changes
  36. useEffect(() => {
  37. if (settings && localSettings) {
  38. const changed =
  39. settings.auto_archive !== localSettings.auto_archive ||
  40. settings.save_thumbnails !== localSettings.save_thumbnails ||
  41. settings.capture_finish_photo !== localSettings.capture_finish_photo ||
  42. settings.default_filament_cost !== localSettings.default_filament_cost ||
  43. settings.currency !== localSettings.currency ||
  44. settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh;
  45. setHasChanges(changed);
  46. }
  47. }, [settings, localSettings]);
  48. const updateMutation = useMutation({
  49. mutationFn: api.updateSettings,
  50. onSuccess: (data) => {
  51. queryClient.setQueryData(['settings'], data);
  52. setLocalSettings(data);
  53. setHasChanges(false);
  54. setShowSaved(true);
  55. setTimeout(() => setShowSaved(false), 2000);
  56. },
  57. });
  58. const handleSave = () => {
  59. if (localSettings) {
  60. updateMutation.mutate(localSettings);
  61. }
  62. };
  63. const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
  64. if (localSettings) {
  65. setLocalSettings({ ...localSettings, [key]: value });
  66. }
  67. };
  68. if (isLoading || !localSettings) {
  69. return (
  70. <div className="p-8 flex justify-center">
  71. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  72. </div>
  73. );
  74. }
  75. return (
  76. <div className="p-8">
  77. <div className="mb-8 flex items-center justify-between">
  78. <div>
  79. <h1 className="text-2xl font-bold text-white">Settings</h1>
  80. <p className="text-bambu-gray">Configure Bambusy</p>
  81. </div>
  82. <Button
  83. onClick={handleSave}
  84. disabled={!hasChanges || updateMutation.isPending}
  85. >
  86. {updateMutation.isPending ? (
  87. <Loader2 className="w-4 h-4 animate-spin" />
  88. ) : showSaved ? (
  89. <Check className="w-4 h-4" />
  90. ) : (
  91. <Save className="w-4 h-4" />
  92. )}
  93. {showSaved ? 'Saved!' : 'Save'}
  94. </Button>
  95. </div>
  96. {updateMutation.isError && (
  97. <div className="mb-6 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  98. Failed to save settings: {(updateMutation.error as Error).message}
  99. </div>
  100. )}
  101. <div className="flex gap-8">
  102. {/* Left Column - General Settings */}
  103. <div className="space-y-6 flex-1 max-w-xl">
  104. <Card>
  105. <CardHeader>
  106. <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
  107. </CardHeader>
  108. <CardContent className="space-y-4">
  109. <div className="flex items-center justify-between">
  110. <div>
  111. <p className="text-white">Auto-archive prints</p>
  112. <p className="text-sm text-bambu-gray">
  113. Automatically save 3MF files when prints complete
  114. </p>
  115. </div>
  116. <label className="relative inline-flex items-center cursor-pointer">
  117. <input
  118. type="checkbox"
  119. checked={localSettings.auto_archive}
  120. onChange={(e) => updateSetting('auto_archive', e.target.checked)}
  121. className="sr-only peer"
  122. />
  123. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  124. </label>
  125. </div>
  126. <div className="flex items-center justify-between">
  127. <div>
  128. <p className="text-white">Save thumbnails</p>
  129. <p className="text-sm text-bambu-gray">
  130. Extract and save preview images from 3MF files
  131. </p>
  132. </div>
  133. <label className="relative inline-flex items-center cursor-pointer">
  134. <input
  135. type="checkbox"
  136. checked={localSettings.save_thumbnails}
  137. onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}
  138. className="sr-only peer"
  139. />
  140. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  141. </label>
  142. </div>
  143. <div className="flex items-center justify-between">
  144. <div>
  145. <p className="text-white">Capture finish photo</p>
  146. <p className="text-sm text-bambu-gray">
  147. Take a photo from printer camera when print completes
  148. </p>
  149. </div>
  150. <label className="relative inline-flex items-center cursor-pointer">
  151. <input
  152. type="checkbox"
  153. checked={localSettings.capture_finish_photo}
  154. onChange={(e) => updateSetting('capture_finish_photo', e.target.checked)}
  155. className="sr-only peer"
  156. />
  157. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  158. </label>
  159. </div>
  160. {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
  161. <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
  162. <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
  163. <div className="text-sm">
  164. <p className="text-yellow-500 font-medium">ffmpeg not installed</p>
  165. <p className="text-bambu-gray mt-1">
  166. Camera capture requires ffmpeg. Install it via{' '}
  167. <code className="bg-bambu-dark-tertiary px-1 rounded">brew install ffmpeg</code> (macOS) or{' '}
  168. <code className="bg-bambu-dark-tertiary px-1 rounded">apt install ffmpeg</code> (Linux).
  169. </p>
  170. </div>
  171. </div>
  172. )}
  173. </CardContent>
  174. </Card>
  175. <Card>
  176. <CardHeader>
  177. <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
  178. </CardHeader>
  179. <CardContent className="space-y-4">
  180. <div>
  181. <label className="block text-sm text-bambu-gray mb-1">
  182. Default filament cost (per kg)
  183. </label>
  184. <input
  185. type="number"
  186. step="0.01"
  187. min="0"
  188. value={localSettings.default_filament_cost}
  189. onChange={(e) =>
  190. updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
  191. }
  192. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  193. />
  194. </div>
  195. <div>
  196. <label className="block text-sm text-bambu-gray mb-1">Currency</label>
  197. <select
  198. value={localSettings.currency}
  199. onChange={(e) => updateSetting('currency', e.target.value)}
  200. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  201. >
  202. <option value="USD">USD ($)</option>
  203. <option value="EUR">EUR (€)</option>
  204. <option value="GBP">GBP (£)</option>
  205. <option value="CHF">CHF (Fr.)</option>
  206. <option value="JPY">JPY (¥)</option>
  207. <option value="CNY">CNY (¥)</option>
  208. <option value="CAD">CAD ($)</option>
  209. <option value="AUD">AUD ($)</option>
  210. </select>
  211. </div>
  212. <div>
  213. <label className="block text-sm text-bambu-gray mb-1">
  214. Electricity cost per kWh
  215. </label>
  216. <input
  217. type="number"
  218. step="0.01"
  219. min="0"
  220. value={localSettings.energy_cost_per_kwh}
  221. onChange={(e) =>
  222. updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
  223. }
  224. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  225. />
  226. <p className="text-xs text-bambu-gray mt-1">
  227. Used for tracking energy costs per print via smart plugs
  228. </p>
  229. </div>
  230. </CardContent>
  231. </Card>
  232. <Card>
  233. <CardHeader>
  234. <h2 className="text-lg font-semibold text-white">About</h2>
  235. </CardHeader>
  236. <CardContent>
  237. <div className="space-y-2 text-sm">
  238. <p className="text-white">Bambusy v0.1.2</p>
  239. <p className="text-bambu-gray">
  240. Archive and manage your Bambu Lab 3MF files
  241. </p>
  242. <p className="text-bambu-gray">
  243. Connect to printers via LAN mode (developer mode required)
  244. </p>
  245. </div>
  246. </CardContent>
  247. </Card>
  248. </div>
  249. {/* Right Column - Smart Plugs */}
  250. <div className="w-96 flex-shrink-0">
  251. <Card>
  252. <CardHeader>
  253. <div className="flex items-center justify-between">
  254. <div className="flex items-center gap-2">
  255. <Plug className="w-5 h-5 text-bambu-green" />
  256. <h2 className="text-lg font-semibold text-white">Smart Plugs</h2>
  257. </div>
  258. <Button
  259. size="sm"
  260. onClick={() => {
  261. setEditingPlug(null);
  262. setShowPlugModal(true);
  263. }}
  264. >
  265. <Plus className="w-4 h-4" />
  266. Add
  267. </Button>
  268. </div>
  269. </CardHeader>
  270. <CardContent>
  271. <p className="text-sm text-bambu-gray mb-4">
  272. Connect Tasmota-based smart plugs to automate power control for your printers.
  273. </p>
  274. {plugsLoading ? (
  275. <div className="flex justify-center py-8">
  276. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  277. </div>
  278. ) : smartPlugs && smartPlugs.length > 0 ? (
  279. <div className="space-y-4">
  280. {smartPlugs.map((plug) => (
  281. <SmartPlugCard
  282. key={plug.id}
  283. plug={plug}
  284. onEdit={(p) => {
  285. setEditingPlug(p);
  286. setShowPlugModal(true);
  287. }}
  288. />
  289. ))}
  290. </div>
  291. ) : (
  292. <div className="text-center py-8 text-bambu-gray">
  293. <Plug className="w-12 h-12 mx-auto mb-3 opacity-30" />
  294. <p>No smart plugs configured</p>
  295. <p className="text-sm mt-1">Add a Tasmota plug to get started</p>
  296. </div>
  297. )}
  298. </CardContent>
  299. </Card>
  300. </div>
  301. </div>
  302. {/* Smart Plug Modal */}
  303. {showPlugModal && (
  304. <AddSmartPlugModal
  305. plug={editingPlug}
  306. onClose={() => {
  307. setShowPlugModal(false);
  308. setEditingPlug(null);
  309. }}
  310. />
  311. )}
  312. </div>
  313. );
  314. }