| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
- import { Save, Loader2, Check, Plus, Plug, AlertTriangle } from 'lucide-react';
- import { api } from '../api/client';
- import type { AppSettings, SmartPlug } from '../api/client';
- import { Card, CardContent, CardHeader } from '../components/Card';
- import { Button } from '../components/Button';
- import { SmartPlugCard } from '../components/SmartPlugCard';
- import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
- import { useState, useEffect } from 'react';
- export function SettingsPage() {
- const queryClient = useQueryClient();
- const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
- const [hasChanges, setHasChanges] = useState(false);
- const [showSaved, setShowSaved] = useState(false);
- const [showPlugModal, setShowPlugModal] = useState(false);
- const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
- const { data: settings, isLoading } = useQuery({
- queryKey: ['settings'],
- queryFn: api.getSettings,
- });
- const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
- queryKey: ['smart-plugs'],
- queryFn: api.getSmartPlugs,
- });
- const { data: ffmpegStatus } = useQuery({
- queryKey: ['ffmpeg-status'],
- queryFn: api.checkFfmpeg,
- });
- // Sync local state when settings load
- useEffect(() => {
- if (settings && !localSettings) {
- setLocalSettings(settings);
- }
- }, [settings, localSettings]);
- // Track changes
- useEffect(() => {
- if (settings && localSettings) {
- const changed =
- settings.auto_archive !== localSettings.auto_archive ||
- settings.save_thumbnails !== localSettings.save_thumbnails ||
- settings.capture_finish_photo !== localSettings.capture_finish_photo ||
- settings.default_filament_cost !== localSettings.default_filament_cost ||
- settings.currency !== localSettings.currency ||
- settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh;
- setHasChanges(changed);
- }
- }, [settings, localSettings]);
- const updateMutation = useMutation({
- mutationFn: api.updateSettings,
- onSuccess: (data) => {
- queryClient.setQueryData(['settings'], data);
- setLocalSettings(data);
- setHasChanges(false);
- setShowSaved(true);
- setTimeout(() => setShowSaved(false), 2000);
- },
- });
- const handleSave = () => {
- if (localSettings) {
- updateMutation.mutate(localSettings);
- }
- };
- const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
- if (localSettings) {
- setLocalSettings({ ...localSettings, [key]: value });
- }
- };
- if (isLoading || !localSettings) {
- return (
- <div className="p-8 flex justify-center">
- <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
- </div>
- );
- }
- return (
- <div className="p-8">
- <div className="mb-8 flex items-center justify-between">
- <div>
- <h1 className="text-2xl font-bold text-white">Settings</h1>
- <p className="text-bambu-gray">Configure Bambusy</p>
- </div>
- <Button
- onClick={handleSave}
- disabled={!hasChanges || updateMutation.isPending}
- >
- {updateMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : showSaved ? (
- <Check className="w-4 h-4" />
- ) : (
- <Save className="w-4 h-4" />
- )}
- {showSaved ? 'Saved!' : 'Save'}
- </Button>
- </div>
- {updateMutation.isError && (
- <div className="mb-6 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
- Failed to save settings: {(updateMutation.error as Error).message}
- </div>
- )}
- <div className="flex gap-8">
- {/* Left Column - General Settings */}
- <div className="space-y-6 flex-1 max-w-xl">
- <Card>
- <CardHeader>
- <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="flex items-center justify-between">
- <div>
- <p className="text-white">Auto-archive prints</p>
- <p className="text-sm text-bambu-gray">
- Automatically save 3MF files when prints complete
- </p>
- </div>
- <label className="relative inline-flex items-center cursor-pointer">
- <input
- type="checkbox"
- checked={localSettings.auto_archive}
- onChange={(e) => updateSetting('auto_archive', e.target.checked)}
- className="sr-only peer"
- />
- <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>
- </label>
- </div>
- <div className="flex items-center justify-between">
- <div>
- <p className="text-white">Save thumbnails</p>
- <p className="text-sm text-bambu-gray">
- Extract and save preview images from 3MF files
- </p>
- </div>
- <label className="relative inline-flex items-center cursor-pointer">
- <input
- type="checkbox"
- checked={localSettings.save_thumbnails}
- onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}
- className="sr-only peer"
- />
- <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>
- </label>
- </div>
- <div className="flex items-center justify-between">
- <div>
- <p className="text-white">Capture finish photo</p>
- <p className="text-sm text-bambu-gray">
- Take a photo from printer camera when print completes
- </p>
- </div>
- <label className="relative inline-flex items-center cursor-pointer">
- <input
- type="checkbox"
- checked={localSettings.capture_finish_photo}
- onChange={(e) => updateSetting('capture_finish_photo', e.target.checked)}
- className="sr-only peer"
- />
- <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>
- </label>
- </div>
- {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
- <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
- <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
- <div className="text-sm">
- <p className="text-yellow-500 font-medium">ffmpeg not installed</p>
- <p className="text-bambu-gray mt-1">
- Camera capture requires ffmpeg. Install it via{' '}
- <code className="bg-bambu-dark-tertiary px-1 rounded">brew install ffmpeg</code> (macOS) or{' '}
- <code className="bg-bambu-dark-tertiary px-1 rounded">apt install ffmpeg</code> (Linux).
- </p>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
- <Card>
- <CardHeader>
- <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
- </CardHeader>
- <CardContent className="space-y-4">
- <div>
- <label className="block text-sm text-bambu-gray mb-1">
- Default filament cost (per kg)
- </label>
- <input
- type="number"
- step="0.01"
- min="0"
- value={localSettings.default_filament_cost}
- onChange={(e) =>
- updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
- }
- 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"
- />
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Currency</label>
- <select
- value={localSettings.currency}
- onChange={(e) => updateSetting('currency', e.target.value)}
- 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"
- >
- <option value="USD">USD ($)</option>
- <option value="EUR">EUR (€)</option>
- <option value="GBP">GBP (£)</option>
- <option value="CHF">CHF (Fr.)</option>
- <option value="JPY">JPY (¥)</option>
- <option value="CNY">CNY (¥)</option>
- <option value="CAD">CAD ($)</option>
- <option value="AUD">AUD ($)</option>
- </select>
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">
- Electricity cost per kWh
- </label>
- <input
- type="number"
- step="0.01"
- min="0"
- value={localSettings.energy_cost_per_kwh}
- onChange={(e) =>
- updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
- }
- 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"
- />
- <p className="text-xs text-bambu-gray mt-1">
- Used for tracking energy costs per print via smart plugs
- </p>
- </div>
- </CardContent>
- </Card>
- <Card>
- <CardHeader>
- <h2 className="text-lg font-semibold text-white">About</h2>
- </CardHeader>
- <CardContent>
- <div className="space-y-2 text-sm">
- <p className="text-white">Bambusy v0.1.2</p>
- <p className="text-bambu-gray">
- Archive and manage your Bambu Lab 3MF files
- </p>
- <p className="text-bambu-gray">
- Connect to printers via LAN mode (developer mode required)
- </p>
- </div>
- </CardContent>
- </Card>
- </div>
- {/* Right Column - Smart Plugs */}
- <div className="w-96 flex-shrink-0">
- <Card>
- <CardHeader>
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <Plug className="w-5 h-5 text-bambu-green" />
- <h2 className="text-lg font-semibold text-white">Smart Plugs</h2>
- </div>
- <Button
- size="sm"
- onClick={() => {
- setEditingPlug(null);
- setShowPlugModal(true);
- }}
- >
- <Plus className="w-4 h-4" />
- Add
- </Button>
- </div>
- </CardHeader>
- <CardContent>
- <p className="text-sm text-bambu-gray mb-4">
- Connect Tasmota-based smart plugs to automate power control for your printers.
- </p>
- {plugsLoading ? (
- <div className="flex justify-center py-8">
- <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
- </div>
- ) : smartPlugs && smartPlugs.length > 0 ? (
- <div className="space-y-4">
- {smartPlugs.map((plug) => (
- <SmartPlugCard
- key={plug.id}
- plug={plug}
- onEdit={(p) => {
- setEditingPlug(p);
- setShowPlugModal(true);
- }}
- />
- ))}
- </div>
- ) : (
- <div className="text-center py-8 text-bambu-gray">
- <Plug className="w-12 h-12 mx-auto mb-3 opacity-30" />
- <p>No smart plugs configured</p>
- <p className="text-sm mt-1">Add a Tasmota plug to get started</p>
- </div>
- )}
- </CardContent>
- </Card>
- </div>
- </div>
- {/* Smart Plug Modal */}
- {showPlugModal && (
- <AddSmartPlugModal
- plug={editingPlug}
- onClose={() => {
- setShowPlugModal(false);
- setEditingPlug(null);
- }}
- />
- )}
- </div>
- );
- }
|