Преглед изворни кода

Add frontend Dockerfile and update CI for two-container setup

- Add frontend/Dockerfile with multi-stage build (node -> nginx)
- Update docker-compose.yml to build frontend from Dockerfile
- Update GitHub Actions to build both images in parallel
- Simplify backend Dockerfile (remove nginx, start.sh)
- Update nginx.conf with all backend proxy endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris пре 3 недеља
родитељ
комит
417a324398
6 измењених фајлова са 951 додато и 75 уклоњено
  1. 49 10
      .github/workflows/docker-publish.yml
  2. 5 35
      Dockerfile
  3. 9 5
      docker-compose.yml
  4. 29 0
      frontend/Dockerfile
  5. 846 23
      frontend/src/pages/SettingsPage.tsx
  6. 13 2
      nginx.conf

+ 49 - 10
.github/workflows/docker-publish.yml

@@ -5,8 +5,11 @@ on:
     branches: [ "main" ]
     paths:
       - 'Dockerfile'
+      - 'frontend/Dockerfile'
+      - 'frontend/**'
       - 'requirements.txt'
       - 'VERSION'
+      - '**.py'
   # Allow manual trigger on any branch
   workflow_dispatch:
     inputs:
@@ -14,13 +17,12 @@ on:
         description: 'Branch to build from'
         required: false
         default: ''
-    
+
 env:
   REGISTRY: ghcr.io
-  IMAGE_NAME: ${{ github.repository }}
 
 jobs:
-  build:
+  build-backend:
     runs-on: ubuntu-latest
     permissions:
       contents: read
@@ -35,7 +37,6 @@ jobs:
         uses: docker/setup-buildx-action@v3
 
       - name: Log into registry ${{ env.REGISTRY }}
-        if: github.event_name != 'pull_request'
         uses: docker/login-action@v3
         with:
           registry: ${{ env.REGISTRY }}
@@ -46,16 +47,54 @@ jobs:
         id: meta
         uses: docker/metadata-action@v5
         with:
-          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+          images: ${{ env.REGISTRY }}/${{ github.repository }}
 
-      - name: Build and push Docker image for Raspberry Pi
-        id: build-and-push
+      - name: Build and push backend image
         uses: docker/build-push-action@v5
         with:
           context: .
-          push: ${{ github.event_name != 'pull_request' }}
+          push: true
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          platforms: linux/amd64,linux/arm64
+          cache-from: type=gha,scope=backend
+          cache-to: type=gha,mode=max,scope=backend
+
+  build-frontend:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      packages: write
+      id-token: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Log into registry ${{ env.REGISTRY }}
+        uses: docker/login-action@v3
+        with:
+          registry: ${{ env.REGISTRY }}
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Extract Docker metadata
+        id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: ${{ env.REGISTRY }}/${{ github.repository }}-frontend
+
+      - name: Build and push frontend image
+        uses: docker/build-push-action@v5
+        with:
+          context: ./frontend
+          file: ./frontend/Dockerfile
+          push: true
           tags: ${{ steps.meta.outputs.tags }}
           labels: ${{ steps.meta.outputs.labels }}
           platforms: linux/amd64,linux/arm64
-          cache-from: type=gha
-          cache-to: type=gha,mode=max
+          cache-from: type=gha,scope=frontend
+          cache-to: type=gha,mode=max,scope=frontend

+ 5 - 35
Dockerfile

@@ -1,22 +1,5 @@
-# Stage 1: Build frontend
-FROM --platform=$TARGETPLATFORM node:20-slim AS frontend-builder
-
-WORKDIR /app/frontend
-
-# Copy frontend package files
-COPY frontend/package*.json ./
-
-# Install dependencies
-RUN npm ci
-
-# Copy frontend source
-COPY frontend/ ./
-
-# Build frontend
-RUN npm run build
-
-# Stage 2: Python backend with nginx
-FROM --platform=$TARGETPLATFORM python:3.11-slim-bookworm
+# Backend-only Dockerfile
+FROM python:3.11-slim-bookworm
 
 # Faster, repeatable builds
 ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -34,8 +17,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
         libgpiod2 libgpiod-dev \
         scons \
         systemd \
-        # Nginx for serving frontend
-        nginx \
         # Docker CLI for container self-restart/update
         ca-certificates curl gnupg \
     && pip install --upgrade pip \
@@ -50,21 +31,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     && apt-get purge -y gcc g++ make scons \
     && rm -rf /var/lib/apt/lists/*
 
-# Copy nginx configuration
-COPY nginx.conf /etc/nginx/sites-available/default
-RUN rm -f /etc/nginx/sites-enabled/default && \
-    ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
-
 # Copy backend code
 COPY . .
 
-# Copy built frontend from Stage 1
-COPY --from=frontend-builder /app/static/dist ./static/dist
-
-# Ensure startup script is executable
-RUN chmod +x /app/start.sh
-
-# Expose ports: 80 for frontend (nginx), 8080 for backend API
-EXPOSE 80 8080
+# Expose backend API port
+EXPOSE 8080
 
-CMD ["/app/start.sh"]
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

+ 9 - 5
docker-compose.yml

@@ -1,11 +1,13 @@
 services:
   frontend:
-    image: nginx:alpine
+    build:
+      context: ./frontend
+      dockerfile: Dockerfile
+    image: ghcr.io/tuanchris/dune-weaver-frontend:main
     restart: always
     ports:
       - "80:80"
     volumes:
-      - ./static/dist:/usr/share/nginx/html:ro
       - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
     depends_on:
       - backend
@@ -17,10 +19,12 @@ services:
     restart: always
     ports:
       - "8080:8080"
-    command: uvicorn main:app --host 0.0.0.0 --port 8080
     volumes:
-      # Mount entire app directory
-      - .:/app
+      # Persistent data directories
+      - ./patterns:/app/patterns
+      - ./playlists:/app/playlists
+      - ./state.json:/app/state.json
+      - ./static/custom:/app/static/custom
       # Mount Docker socket for container self-restart/update
       - /var/run/docker.sock:/var/run/docker.sock
       # Mount timezone file from host for scheduling features

+ 29 - 0
frontend/Dockerfile

@@ -0,0 +1,29 @@
+# Build stage
+FROM node:20-slim AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package*.json ./
+
+# Install dependencies
+RUN npm ci
+
+# Copy source
+COPY . .
+
+# Override output to local directory for Docker build
+RUN npm run build -- --outDir ./dist
+
+# Production stage
+FROM nginx:alpine
+
+# Copy built files from builder
+COPY --from=builder /app/dist /usr/share/nginx/html
+
+# Copy nginx config (will be mounted or copied separately)
+# Note: nginx.conf should be copied in docker-compose or here
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]

+ 846 - 23
frontend/src/pages/SettingsPage.tsx

@@ -29,16 +29,45 @@ interface Settings {
   app_name?: string
   custom_logo?: string
   preferred_port?: string
+  // Machine settings
   table_type_override?: string
+  detected_table_type?: string
+  available_table_types?: { value: string; label: string }[]
+  // Homing settings
   homing_mode?: number
   angular_offset?: number
   auto_home_enabled?: boolean
   auto_home_after_patterns?: number
+  // Pattern clearing settings
   clear_pattern_speed?: number
   custom_clear_from_in?: string
   custom_clear_from_out?: string
 }
 
+interface TimeSlot {
+  start_time: string
+  end_time: string
+  days: 'daily' | 'weekdays' | 'weekends' | 'custom'
+  custom_days?: string[]
+}
+
+interface StillSandsSettings {
+  enabled: boolean
+  finish_pattern: boolean
+  control_wled: boolean
+  timezone: string
+  time_slots: TimeSlot[]
+}
+
+interface AutoPlaySettings {
+  enabled: boolean
+  playlist: string
+  run_mode: 'single' | 'loop'
+  pause_time: number
+  clear_pattern: string
+  shuffle: boolean
+}
+
 interface LedConfig {
   provider: 'none' | 'wled' | 'dw_leds'
   wled_ip?: string
@@ -87,11 +116,27 @@ export function SettingsPage() {
   const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set())
 
   // Auto-play state
-  const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
+  const [autoPlaySettings, setAutoPlaySettings] = useState<AutoPlaySettings>({
+    enabled: false,
+    playlist: '',
+    run_mode: 'loop',
+    pause_time: 5,
+    clear_pattern: 'adaptive',
+    shuffle: false,
+  })
   const [playlists, setPlaylists] = useState<string[]>([])
 
   // Still Sands state
-  const [stillSandsEnabled, setStillSandsEnabled] = useState(false)
+  const [stillSandsSettings, setStillSandsSettings] = useState<StillSandsSettings>({
+    enabled: false,
+    finish_pattern: false,
+    control_wled: false,
+    timezone: '',
+    time_slots: [],
+  })
+
+  // Pattern search state for clearing patterns
+  const [patternFiles, setPatternFiles] = useState<string[]>([])
 
   // Version state
   const [versionInfo, setVersionInfo] = useState<{
@@ -129,15 +174,22 @@ export function SettingsPage() {
       case 'mqtt':
       case 'autoplay':
       case 'stillsands':
+      case 'machine':
+      case 'homing':
+      case 'clearing':
         // These all share settings data
         if (!loadedSections.has('_settings')) {
           setLoadedSections((prev) => new Set(prev).add('_settings'))
           await fetchSettings()
         }
-        if (section === 'autoplay' && !loadedSections.has('_playlists')) {
+        if ((section === 'autoplay' || section === 'clearing') && !loadedSections.has('_playlists')) {
           setLoadedSections((prev) => new Set(prev).add('_playlists'))
           await fetchPlaylists()
         }
+        if (section === 'clearing' && !loadedSections.has('_patterns')) {
+          setLoadedSections((prev) => new Set(prev).add('_patterns'))
+          await fetchPatternFiles()
+        }
         break
       case 'led':
         await fetchLedConfig()
@@ -148,6 +200,16 @@ export function SettingsPage() {
     }
   }
 
+  const fetchPatternFiles = async () => {
+    try {
+      const response = await fetch('/api/patterns')
+      const data = await response.json()
+      setPatternFiles(data.patterns?.map((p: { file: string }) => p.file) || [])
+    } catch (error) {
+      console.error('Error fetching pattern files:', error)
+    }
+  }
+
   const fetchVersionInfo = async () => {
     try {
       const response = await fetch('/api/version')
@@ -229,16 +291,40 @@ export function SettingsPage() {
         app_name: data.app?.name,
         custom_logo: data.app?.custom_logo,
         preferred_port: data.connection?.preferred_port,
+        // Machine settings
+        table_type_override: data.machine?.table_type_override,
+        detected_table_type: data.machine?.detected_table_type,
+        available_table_types: data.machine?.available_table_types,
+        // Homing settings
+        homing_mode: data.homing?.mode,
+        angular_offset: data.homing?.angular_offset_degrees,
+        auto_home_enabled: data.homing?.auto_home_enabled,
+        auto_home_after_patterns: data.homing?.auto_home_after_patterns,
+        // Pattern clearing settings
         clear_pattern_speed: data.patterns?.clear_pattern_speed,
         custom_clear_from_in: data.patterns?.custom_clear_from_in,
         custom_clear_from_out: data.patterns?.custom_clear_from_out,
       })
-      // Also set auto-play and still sands from the same response
+      // Set auto-play settings
       if (data.auto_play) {
-        setAutoPlayEnabled(data.auto_play.enabled || false)
+        setAutoPlaySettings({
+          enabled: data.auto_play.enabled || false,
+          playlist: data.auto_play.playlist || '',
+          run_mode: data.auto_play.run_mode || 'loop',
+          pause_time: data.auto_play.pause_time ?? 5,
+          clear_pattern: data.auto_play.clear_pattern || 'adaptive',
+          shuffle: data.auto_play.shuffle || false,
+        })
       }
+      // Set still sands settings
       if (data.scheduled_pause) {
-        setStillSandsEnabled(data.scheduled_pause.enabled || false)
+        setStillSandsSettings({
+          enabled: data.scheduled_pause.enabled || false,
+          finish_pattern: data.scheduled_pause.finish_pattern || false,
+          control_wled: data.scheduled_pause.control_wled || false,
+          timezone: data.scheduled_pause.timezone || '',
+          time_slots: data.scheduled_pause.time_slots || [],
+        })
       }
       // Set MQTT config from the same response
       if (data.mqtt) {
@@ -474,6 +560,140 @@ export function SettingsPage() {
     }
   }
 
+  const handleSaveMachineSettings = async () => {
+    setIsLoading('machine')
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          machine: {
+            table_type_override: settings.table_type_override || '',
+          },
+        }),
+      })
+      if (response.ok) {
+        toast.success('Machine settings saved')
+      }
+    } catch (error) {
+      toast.error('Failed to save machine settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveHomingConfig = async () => {
+    setIsLoading('homing')
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          homing: {
+            mode: settings.homing_mode,
+            angular_offset_degrees: settings.angular_offset,
+            auto_home_enabled: settings.auto_home_enabled,
+            auto_home_after_patterns: settings.auto_home_after_patterns,
+          },
+        }),
+      })
+      if (response.ok) {
+        toast.success('Homing configuration saved')
+      }
+    } catch (error) {
+      toast.error('Failed to save homing configuration')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveClearingSettings = async () => {
+    setIsLoading('clearing')
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          patterns: {
+            clear_pattern_speed: settings.clear_pattern_speed || null,
+            custom_clear_from_in: settings.custom_clear_from_in || null,
+            custom_clear_from_out: settings.custom_clear_from_out || null,
+          },
+        }),
+      })
+      if (response.ok) {
+        toast.success('Clearing settings saved')
+      }
+    } catch (error) {
+      toast.error('Failed to save clearing settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveAutoPlaySettings = async () => {
+    setIsLoading('autoplay')
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          auto_play: autoPlaySettings,
+        }),
+      })
+      if (response.ok) {
+        toast.success('Auto-play settings saved')
+      }
+    } catch (error) {
+      toast.error('Failed to save auto-play settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const handleSaveStillSandsSettings = async () => {
+    setIsLoading('stillsands')
+    try {
+      const response = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          scheduled_pause: stillSandsSettings,
+        }),
+      })
+      if (response.ok) {
+        toast.success('Still Sands settings saved')
+      }
+    } catch (error) {
+      toast.error('Failed to save Still Sands settings')
+    } finally {
+      setIsLoading(null)
+    }
+  }
+
+  const addTimeSlot = () => {
+    setStillSandsSettings({
+      ...stillSandsSettings,
+      time_slots: [
+        ...stillSandsSettings.time_slots,
+        { start_time: '22:00', end_time: '06:00', days: 'daily' },
+      ],
+    })
+  }
+
+  const removeTimeSlot = (index: number) => {
+    setStillSandsSettings({
+      ...stillSandsSettings,
+      time_slots: stillSandsSettings.time_slots.filter((_, i) => i !== index),
+    })
+  }
+
+  const updateTimeSlot = (index: number, updates: Partial<TimeSlot>) => {
+    const newSlots = [...stillSandsSettings.time_slots]
+    newSlots[index] = { ...newSlots[index], ...updates }
+    setStillSandsSettings({ ...stillSandsSettings, time_slots: newSlots })
+  }
+
   return (
     <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 px-4">
       {/* Page Header */}
@@ -577,6 +797,213 @@ export function SettingsPage() {
           </AccordionContent>
         </AccordionItem>
 
+        {/* Machine Settings */}
+        <AccordionItem value="machine" id="section-machine" className="border rounded-lg px-4 overflow-visible">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                precision_manufacturing
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Machine Settings</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Table type and hardware configuration
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Detected Table Type */}
+            <div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
+              <span className="material-icons-outlined text-muted-foreground">info</span>
+              <span className="text-sm">
+                Detected:{' '}
+                <span className="font-medium">
+                  {settings.detected_table_type || 'Unknown'}
+                </span>
+              </span>
+            </div>
+
+            {/* Table Type Override */}
+            <div className="space-y-3">
+              <Label>Table Type Override</Label>
+              <div className="flex gap-3">
+                <Select
+                  value={settings.table_type_override || ''}
+                  onValueChange={(value) =>
+                    setSettings({ ...settings, table_type_override: value || undefined })
+                  }
+                >
+                  <SelectTrigger className="flex-1">
+                    <SelectValue placeholder="Auto-detect (use detected type)" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="">Auto-detect (use detected type)</SelectItem>
+                    {settings.available_table_types?.map((type) => (
+                      <SelectItem key={type.value} value={type.value}>
+                        {type.label}
+                      </SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+                <Button
+                  onClick={handleSaveMachineSettings}
+                  disabled={isLoading === 'machine'}
+                  className="gap-2"
+                >
+                  {isLoading === 'machine' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">save</span>
+                  )}
+                  Save
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                Override the automatically detected table type. This affects gear ratio calculations and homing behavior.
+              </p>
+            </div>
+
+            <Alert className="flex items-start">
+              <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+              <AlertDescription>
+                Table type is normally detected automatically from GRBL settings. Use override if auto-detection is incorrect for your hardware.
+              </AlertDescription>
+            </Alert>
+          </AccordionContent>
+        </AccordionItem>
+
+        {/* Homing Configuration */}
+        <AccordionItem value="homing" id="section-homing" className="border rounded-lg px-4 overflow-visible">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                home
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Homing Configuration</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Homing mode and auto-home settings
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            {/* Homing Mode Selection */}
+            <div className="space-y-3">
+              <Label>Homing Mode</Label>
+              <RadioGroup
+                value={String(settings.homing_mode || 0)}
+                onValueChange={(value) =>
+                  setSettings({ ...settings, homing_mode: parseInt(value) })
+                }
+                className="space-y-3"
+              >
+                <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
+                  <RadioGroupItem value="0" id="homing-crash" className="mt-0.5" />
+                  <div className="flex-1">
+                    <Label htmlFor="homing-crash" className="font-medium cursor-pointer">
+                      Crash Homing
+                    </Label>
+                    <p className="text-xs text-muted-foreground mt-1">
+                      Y axis moves until physical stop, then theta and rho set to 0
+                    </p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-muted/50">
+                  <RadioGroupItem value="1" id="homing-sensor" className="mt-0.5" />
+                  <div className="flex-1">
+                    <Label htmlFor="homing-sensor" className="font-medium cursor-pointer">
+                      Sensor Homing
+                    </Label>
+                    <p className="text-xs text-muted-foreground mt-1">
+                      Homes both X and Y axes using sensors
+                    </p>
+                  </div>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {/* Sensor Offset (only visible for sensor mode) */}
+            {settings.homing_mode === 1 && (
+              <div className="space-y-3">
+                <Label htmlFor="angular-offset">Sensor Offset (degrees)</Label>
+                <Input
+                  id="angular-offset"
+                  type="number"
+                  min="0"
+                  max="360"
+                  step="0.1"
+                  value={settings.angular_offset || 0}
+                  onChange={(e) =>
+                    setSettings({ ...settings, angular_offset: parseFloat(e.target.value) || 0 })
+                  }
+                  placeholder="0.0"
+                />
+                <p className="text-xs text-muted-foreground">
+                  Set the angle (in degrees) where your radial arm should be offset. Choose a value so the radial arm points East.
+                </p>
+              </div>
+            )}
+
+            {/* Auto-Home During Playlists */}
+            <div className="p-4 bg-muted/50 rounded-lg space-y-4">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="font-medium flex items-center gap-2">
+                    <span className="material-icons-outlined text-base">autorenew</span>
+                    Auto-Home During Playlists
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Perform homing after a set number of patterns to maintain accuracy
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.auto_home_enabled || false}
+                  onCheckedChange={(checked) =>
+                    setSettings({ ...settings, auto_home_enabled: checked })
+                  }
+                />
+              </div>
+
+              {settings.auto_home_enabled && (
+                <div className="space-y-2">
+                  <Label htmlFor="auto-home-patterns">Home after every X patterns</Label>
+                  <Input
+                    id="auto-home-patterns"
+                    type="number"
+                    min="1"
+                    max="100"
+                    value={settings.auto_home_after_patterns || 5}
+                    onChange={(e) =>
+                      setSettings({
+                        ...settings,
+                        auto_home_after_patterns: parseInt(e.target.value) || 5,
+                      })
+                    }
+                  />
+                  <p className="text-xs text-muted-foreground">
+                    Homing occurs after the clear pattern completes, before the next pattern.
+                  </p>
+                </div>
+              )}
+            </div>
+
+            <Button
+              onClick={handleSaveHomingConfig}
+              disabled={isLoading === 'homing'}
+              className="gap-2"
+            >
+              {isLoading === 'homing' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Homing Configuration
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
         {/* Application Settings */}
         <AccordionItem value="application" id="section-application" className="border rounded-lg px-4 overflow-visible">
           <AccordionTrigger className="hover:no-underline">
@@ -704,6 +1131,127 @@ export function SettingsPage() {
           </AccordionContent>
         </AccordionItem>
 
+        {/* Pattern Clearing */}
+        <AccordionItem value="clearing" id="section-clearing" className="border rounded-lg px-4 overflow-visible">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                cleaning_services
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Pattern Clearing</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Customize clearing speed and patterns
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-6">
+            <p className="text-sm text-muted-foreground">
+              Customize the clearing behavior used when transitioning between patterns.
+            </p>
+
+            {/* Clearing Speed */}
+            <div className="p-4 bg-muted/50 rounded-lg space-y-4">
+              <h4 className="font-medium">Clearing Speed</h4>
+              <p className="text-sm text-muted-foreground">
+                Set a custom speed for clearing patterns. Leave empty to use the default pattern speed.
+              </p>
+              <div className="space-y-2">
+                <Label htmlFor="clear-speed">Speed (steps per minute)</Label>
+                <Input
+                  id="clear-speed"
+                  type="number"
+                  min="50"
+                  max="2000"
+                  step="50"
+                  value={settings.clear_pattern_speed || ''}
+                  onChange={(e) =>
+                    setSettings({
+                      ...settings,
+                      clear_pattern_speed: e.target.value ? parseInt(e.target.value) : undefined,
+                    })
+                  }
+                  placeholder="Default (use pattern speed)"
+                />
+              </div>
+            </div>
+
+            {/* Custom Clear Patterns */}
+            <div className="p-4 bg-muted/50 rounded-lg space-y-4">
+              <h4 className="font-medium">Custom Clear Patterns</h4>
+              <p className="text-sm text-muted-foreground">
+                Choose specific patterns to use when clearing. Leave empty for default behavior.
+              </p>
+
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor="clear-from-in">Clear From Center Pattern</Label>
+                  <Select
+                    value={settings.custom_clear_from_in || ''}
+                    onValueChange={(value) =>
+                      setSettings({ ...settings, custom_clear_from_in: value || undefined })
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue placeholder="Default (built-in)" />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="">Default (built-in)</SelectItem>
+                      {patternFiles.map((file) => (
+                        <SelectItem key={file} value={file}>
+                          {file}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                  <p className="text-xs text-muted-foreground">
+                    Pattern used when clearing from center outward.
+                  </p>
+                </div>
+
+                <div className="space-y-2">
+                  <Label htmlFor="clear-from-out">Clear From Perimeter Pattern</Label>
+                  <Select
+                    value={settings.custom_clear_from_out || ''}
+                    onValueChange={(value) =>
+                      setSettings({ ...settings, custom_clear_from_out: value || undefined })
+                    }
+                  >
+                    <SelectTrigger>
+                      <SelectValue placeholder="Default (built-in)" />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="">Default (built-in)</SelectItem>
+                      {patternFiles.map((file) => (
+                        <SelectItem key={file} value={file}>
+                          {file}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                  <p className="text-xs text-muted-foreground">
+                    Pattern used when clearing from perimeter inward.
+                  </p>
+                </div>
+              </div>
+            </div>
+
+            <Button
+              onClick={handleSaveClearingSettings}
+              disabled={isLoading === 'clearing'}
+              className="gap-2"
+            >
+              {isLoading === 'clearing' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Clearing Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
         {/* LED Controller Configuration */}
         <AccordionItem value="led" id="section-led" className="border rounded-lg px-4 overflow-visible">
           <AccordionTrigger className="hover:no-underline">
@@ -1009,16 +1557,23 @@ export function SettingsPage() {
                 </p>
               </div>
               <Switch
-                checked={autoPlayEnabled}
-                onCheckedChange={setAutoPlayEnabled}
+                checked={autoPlaySettings.enabled}
+                onCheckedChange={(checked) =>
+                  setAutoPlaySettings({ ...autoPlaySettings, enabled: checked })
+                }
               />
             </div>
 
-            {autoPlayEnabled && (
+            {autoPlaySettings.enabled && (
               <div className="space-y-4 p-4 bg-muted/50 rounded-lg">
                 <div className="space-y-2">
                   <Label>Startup Playlist</Label>
-                  <Select>
+                  <Select
+                    value={autoPlaySettings.playlist}
+                    onValueChange={(value) =>
+                      setAutoPlaySettings({ ...autoPlaySettings, playlist: value })
+                    }
+                  >
                     <SelectTrigger>
                       <SelectValue placeholder="Select a playlist..." />
                     </SelectTrigger>
@@ -1030,12 +1585,23 @@ export function SettingsPage() {
                       ))}
                     </SelectContent>
                   </Select>
+                  <p className="text-xs text-muted-foreground">
+                    Choose which playlist to play when the system starts.
+                  </p>
                 </div>
 
-                <div className="grid grid-cols-2 gap-4">
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                   <div className="space-y-2">
                     <Label>Run Mode</Label>
-                    <Select defaultValue="loop">
+                    <Select
+                      value={autoPlaySettings.run_mode}
+                      onValueChange={(value) =>
+                        setAutoPlaySettings({
+                          ...autoPlaySettings,
+                          run_mode: value as 'single' | 'loop',
+                        })
+                      }
+                    >
                       <SelectTrigger>
                         <SelectValue />
                       </SelectTrigger>
@@ -1047,11 +1613,77 @@ export function SettingsPage() {
                   </div>
                   <div className="space-y-2">
                     <Label>Pause Between Patterns (s)</Label>
-                    <Input type="number" defaultValue="5" min="0" step="0.5" />
+                    <Input
+                      type="number"
+                      min="0"
+                      step="0.5"
+                      value={autoPlaySettings.pause_time}
+                      onChange={(e) =>
+                        setAutoPlaySettings({
+                          ...autoPlaySettings,
+                          pause_time: parseFloat(e.target.value) || 0,
+                        })
+                      }
+                    />
+                  </div>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div className="space-y-2">
+                    <Label>Clear Pattern</Label>
+                    <Select
+                      value={autoPlaySettings.clear_pattern}
+                      onValueChange={(value) =>
+                        setAutoPlaySettings({ ...autoPlaySettings, clear_pattern: value })
+                      }
+                    >
+                      <SelectTrigger>
+                        <SelectValue />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="none">None</SelectItem>
+                        <SelectItem value="adaptive">Adaptive</SelectItem>
+                        <SelectItem value="clear_from_in">Clear From Center</SelectItem>
+                        <SelectItem value="clear_from_out">Clear From Perimeter</SelectItem>
+                        <SelectItem value="clear_sideway">Clear Sideway</SelectItem>
+                        <SelectItem value="random">Random</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <p className="text-xs text-muted-foreground">
+                      Pattern to run before each main pattern.
+                    </p>
+                  </div>
+
+                  <div className="flex items-center justify-between">
+                    <div className="flex-1">
+                      <p className="text-sm font-medium">Shuffle Playlist</p>
+                      <p className="text-xs text-muted-foreground">
+                        Randomize pattern order
+                      </p>
+                    </div>
+                    <Switch
+                      checked={autoPlaySettings.shuffle}
+                      onCheckedChange={(checked) =>
+                        setAutoPlaySettings({ ...autoPlaySettings, shuffle: checked })
+                      }
+                    />
                   </div>
                 </div>
               </div>
             )}
+
+            <Button
+              onClick={handleSaveAutoPlaySettings}
+              disabled={isLoading === 'autoplay'}
+              className="gap-2"
+            >
+              {isLoading === 'autoplay' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Auto-play Settings
+            </Button>
           </AccordionContent>
         </AccordionItem>
 
@@ -1079,20 +1711,211 @@ export function SettingsPage() {
                 </p>
               </div>
               <Switch
-                checked={stillSandsEnabled}
-                onCheckedChange={setStillSandsEnabled}
+                checked={stillSandsSettings.enabled}
+                onCheckedChange={(checked) =>
+                  setStillSandsSettings({ ...stillSandsSettings, enabled: checked })
+                }
               />
             </div>
 
-            {stillSandsEnabled && (
-              <Alert className="flex items-start">
-                <span className="material-icons-outlined text-base mr-2 shrink-0">schedule</span>
-                <AlertDescription>
-                  Configure time periods when the sand table should rest.
-                  Patterns will resume automatically when still periods end.
-                </AlertDescription>
-              </Alert>
+            {stillSandsSettings.enabled && (
+              <div className="space-y-4">
+                {/* Options */}
+                <div className="p-4 bg-muted/50 rounded-lg space-y-4">
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">
+                        hourglass_bottom
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Finish Current Pattern</p>
+                        <p className="text-xs text-muted-foreground">
+                          Let the current pattern complete before entering still mode
+                        </p>
+                      </div>
+                    </div>
+                    <Switch
+                      checked={stillSandsSettings.finish_pattern}
+                      onCheckedChange={(checked) =>
+                        setStillSandsSettings({ ...stillSandsSettings, finish_pattern: checked })
+                      }
+                    />
+                  </div>
+
+                  <Separator />
+
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">
+                        lightbulb
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Control WLED Lights</p>
+                        <p className="text-xs text-muted-foreground">
+                          Turn off WLED lights during still periods
+                        </p>
+                      </div>
+                    </div>
+                    <Switch
+                      checked={stillSandsSettings.control_wled}
+                      onCheckedChange={(checked) =>
+                        setStillSandsSettings({ ...stillSandsSettings, control_wled: checked })
+                      }
+                    />
+                  </div>
+
+                  <Separator />
+
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-base text-muted-foreground">
+                        schedule
+                      </span>
+                      <div>
+                        <p className="text-sm font-medium">Timezone</p>
+                        <p className="text-xs text-muted-foreground">
+                          Select a timezone for still periods
+                        </p>
+                      </div>
+                    </div>
+                    <Select
+                      value={stillSandsSettings.timezone}
+                      onValueChange={(value) =>
+                        setStillSandsSettings({ ...stillSandsSettings, timezone: value })
+                      }
+                    >
+                      <SelectTrigger className="w-[200px]">
+                        <SelectValue placeholder="System Default" />
+                      </SelectTrigger>
+                      <SelectContent>
+                        <SelectItem value="">System Default</SelectItem>
+                        <SelectItem value="America/New_York">Eastern Time</SelectItem>
+                        <SelectItem value="America/Chicago">Central Time</SelectItem>
+                        <SelectItem value="America/Denver">Mountain Time</SelectItem>
+                        <SelectItem value="America/Los_Angeles">Pacific Time</SelectItem>
+                        <SelectItem value="Europe/London">London</SelectItem>
+                        <SelectItem value="Europe/Paris">Paris</SelectItem>
+                        <SelectItem value="Europe/Berlin">Berlin</SelectItem>
+                        <SelectItem value="Asia/Tokyo">Tokyo</SelectItem>
+                        <SelectItem value="Asia/Shanghai">Shanghai</SelectItem>
+                        <SelectItem value="Australia/Sydney">Sydney</SelectItem>
+                      </SelectContent>
+                    </Select>
+                  </div>
+                </div>
+
+                {/* Time Slots */}
+                <div className="p-4 bg-muted/50 rounded-lg space-y-4">
+                  <div className="flex items-center justify-between">
+                    <h4 className="font-medium">Still Periods</h4>
+                    <Button onClick={addTimeSlot} size="sm" variant="outline" className="gap-1">
+                      <span className="material-icons text-base">add</span>
+                      Add Period
+                    </Button>
+                  </div>
+
+                  <p className="text-sm text-muted-foreground">
+                    Define time periods when the sands should rest.
+                  </p>
+
+                  {stillSandsSettings.time_slots.length === 0 ? (
+                    <div className="text-center py-6 text-muted-foreground">
+                      <span className="material-icons text-3xl mb-2">schedule</span>
+                      <p className="text-sm">No still periods configured</p>
+                      <p className="text-xs">Click "Add Period" to create one</p>
+                    </div>
+                  ) : (
+                    <div className="space-y-3">
+                      {stillSandsSettings.time_slots.map((slot, index) => (
+                        <div
+                          key={index}
+                          className="p-3 border rounded-lg bg-background space-y-3"
+                        >
+                          <div className="flex items-center justify-between">
+                            <span className="text-sm font-medium">Period {index + 1}</span>
+                            <Button
+                              variant="ghost"
+                              size="sm"
+                              onClick={() => removeTimeSlot(index)}
+                              className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+                            >
+                              <span className="material-icons text-base">delete</span>
+                            </Button>
+                          </div>
+
+                          <div className="grid grid-cols-2 gap-3">
+                            <div className="space-y-1">
+                              <Label className="text-xs">Start Time</Label>
+                              <Input
+                                type="time"
+                                value={slot.start_time}
+                                onChange={(e) =>
+                                  updateTimeSlot(index, { start_time: e.target.value })
+                                }
+                              />
+                            </div>
+                            <div className="space-y-1">
+                              <Label className="text-xs">End Time</Label>
+                              <Input
+                                type="time"
+                                value={slot.end_time}
+                                onChange={(e) =>
+                                  updateTimeSlot(index, { end_time: e.target.value })
+                                }
+                              />
+                            </div>
+                          </div>
+
+                          <div className="space-y-1">
+                            <Label className="text-xs">Days</Label>
+                            <Select
+                              value={slot.days}
+                              onValueChange={(value) =>
+                                updateTimeSlot(index, {
+                                  days: value as TimeSlot['days'],
+                                })
+                              }
+                            >
+                              <SelectTrigger>
+                                <SelectValue />
+                              </SelectTrigger>
+                              <SelectContent>
+                                <SelectItem value="daily">Daily</SelectItem>
+                                <SelectItem value="weekdays">Weekdays</SelectItem>
+                                <SelectItem value="weekends">Weekends</SelectItem>
+                                <SelectItem value="custom">Custom</SelectItem>
+                              </SelectContent>
+                            </Select>
+                          </div>
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </div>
+
+                <Alert className="flex items-start">
+                  <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+                  <AlertDescription>
+                    Times are based on the selected timezone. Still periods that span midnight
+                    (e.g., 22:00 to 06:00) are supported. Patterns resume automatically when
+                    still periods end.
+                  </AlertDescription>
+                </Alert>
+              </div>
             )}
+
+            <Button
+              onClick={handleSaveStillSandsSettings}
+              disabled={isLoading === 'stillsands'}
+              className="gap-2"
+            >
+              {isLoading === 'stillsands' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Still Sands Settings
+            </Button>
           </AccordionContent>
         </AccordionItem>
 

+ 13 - 2
nginx.conf

@@ -2,6 +2,9 @@ server {
     listen 80;
     server_name _;
 
+    # Increase max upload size for pattern files
+    client_max_body_size 10M;
+
     # Frontend - serve static files
     location / {
         root /usr/share/nginx/html;
@@ -30,8 +33,16 @@ server {
         proxy_read_timeout 86400;
     }
 
-    # Legacy endpoints (non-/api/ routes that go to backend)
-    location ~ ^/(preview_thr_batch|get_theta_rho_coordinates|pause_execution|resume_execution|stop_execution|skip_pattern|set_speed|restart|shutdown|run_pattern|run_playlist|connect_device|disconnect_device|home_device|clear_sand) {
+    # Static files from backend (pattern previews, custom logos)
+    location /static/ {
+        proxy_pass http://backend:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_cache_valid 200 1h;
+    }
+
+    # All backend API endpoints (legacy non-/api/ routes)
+    location ~ ^/(list_theta_rho_files|preview_thr_batch|get_theta_rho_coordinates|upload_theta_rho|pause_execution|resume_execution|stop_execution|skip_pattern|set_speed|restart|shutdown|run_pattern|run_playlist|connect_device|disconnect_device|home_device|clear_sand|move_to_position|get_playlists|save_playlist|delete_playlist|rename_playlist|get_status) {
         proxy_pass http://backend:8080;
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;