1
0
Эх сурвалжийг харах

Add nginx to serve frontend on port 80, backend on port 8080

- Add nginx.conf with reverse proxy for API/WebSocket requests
- Add start.sh to run nginx and uvicorn together
- Update Dockerfile to install nginx and use multi-process startup
- Update docker-compose.yml to expose ports 80 and 8080
- Simplify connection indicator in header (remove icon, keep dot)
- UI refinements from linter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 3 долоо хоног өмнө
parent
commit
2bec7f9ec6

+ 16 - 3
Dockerfile

@@ -15,7 +15,7 @@ COPY frontend/ ./
 # Build frontend
 RUN npm run build
 
-# Stage 2: Python backend
+# Stage 2: Python backend with nginx
 FROM --platform=$TARGETPLATFORM python:3.11-slim-bookworm
 
 # Faster, repeatable builds
@@ -34,6 +34,8 @@ 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 \
@@ -48,11 +50,22 @@ 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
 
-EXPOSE 8080
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
+# Copy and set up startup script
+COPY start.sh /start.sh
+RUN chmod +x /start.sh
+
+# Expose ports: 80 for frontend (nginx), 8080 for backend API
+EXPOSE 80 8080
+
+CMD ["/start.sh"]

+ 3 - 4
docker-compose.yml

@@ -3,10 +3,9 @@ services:
     build: .
     image: ghcr.io/tuanchris/dune-weaver:main
     restart: always
-    network_mode: "host" # Use host network for device access (serves on port 8080)
-    # Alternative: Use port mapping instead of host network
-    # ports:
-    #   - "8080:8080"
+    ports:
+      - "80:80"     # Frontend (nginx)
+      - "8080:8080" # Backend API
     volumes:
       # Persist state and patterns
       - ./state.json:/app/state.json

+ 46 - 27
frontend/src/components/layout/Layout.tsx

@@ -2,6 +2,7 @@ import { Outlet, Link, useLocation } from 'react-router-dom'
 import { useEffect, useState, useRef } from 'react'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
+import { Button } from '@/components/ui/button'
 
 const navItems = [
   { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
@@ -476,45 +477,53 @@ export function Layout() {
             />
             <span className="font-semibold text-lg">{appName}</span>
             <span
-              className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}
-              title={isConnected ? 'Connected' : 'Disconnected'}
+              className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}
+              title={isConnected ? 'Connected to table' : 'Disconnected from table'}
             />
           </Link>
           <div className="flex items-center gap-1">
-            <button
+            <Button
+              variant="ghost"
+              size="icon"
               onClick={() => setIsDark(!isDark)}
-              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent"
+              className="rounded-full"
               aria-label="Toggle dark mode"
               title="Toggle Theme"
             >
               <span className="material-icons-outlined">
                 {isDark ? 'light_mode' : 'dark_mode'}
               </span>
-            </button>
-            <button
+            </Button>
+            <Button
+              variant="ghost"
+              size="icon"
               onClick={handleOpenLogs}
-              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent"
+              className="rounded-full"
               aria-label="View logs"
               title="View Application Logs"
             >
               <span className="material-icons-outlined">article</span>
-            </button>
-            <button
+            </Button>
+            <Button
+              variant="ghost"
+              size="icon"
               onClick={handleRestart}
-              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent text-amber-500"
+              className="rounded-full text-amber-500 hover:text-amber-600"
               aria-label="Restart system"
               title="Restart System"
             >
               <span className="material-icons-outlined">restart_alt</span>
-            </button>
-            <button
+            </Button>
+            <Button
+              variant="ghost"
+              size="icon"
               onClick={handleShutdown}
-              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent text-red-500"
+              className="rounded-full text-red-500 hover:text-red-600"
               aria-label="Shutdown system"
               title="Shutdown System"
             >
               <span className="material-icons-outlined">power_settings_new</span>
-            </button>
+            </Button>
           </div>
         </div>
       </header>
@@ -541,7 +550,7 @@ export function Layout() {
       {!isNowPlayingOpen && (
         <button
           onClick={() => setIsNowPlayingOpen(true)}
-          className="fixed right-4 bottom-20 z-30 w-12 h-12 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center hover:bg-primary/90 transition-all"
+          className="fixed right-4 bottom-20 z-30 w-12 h-12 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center transition-all duration-200 hover:bg-primary/90 hover:shadow-xl hover:scale-110 active:scale-95"
           title="Now Playing"
         >
           <span className="material-icons">play_circle</span>
@@ -575,27 +584,33 @@ export function Layout() {
                 </span>
               </div>
               <div className="flex items-center gap-1">
-                <button
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
                   onClick={handleCopyLogs}
-                  className="rounded-full w-7 h-7 flex items-center justify-center hover:bg-accent"
+                  className="rounded-full"
                   title="Copy logs"
                 >
                   <span className="material-icons-outlined text-base">content_copy</span>
-                </button>
-                <button
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
                   onClick={handleDownloadLogs}
-                  className="rounded-full w-7 h-7 flex items-center justify-center hover:bg-accent"
+                  className="rounded-full"
                   title="Download logs"
                 >
                   <span className="material-icons-outlined text-base">download</span>
-                </button>
-                <button
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
                   onClick={() => setIsLogsOpen(false)}
-                  className="rounded-full w-7 h-7 flex items-center justify-center hover:bg-accent"
+                  className="rounded-full"
                   title="Close logs"
                 >
                   <span className="material-icons-outlined text-base">close</span>
-                </button>
+                </Button>
               </div>
             </div>
             <div
@@ -636,13 +651,17 @@ export function Layout() {
               <Link
                 key={item.path}
                 to={item.path}
-                className={`flex flex-col items-center justify-center gap-1 transition-colors ${
+                className={`relative flex flex-col items-center justify-center gap-1 transition-all duration-200 ${
                   isActive
                     ? 'text-primary'
-                    : 'text-muted-foreground hover:text-foreground'
+                    : 'text-muted-foreground hover:text-foreground active:scale-95'
                 }`}
               >
-                <span className="material-icons-outlined text-xl">
+                {/* Active indicator pill */}
+                {isActive && (
+                  <span className="absolute -top-0.5 w-8 h-1 rounded-full bg-primary" />
+                )}
+                <span className={`text-xl ${isActive ? 'material-icons' : 'material-icons-outlined'}`}>
                   {item.icon}
                 </span>
                 <span className="text-xs font-medium">{item.label}</span>

+ 3 - 1
frontend/src/components/ui/button.tsx

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
 import { cn } from "@/lib/utils"
 
 const buttonVariants = cva(
-  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-95 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
   {
     variants: {
       variant: {
@@ -24,6 +24,8 @@ const buttonVariants = cva(
         sm: "h-9 rounded-md px-3",
         lg: "h-11 rounded-md px-8",
         icon: "h-10 w-10",
+        "icon-sm": "h-8 w-8",
+        "icon-lg": "h-12 w-12",
       },
     },
     defaultVariants: {

+ 46 - 0
frontend/src/index.css

@@ -140,3 +140,49 @@ body {
 .animate-marquee:hover {
   animation-play-state: paused;
 }
+
+/* Smooth fade in for lazy-loaded content */
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+    transform: scale(0.98);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+.animate-fade-in {
+  animation: fadeIn 0.2s ease-out forwards;
+}
+
+/* Subtle pulse for connection status */
+@keyframes subtle-pulse {
+  0%, 100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.6;
+  }
+}
+
+.animate-subtle-pulse {
+  animation: subtle-pulse 2s ease-in-out infinite;
+}
+
+/* Slide in from right for panels */
+@keyframes slideInRight {
+  from {
+    transform: translateX(100%);
+    opacity: 0;
+  }
+  to {
+    transform: translateX(0);
+    opacity: 1;
+  }
+}
+
+.animate-slide-in-right {
+  animation: slideInRight 0.3s ease-out forwards;
+}

+ 95 - 15
frontend/src/pages/BrowsePage.tsx

@@ -101,6 +101,10 @@ export function BrowsePage() {
   // Favorites state
   const [favorites, setFavorites] = useState<Set<string>>(new Set())
 
+  // Upload state
+  const fileInputRef = useRef<HTMLInputElement>(null)
+  const [isUploading, setIsUploading] = useState(false)
+
   // Close panel when playback starts
   useEffect(() => {
     const handlePlaybackStarted = () => {
@@ -817,6 +821,52 @@ export function BrowsePage() {
     }
   }
 
+  // Handle pattern file upload
+  const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0]
+    if (!file) return
+
+    // Validate file extension
+    if (!file.name.endsWith('.thr')) {
+      toast.error('Please select a .thr file')
+      return
+    }
+
+    setIsUploading(true)
+    try {
+      const formData = new FormData()
+      formData.append('file', file)
+
+      const response = await fetch('/upload_theta_rho', {
+        method: 'POST',
+        body: formData,
+      })
+
+      if (!response.ok) {
+        const error = await response.json()
+        throw new Error(error.detail || 'Upload failed')
+      }
+
+      toast.success(`Pattern "${file.name}" uploaded successfully`)
+
+      // Refresh patterns list
+      const patternsRes = await fetch('/list_theta_rho_files')
+      if (patternsRes.ok) {
+        const data = await patternsRes.json()
+        setPatterns(data.files || [])
+      }
+    } catch (error) {
+      console.error('Upload error:', error)
+      toast.error(error instanceof Error ? error.message : 'Failed to upload pattern')
+    } finally {
+      setIsUploading(false)
+      // Reset file input
+      if (fileInputRef.current) {
+        fileInputRef.current.value = ''
+      }
+    }
+  }
+
   if (isLoading) {
     return (
       <div className="flex items-center justify-center min-h-[60vh]">
@@ -829,12 +879,36 @@ export function BrowsePage() {
 
   return (
     <div className={`flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 px-4 transition-all duration-300 ${isPanelOpen ? 'lg:mr-[28rem]' : ''}`}>
+      {/* Hidden file input for pattern upload */}
+      <input
+        ref={fileInputRef}
+        type="file"
+        accept=".thr"
+        onChange={handleFileUpload}
+        className="hidden"
+      />
+
       {/* Page Header */}
-      <div className="space-y-1">
-        <h1 className="text-3xl font-bold tracking-tight">Browse Patterns</h1>
-        <p className="text-muted-foreground">
-          Explore and run patterns on your sand table · {patterns.length} patterns available
-        </p>
+      <div className="flex items-start justify-between gap-4">
+        <div className="space-y-1">
+          <h1 className="text-3xl font-bold tracking-tight">Browse Patterns</h1>
+          <p className="text-muted-foreground">
+            Explore and run patterns on your sand table · {patterns.length} patterns available
+          </p>
+        </div>
+        <Button
+          variant="outline"
+          onClick={() => fileInputRef.current?.click()}
+          disabled={isUploading}
+          className="gap-2 shrink-0"
+        >
+          {isUploading ? (
+            <span className="material-icons-outlined animate-spin text-lg">sync</span>
+          ) : (
+            <span className="material-icons-outlined text-lg">add</span>
+          )}
+          <span className="hidden sm:inline">Add Pattern</span>
+        </Button>
       </div>
 
       <Separator />
@@ -854,12 +928,14 @@ export function BrowsePage() {
                 className="pl-10 pr-10"
               />
               {searchQuery && (
-                <button
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
                   onClick={() => setSearchQuery('')}
                   className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
                 >
                   <span className="material-icons-outlined text-xl">close</span>
-                </button>
+                </Button>
               )}
             </div>
           </div>
@@ -987,12 +1063,14 @@ export function BrowsePage() {
             <h2 className="text-lg font-semibold truncate pr-4">
               {selectedPattern?.name || 'Pattern Details'}
             </h2>
-            <button
+            <Button
+              variant="ghost"
+              size="icon"
               onClick={handleClosePanel}
-              className="rounded-full w-10 h-10 flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
+              className="rounded-full text-muted-foreground"
             >
               <span className="material-icons-outlined">close</span>
-            </button>
+            </Button>
           </header>
 
           {selectedPattern && (
@@ -1131,12 +1209,14 @@ export function BrowsePage() {
               <h3 className="text-xl font-semibold">
                 {selectedPattern?.name || 'Animated Preview'}
               </h3>
-              <button
+              <Button
+                variant="ghost"
+                size="icon"
                 onClick={handleCloseAnimatedPreview}
-                className="text-muted-foreground hover:text-foreground transition-colors"
+                className="rounded-full"
               >
                 <span className="material-icons text-2xl">close</span>
-              </button>
+              </Button>
             </div>
 
             {/* Modal Content */}
@@ -1267,8 +1347,8 @@ function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClic
     <button
       ref={cardRef}
       onClick={onClick}
-      className={`group flex flex-col items-center gap-2 p-2 rounded-lg transition-all hover:-translate-y-1 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
-        isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''
+      className={`group flex flex-col items-center gap-2 p-2 rounded-lg transition-all duration-200 ease-out hover:-translate-y-1 hover:scale-[1.02] hover:shadow-lg hover:bg-accent/30 active:scale-95 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
+        isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background bg-accent/20' : ''
       }`}
     >
       <div className="relative w-full aspect-square">

+ 10 - 6
frontend/src/pages/PlaylistsPage.tsx

@@ -543,8 +543,10 @@ export function PlaylistsPage() {
                   <span className="truncate text-sm font-medium">{name}</span>
                 </div>
                 <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
-                  <button
-                    className="p-1 rounded hover:bg-muted-foreground/20"
+                  <Button
+                    variant="ghost"
+                    size="icon-sm"
+                    className="h-7 w-7"
                     onClick={(e) => {
                       e.stopPropagation()
                       setPlaylistToRename(name)
@@ -553,16 +555,18 @@ export function PlaylistsPage() {
                     }}
                   >
                     <span className="material-icons-outlined text-base">edit</span>
-                  </button>
-                  <button
-                    className="p-1 rounded hover:bg-destructive/20 text-destructive"
+                  </Button>
+                  <Button
+                    variant="ghost"
+                    size="icon-sm"
+                    className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
                     onClick={(e) => {
                       e.stopPropagation()
                       handleDeletePlaylist(name)
                     }}
                   >
                     <span className="material-icons-outlined text-base">delete</span>
-                  </button>
+                  </Button>
                 </div>
               </div>
             ))

+ 4 - 4
frontend/src/pages/TableControlPage.tsx

@@ -178,7 +178,7 @@ export function TableControlPage() {
         {/* Main Controls Grid - 2x2 */}
         <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
           {/* Primary Actions */}
-          <Card>
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg">Primary Actions</CardTitle>
               <CardDescription>Calibrate or stop the table</CardDescription>
@@ -226,7 +226,7 @@ export function TableControlPage() {
           </Card>
 
           {/* Speed Control */}
-          <Card>
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
             <CardHeader className="pb-3">
               <div className="flex items-center justify-between">
                 <div>
@@ -267,7 +267,7 @@ export function TableControlPage() {
           </Card>
 
           {/* Position */}
-          <Card>
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg">Position</CardTitle>
               <CardDescription>Move ball to a specific location</CardDescription>
@@ -402,7 +402,7 @@ export function TableControlPage() {
           </Card>
 
           {/* Clear Patterns */}
-          <Card>
+          <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
             <CardHeader className="pb-3">
               <CardTitle className="text-lg">Clear Sand</CardTitle>
               <CardDescription>Erase current pattern from the table</CardDescription>

+ 48 - 0
nginx.conf

@@ -0,0 +1,48 @@
+server {
+    listen 80;
+    server_name _;
+
+    # Frontend - serve static files
+    location / {
+        root /app/static/dist;
+        index index.html;
+        try_files $uri $uri/ /index.html;
+    }
+
+    # Serve static assets (legacy CSS, images, etc.)
+    location /static/ {
+        alias /app/static/;
+        expires 1d;
+        add_header Cache-Control "public, immutable";
+    }
+
+    # API proxy to backend
+    location /api/ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # WebSocket proxy
+    location /ws/ {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        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) {
+        proxy_pass http://127.0.0.1:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}

+ 16 - 0
start.sh

@@ -0,0 +1,16 @@
+#!/bin/bash
+set -e
+
+# Start nginx in background
+nginx -g "daemon off;" &
+NGINX_PID=$!
+
+# Start backend
+uvicorn main:app --host 0.0.0.0 --port 8080 &
+UVICORN_PID=$!
+
+# Wait for either process to exit
+wait -n $NGINX_PID $UVICORN_PID
+
+# If one exits, kill the other
+kill $NGINX_PID $UVICORN_PID 2>/dev/null || true