Jelajahi Sumber

Add play time badges, editable numeric inputs, and UI refinements

- Add play time badges on pattern cards showing last execution duration
- Make Speed, Intensity, Number of LEDs, and Pause inputs editable via typing
- Replace timezone datalist with SearchableSelect for better UX
- Add soft reset button to Control page (sends GRBL Ctrl+X)
- Change Home button to primary (blue) and Reset to secondary styling
- Reduce Select dropdown max-height from 384px to 256px
- Add /soft_reset and /api/pattern_history_all backend endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 minggu lalu
induk
melakukan
c6f9c0460b

+ 4 - 4
frontend/src/components/layout/Layout.tsx

@@ -455,8 +455,8 @@ export function Layout() {
     // Also reconnect when active table changes
   }, [isLogsOpen, activeTable?.id])
 
-  const handleOpenLogs = () => {
-    setIsLogsOpen(true)
+  const handleToggleLogs = () => {
+    setIsLogsOpen((prev) => !prev)
   }
 
   // Filter logs by level
@@ -1207,7 +1207,7 @@ export function Layout() {
             <Button
               variant="ghost"
               size="icon"
-              onClick={handleOpenLogs}
+              onClick={handleToggleLogs}
               className="rounded-full"
               aria-label="View logs"
               title="View Application Logs"
@@ -1282,7 +1282,7 @@ export function Layout() {
                   </button>
                   <button
                     onClick={() => {
-                      handleOpenLogs()
+                      handleToggleLogs()
                       setIsMobileMenuOpen(false)
                     }}
                     className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"

+ 5 - 4
frontend/src/components/ui/button.tsx

@@ -5,11 +5,12 @@ 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-full 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",
   {
     variants: {
       variant: {
-        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        default: "bg-card text-foreground border border-border shadow-sm hover:bg-accent",
+        primary: "bg-primary text-primary-foreground hover:bg-primary/90",
         destructive:
           "bg-destructive text-destructive-foreground hover:bg-destructive/90",
         outline:
@@ -21,8 +22,8 @@ const buttonVariants = cva(
       },
       size: {
         default: "h-10 px-4 py-2",
-        sm: "h-9 rounded-md px-3",
-        lg: "h-11 rounded-md px-8",
+        sm: "h-9 px-3",
+        lg: "h-11 px-8",
         icon: "h-10 w-10",
         "icon-sm": "h-8 w-8",
       },

+ 1 - 1
frontend/src/components/ui/input.tsx

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
       <input
         type={type}
         className={cn(
-          "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+          "flex h-10 w-full rounded-full border border-input bg-background px-4 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
           className
         )}
         ref={ref}

+ 1 - 1
frontend/src/components/ui/popover.tsx

@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
       align={align}
       sideOffset={sideOffset}
       className={cn(
-        "z-[100] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
+        "z-[100] w-72 rounded-2xl border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
         className
       )}
       {...props}

+ 2 - 2
frontend/src/components/ui/select.tsx

@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
   <SelectPrimitive.Trigger
     ref={ref}
     className={cn(
-      "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+      "flex h-10 w-full items-center justify-between rounded-full border border-input bg-background px-4 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
       className
     )}
     {...props}
@@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
     <SelectPrimitive.Content
       ref={ref}
       className={cn(
-        "relative z-[9999] max-h-[min(var(--radix-select-content-available-height,384px),384px)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
+        "relative z-[9999] max-h-[min(var(--radix-select-content-available-height,256px),256px)] min-w-[8rem] overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
         position === "popper" &&
           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
         className

+ 2 - 2
frontend/src/index.css

@@ -11,7 +11,7 @@
   --color-border: hsl(214.3 31.8% 91.4%);
   --color-input: hsl(214.3 31.8% 91.4%);
   --color-ring: hsl(207 90% 50%);
-  --color-background: hsl(0 0% 100%);
+  --color-background: hsl(220 14% 98%);
   --color-foreground: hsl(222.2 84% 4.9%);
 
   --color-primary: hsl(207 90% 50%);
@@ -20,7 +20,7 @@
   --color-secondary: hsl(210 40% 96.1%);
   --color-secondary-foreground: hsl(222.2 47.4% 11.2%);
 
-  --color-muted: hsl(210 40% 96.1%);
+  --color-muted: hsl(220 14% 92%);
   --color-muted-foreground: hsl(215.4 16.3% 46.9%);
 
   --color-accent: hsl(210 40% 96.1%);

+ 1 - 1
frontend/src/lib/types.ts

@@ -20,7 +20,7 @@ export interface Playlist {
   files: string[]
 }
 
-export type SortOption = 'name' | 'date' | 'category'
+export type SortOption = 'name' | 'date' | 'size'
 export type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 export type RunMode = 'single' | 'indefinite'
 

+ 62 - 10
frontend/src/pages/BrowsePage.tsx

@@ -46,7 +46,7 @@ interface PreviewData {
 // Coordinates come as [theta, rho] tuples from the backend
 type Coordinate = [number, number]
 
-type SortOption = 'name' | 'date' | 'category'
+type SortOption = 'name' | 'date' | 'size'
 type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 
 const preExecutionOptions: { value: PreExecution; label: string }[] = [
@@ -100,6 +100,12 @@ export function BrowsePage() {
     speed: number | null
   } | null>(null)
 
+  // All pattern histories for badges
+  const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
+    actual_time_formatted: string | null
+    timestamp: string | null
+  }>>({})
+
   // Canvas and animation refs
   const canvasRef = useRef<HTMLCanvasElement>(null)
   const animationRef = useRef<number | null>(null)
@@ -210,8 +216,13 @@ export function BrowsePage() {
   const fetchPatterns = async () => {
     setIsLoading(true)
     try {
-      const data = await apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata')
+      // Fetch patterns and history in parallel
+      const [data, historyData] = await Promise.all([
+        apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
+        apiClient.get<Record<string, { actual_time_formatted: string | null; timestamp: string | null }>>('/api/pattern_history_all')
+      ])
       setPatterns(data)
+      setAllPatternHistories(historyData)
 
       if (data.length > 0) {
         // Sort patterns by name (default sort) before preloading
@@ -355,8 +366,8 @@ export function BrowsePage() {
         case 'date':
           comparison = a.date_modified - b.date_modified
           break
-        case 'category':
-          comparison = a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
+        case 'size':
+          comparison = a.coordinates_count - b.coordinates_count
           break
         default:
           return 0
@@ -802,7 +813,7 @@ export function BrowsePage() {
   }
 
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-3 sm:px-4">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Hidden file input for pattern upload */}
       <input
         ref={fileInputRef}
@@ -836,7 +847,7 @@ export function BrowsePage() {
       </div>
 
       {/* Filter Bar */}
-      <div className="sticky top-[4.5rem] z-30 py-3 -mx-3 sm:-mx-4 px-3 sm:px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+      <div className="sticky top-[4.5rem] z-30 py-3 -mx-0 sm:-mx-4 px-0 sm:px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
         <div className="flex items-center gap-2 sm:gap-3">
           {/* Search - Pill shaped, white background */}
           <div className="relative flex-1 min-w-0">
@@ -884,8 +895,8 @@ export function BrowsePage() {
             </SelectTrigger>
             <SelectContent>
               <SelectItem value="name">Name</SelectItem>
-              <SelectItem value="date">Date</SelectItem>
-              <SelectItem value="category">Category</SelectItem>
+              <SelectItem value="date">Modified</SelectItem>
+              <SelectItem value="size">Size</SelectItem>
             </SelectContent>
           </Select>
 
@@ -965,6 +976,7 @@ export function BrowsePage() {
                 pattern={pattern}
                 isSelected={selectedPattern?.path === pattern.path}
                 isFavorite={favorites.has(pattern.path)}
+                playTime={allPatternHistories[pattern.path.split('/').pop() || '']?.actual_time_formatted || null}
                 onToggleFavorite={toggleFavorite}
                 onClick={() => handlePatternClick(pattern)}
               />
@@ -1251,11 +1263,12 @@ interface PatternCardProps {
   pattern: PatternMetadata
   isSelected: boolean
   isFavorite: boolean
+  playTime: string | null
   onToggleFavorite: (path: string, e: React.MouseEvent) => void
   onClick: () => void
 }
 
-function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClick }: PatternCardProps) {
+function PatternCard({ pattern, isSelected, isFavorite, playTime, onToggleFavorite, onClick }: PatternCardProps) {
   const [imageLoaded, setImageLoaded] = useState(false)
   const [imageError, setImageError] = useState(false)
   const cardRef = useRef<HTMLButtonElement>(null)
@@ -1288,7 +1301,7 @@ function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClic
     <button
       ref={cardRef}
       onClick={onClick}
-      className={`group flex flex-col items-center gap-2 p-2.5 rounded-xl bg-card border border-border shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-lg active:scale-95 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
+      className={`group flex flex-col items-center gap-2 p-2.5 rounded-xl bg-card border border-border transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-md 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' : ''
       }`}
     >
@@ -1322,6 +1335,45 @@ function PatternCard({ pattern, isSelected, isFavorite, onToggleFavorite, onClic
             </div>
           )}
         </div>
+
+        {/* Play time badge */}
+        {playTime && (
+          <div className="absolute -top-1 -right-1 bg-card/90 backdrop-blur-sm text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-border shadow-sm">
+            {(() => {
+              // Parse time and convert to minutes only
+              // Try MM:SS or HH:MM:SS format first (e.g., "15:48" or "1:15:48")
+              const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
+              if (colonMatch) {
+                const hours = colonMatch[1] ? parseInt(colonMatch[1]) : 0
+                const minutes = parseInt(colonMatch[2])
+                const seconds = parseInt(colonMatch[3])
+                const totalMins = hours * 60 + minutes + (seconds >= 30 ? 1 : 0)
+                return totalMins > 0 ? `${totalMins}m` : '<1m'
+              }
+
+              // Try text-based formats
+              const match = playTime.match(/(\d+)h\s*(\d+)m|(\d+)\s*min|(\d+)m\s*(\d+)s|(\d+)\s*sec/)
+              if (match) {
+                if (match[1] && match[2]) {
+                  // "Xh Ym" format
+                  return `${parseInt(match[1]) * 60 + parseInt(match[2])}m`
+                } else if (match[3]) {
+                  // "X min" format
+                  return `${match[3]}m`
+                } else if (match[4] && match[5]) {
+                  // "Xm Ys" format - round to minutes
+                  const mins = parseInt(match[4])
+                  return mins > 0 ? `${mins}m` : '<1m'
+                } else if (match[6]) {
+                  // seconds only
+                  return '<1m'
+                }
+              }
+              // Fallback: show original
+              return playTime
+            })()}
+          </div>
+        )}
       </div>
 
       {/* Name and favorite row */}

+ 77 - 8
frontend/src/pages/LEDPage.tsx

@@ -66,7 +66,9 @@ export function LEDPage() {
   const [palettes, setPalettes] = useState<[number, string][]>([])
   const [brightness, setBrightness] = useState(35)
   const [speed, setSpeed] = useState(128)
+  const [speedInput, setSpeedInput] = useState('128')
   const [intensity, setIntensity] = useState(128)
+  const [intensityInput, setIntensityInput] = useState('128')
   const [selectedEffect, setSelectedEffect] = useState('')
   const [selectedPalette, setSelectedPalette] = useState('')
   const [color1, setColor1] = useState('#ff0000')
@@ -81,6 +83,7 @@ export function LEDPage() {
   const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
   const [idleTimeoutEnabled, setIdleTimeoutEnabled] = useState(false)
   const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
+  const [idleTimeoutInput, setIdleTimeoutInput] = useState('30')
 
   // Fetch LED configuration
   useEffect(() => {
@@ -120,7 +123,9 @@ export function LEDPage() {
       if (data.connected) {
         setBrightness(data.brightness || 35)
         setSpeed(data.speed || 128)
+        setSpeedInput(String(data.speed || 128))
         setIntensity(data.intensity || 128)
+        setIntensityInput(String(data.intensity || 128))
         setSelectedEffect(String(data.current_effect || 0))
         setSelectedPalette(String(data.current_palette || 0))
         if (data.colors) {
@@ -169,6 +174,7 @@ export function LEDPage() {
       const data = await apiClient.get<{ enabled?: boolean; minutes?: number }>('/api/dw_leds/idle_timeout')
       setIdleTimeoutEnabled(data.enabled || false)
       setIdleTimeoutMinutes(data.minutes || 30)
+      setIdleTimeoutInput(String(data.minutes || 30))
     } catch (error) {
       console.error('Error fetching idle timeout:', error)
     }
@@ -205,6 +211,7 @@ export function LEDPage() {
 
   const handleSpeedChange = useCallback((value: number[]) => {
     setSpeed(value[0])
+    setSpeedInput(String(value[0]))
   }, [])
 
   const handleSpeedCommit = async (value: number[]) => {
@@ -218,6 +225,7 @@ export function LEDPage() {
 
   const handleIntensityChange = useCallback((value: number[]) => {
     setIntensity(value[0])
+    setIntensityInput(String(value[0]))
   }, [])
 
   const handleIntensityCommit = async (value: number[]) => {
@@ -393,7 +401,7 @@ export function LEDPage() {
 
   // DW LEDs control panel
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-3 sm:px-4">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Page Header */}
       <div className="space-y-0.5 sm:space-y-1 pl-1">
         <h1 className="text-xl font-semibold tracking-tight">LED Control</h1>
@@ -514,7 +522,30 @@ export function LEDPage() {
                       <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">speed</span>
                       Speed
                     </Label>
-                    <span className="text-sm font-medium">{speed}</span>
+                    <Input
+                      type="text"
+                      inputMode="numeric"
+                      value={speedInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setSpeedInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(255, Math.max(0, parseInt(speedInput) || 0))
+                        setSpeed(num)
+                        setSpeedInput(String(num))
+                        handleSpeedCommit([num])
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(255, Math.max(0, parseInt(speedInput) || 0))
+                          setSpeed(num)
+                          setSpeedInput(String(num))
+                          handleSpeedCommit([num])
+                        }
+                      }}
+                      className="w-16 h-7 text-center text-sm font-medium px-2"
+                    />
                   </div>
                   <Slider
                     value={[speed]}
@@ -530,7 +561,30 @@ export function LEDPage() {
                       <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">tungsten</span>
                       Intensity
                     </Label>
-                    <span className="text-sm font-medium">{intensity}</span>
+                    <Input
+                      type="text"
+                      inputMode="numeric"
+                      value={intensityInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setIntensityInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(255, Math.max(0, parseInt(intensityInput) || 0))
+                        setIntensity(num)
+                        setIntensityInput(String(num))
+                        handleIntensityCommit([num])
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(255, Math.max(0, parseInt(intensityInput) || 0))
+                          setIntensity(num)
+                          setIntensityInput(String(num))
+                          handleIntensityCommit([num])
+                        }
+                      }}
+                      className="w-16 h-7 text-center text-sm font-medium px-2"
+                    />
                   </div>
                   <Slider
                     value={[intensity]}
@@ -601,11 +655,26 @@ export function LEDPage() {
               {idleTimeoutEnabled && (
                 <div className="flex items-center gap-2">
                   <Input
-                    type="number"
-                    value={idleTimeoutMinutes}
-                    onChange={(e) => setIdleTimeoutMinutes(parseInt(e.target.value) || 30)}
-                    min={1}
-                    max={1440}
+                    type="text"
+                    inputMode="numeric"
+                    value={idleTimeoutInput}
+                    onChange={(e) => {
+                      const val = e.target.value.replace(/[^0-9]/g, '')
+                      setIdleTimeoutInput(val)
+                    }}
+                    onBlur={() => {
+                      const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
+                      setIdleTimeoutMinutes(num)
+                      setIdleTimeoutInput(String(num))
+                    }}
+                    onKeyDown={(e) => {
+                      if (e.key === 'Enter') {
+                        const num = Math.min(1440, Math.max(1, parseInt(idleTimeoutInput) || 30))
+                        setIdleTimeoutMinutes(num)
+                        setIdleTimeoutInput(String(num))
+                        saveIdleTimeout(idleTimeoutEnabled, num)
+                      }
+                    }}
                     className="w-20"
                   />
                   <span className="text-sm text-muted-foreground flex-1">minutes</span>

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

@@ -451,8 +451,8 @@ export function PlaylistsPage() {
         case 'date':
           cmp = a.date_modified - b.date_modified
           break
-        case 'category':
-          cmp = a.category.localeCompare(b.category)
+        case 'size':
+          cmp = a.coordinates_count - b.coordinates_count
           break
       }
       return sortAsc ? cmp : -cmp
@@ -474,7 +474,7 @@ export function PlaylistsPage() {
   }
 
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-3 sm:px-4 h-[calc(100dvh-7rem)] overflow-hidden">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 h-[calc(100dvh-7rem)] overflow-hidden">
       {/* Page Header */}
       <div className="space-y-0.5 sm:space-y-1 shrink-0 pl-1">
         <h1 className="text-xl font-semibold tracking-tight">Playlists</h1>
@@ -662,7 +662,7 @@ export function PlaylistsPage() {
           {selectedPlaylist && (
             <div className="absolute bottom-0 left-0 right-0 pointer-events-none z-20">
               {/* Blur backdrop */}
-              <div className="h-20 bg-gradient-to-t backdrop-blur-sm" />
+              <div className="h-20 bg-gradient-to-t" />
 
               {/* Controls container */}
               <div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-3 px-4 pointer-events-auto">
@@ -878,8 +878,8 @@ export function PlaylistsPage() {
                   </SelectTrigger>
                   <SelectContent>
                     <SelectItem value="name">Name</SelectItem>
-                    <SelectItem value="date">Date</SelectItem>
-                    <SelectItem value="category">Category</SelectItem>
+                    <SelectItem value="date">Modified</SelectItem>
+                    <SelectItem value="size">Size</SelectItem>
                   </SelectContent>
                 </Select>
                 <Button

+ 95 - 70
frontend/src/pages/SettingsPage.tsx

@@ -109,6 +109,7 @@ export function SettingsPage() {
   // Settings state
   const [settings, setSettings] = useState<Settings>({})
   const [ledConfig, setLedConfig] = useState<LedConfig>({ provider: 'none', gpio_pin: 18 })
+  const [numLedsInput, setNumLedsInput] = useState('60')
   const [mqttConfig, setMqttConfig] = useState<MqttConfig>({ enabled: false })
 
   // UI state
@@ -134,6 +135,7 @@ export function SettingsPage() {
   })
   const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
   const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
+  const [autoPlayPauseInput, setAutoPlayPauseInput] = useState('5')
   const [playlists, setPlaylists] = useState<string[]>([])
 
   // Convert pause time from seconds to value + unit for display
@@ -349,6 +351,7 @@ export function SettingsPage() {
         const pauseSeconds = data.auto_play.pause_time ?? 300 // Default 5 minutes
         const { value, unit } = secondsToDisplayPause(pauseSeconds)
         setAutoPlayPauseValue(value)
+        setAutoPlayPauseInput(String(value))
         setAutoPlayPauseUnit(unit)
         setAutoPlaySettings({
           enabled: data.auto_play.enabled || false,
@@ -398,6 +401,7 @@ export function SettingsPage() {
         gpio_pin: data.dw_led_gpio_pin,
         pixel_order: data.dw_led_pixel_order,
       })
+      setNumLedsInput(String(data.dw_led_num_leds || 60))
     } catch (error) {
       console.error('Error fetching LED config:', error)
     }
@@ -719,7 +723,7 @@ export function SettingsPage() {
   }
 
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-3 sm:px-4">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
       {/* Page Header */}
       <div className="space-y-0.5 sm:space-y-1 pl-1">
         <h1 className="text-xl font-semibold tracking-tight">Settings</h1>
@@ -755,7 +759,7 @@ export function SettingsPage() {
             {/* Connection Status */}
             <div className="flex items-center justify-between p-4 rounded-lg border">
               <div className="flex items-center gap-3">
-                <div className={`p-2 rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
+                <div className={`w-10 h-10 flex items-center justify-center rounded-lg ${isConnected ? 'bg-green-100 dark:bg-green-900' : 'bg-muted'}`}>
                   <span className={`material-icons ${isConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
                     {isConnected ? 'usb' : 'usb_off'}
                   </span>
@@ -1403,13 +1407,25 @@ export function SettingsPage() {
                     <Label htmlFor="numLeds">Number of LEDs</Label>
                     <Input
                       id="numLeds"
-                      type="number"
-                      value={ledConfig.num_leds || 60}
-                      onChange={(e) =>
-                        setLedConfig({ ...ledConfig, num_leds: parseInt(e.target.value) })
-                      }
-                      min={1}
-                      max={1000}
+                      type="text"
+                      inputMode="numeric"
+                      value={numLedsInput}
+                      onChange={(e) => {
+                        const val = e.target.value.replace(/[^0-9]/g, '')
+                        setNumLedsInput(val)
+                      }}
+                      onBlur={() => {
+                        const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
+                        setLedConfig({ ...ledConfig, num_leds: num })
+                        setNumLedsInput(String(num))
+                      }}
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter') {
+                          const num = Math.min(1000, Math.max(1, parseInt(numLedsInput) || 60))
+                          setLedConfig({ ...ledConfig, num_leds: num })
+                          setNumLedsInput(String(num))
+                        }
+                      }}
                     />
                   </div>
                   <div className="space-y-3">
@@ -1725,10 +1741,25 @@ export function SettingsPage() {
                     <Label>Pause Between Patterns</Label>
                     <div className="flex gap-2">
                       <Input
-                        type="number"
-                        min="0"
-                        value={autoPlayPauseValue}
-                        onChange={(e) => setAutoPlayPauseValue(Number(e.target.value) || 0)}
+                        type="text"
+                        inputMode="numeric"
+                        value={autoPlayPauseInput}
+                        onChange={(e) => {
+                          const val = e.target.value.replace(/[^0-9]/g, '')
+                          setAutoPlayPauseInput(val)
+                        }}
+                        onBlur={() => {
+                          const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                          setAutoPlayPauseValue(num)
+                          setAutoPlayPauseInput(String(num))
+                        }}
+                        onKeyDown={(e) => {
+                          if (e.key === 'Enter') {
+                            const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                            setAutoPlayPauseValue(num)
+                            setAutoPlayPauseInput(String(num))
+                          }
+                        }}
                         className="w-20"
                       />
                       <Select
@@ -1893,64 +1924,58 @@ export function SettingsPage() {
                       <div>
                         <p className="text-sm font-medium">Timezone</p>
                         <p className="text-xs text-muted-foreground">
-                          Select or type a timezone (e.g., UTC+5, America/New_York)
+                          Select a timezone for scheduling
                         </p>
                       </div>
                     </div>
-                    <div className="relative">
-                      <Input
-                        list="timezone-options"
-                        value={stillSandsSettings.timezone || ''}
-                        onChange={(e) =>
-                          setStillSandsSettings({ ...stillSandsSettings, timezone: e.target.value })
-                        }
-                        placeholder="System Default"
-                        className="w-full sm:w-[200px]"
-                      />
-                      <datalist id="timezone-options">
-                        {/* UTC Offsets */}
-                        <option value="Etc/GMT+12">UTC-12</option>
-                        <option value="Etc/GMT+11">UTC-11</option>
-                        <option value="Etc/GMT+10">UTC-10</option>
-                        <option value="Etc/GMT+9">UTC-9</option>
-                        <option value="Etc/GMT+8">UTC-8</option>
-                        <option value="Etc/GMT+7">UTC-7</option>
-                        <option value="Etc/GMT+6">UTC-6</option>
-                        <option value="Etc/GMT+5">UTC-5</option>
-                        <option value="Etc/GMT+4">UTC-4</option>
-                        <option value="Etc/GMT+3">UTC-3</option>
-                        <option value="Etc/GMT+2">UTC-2</option>
-                        <option value="Etc/GMT+1">UTC-1</option>
-                        <option value="UTC">UTC</option>
-                        <option value="Etc/GMT-1">UTC+1</option>
-                        <option value="Etc/GMT-2">UTC+2</option>
-                        <option value="Etc/GMT-3">UTC+3</option>
-                        <option value="Etc/GMT-4">UTC+4</option>
-                        <option value="Etc/GMT-5">UTC+5</option>
-                        <option value="Etc/GMT-6">UTC+6</option>
-                        <option value="Etc/GMT-7">UTC+7</option>
-                        <option value="Etc/GMT-8">UTC+8</option>
-                        <option value="Etc/GMT-9">UTC+9</option>
-                        <option value="Etc/GMT-10">UTC+10</option>
-                        <option value="Etc/GMT-11">UTC+11</option>
-                        <option value="Etc/GMT-12">UTC+12</option>
-                        {/* Americas */}
-                        <option value="America/New_York">America/New_York (Eastern)</option>
-                        <option value="America/Chicago">America/Chicago (Central)</option>
-                        <option value="America/Denver">America/Denver (Mountain)</option>
-                        <option value="America/Los_Angeles">America/Los_Angeles (Pacific)</option>
-                        {/* Europe */}
-                        <option value="Europe/London">Europe/London</option>
-                        <option value="Europe/Paris">Europe/Paris</option>
-                        <option value="Europe/Berlin">Europe/Berlin</option>
-                        {/* Asia */}
-                        <option value="Asia/Tokyo">Asia/Tokyo</option>
-                        <option value="Asia/Shanghai">Asia/Shanghai</option>
-                        <option value="Asia/Singapore">Asia/Singapore</option>
-                        {/* Australia */}
-                        <option value="Australia/Sydney">Australia/Sydney</option>
-                      </datalist>
-                    </div>
+                    <SearchableSelect
+                      value={stillSandsSettings.timezone || ''}
+                      onValueChange={(value) =>
+                        setStillSandsSettings({ ...stillSandsSettings, timezone: value })
+                      }
+                      placeholder="System Default"
+                      searchPlaceholder="Search timezones..."
+                      className="w-full sm:w-[200px]"
+                      options={[
+                        { value: '', label: 'System Default' },
+                        { value: 'Etc/GMT+12', label: 'UTC-12' },
+                        { value: 'Etc/GMT+11', label: 'UTC-11' },
+                        { value: 'Etc/GMT+10', label: 'UTC-10' },
+                        { value: 'Etc/GMT+9', label: 'UTC-9' },
+                        { value: 'Etc/GMT+8', label: 'UTC-8' },
+                        { value: 'Etc/GMT+7', label: 'UTC-7' },
+                        { value: 'Etc/GMT+6', label: 'UTC-6' },
+                        { value: 'Etc/GMT+5', label: 'UTC-5' },
+                        { value: 'Etc/GMT+4', label: 'UTC-4' },
+                        { value: 'Etc/GMT+3', label: 'UTC-3' },
+                        { value: 'Etc/GMT+2', label: 'UTC-2' },
+                        { value: 'Etc/GMT+1', label: 'UTC-1' },
+                        { value: 'UTC', label: 'UTC' },
+                        { value: 'Etc/GMT-1', label: 'UTC+1' },
+                        { value: 'Etc/GMT-2', label: 'UTC+2' },
+                        { value: 'Etc/GMT-3', label: 'UTC+3' },
+                        { value: 'Etc/GMT-4', label: 'UTC+4' },
+                        { value: 'Etc/GMT-5', label: 'UTC+5' },
+                        { value: 'Etc/GMT-6', label: 'UTC+6' },
+                        { value: 'Etc/GMT-7', label: 'UTC+7' },
+                        { value: 'Etc/GMT-8', label: 'UTC+8' },
+                        { value: 'Etc/GMT-9', label: 'UTC+9' },
+                        { value: 'Etc/GMT-10', label: 'UTC+10' },
+                        { value: 'Etc/GMT-11', label: 'UTC+11' },
+                        { value: 'Etc/GMT-12', label: 'UTC+12' },
+                        { value: 'America/New_York', label: 'America/New_York (Eastern)' },
+                        { value: 'America/Chicago', label: 'America/Chicago (Central)' },
+                        { value: 'America/Denver', label: 'America/Denver (Mountain)' },
+                        { value: 'America/Los_Angeles', label: 'America/Los_Angeles (Pacific)' },
+                        { value: 'Europe/London', label: 'Europe/London' },
+                        { value: 'Europe/Paris', label: 'Europe/Paris' },
+                        { value: 'Europe/Berlin', label: 'Europe/Berlin' },
+                        { value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
+                        { value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
+                        { value: 'Asia/Singapore', label: 'Asia/Singapore' },
+                        { value: 'Australia/Sydney', label: 'Australia/Sydney' },
+                      ]}
+                    />
                   </div>
                 </div>
 
@@ -2088,7 +2113,7 @@ export function SettingsPage() {
           </AccordionTrigger>
           <AccordionContent className="pt-4 pb-6 space-y-3">
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
-              <div className="p-2 bg-background rounded-lg">
+              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
                 <span className="material-icons text-muted-foreground">terminal</span>
               </div>
               <div className="flex-1">
@@ -2100,7 +2125,7 @@ export function SettingsPage() {
             </div>
 
             <div className="flex items-center gap-4 p-4 rounded-lg bg-muted/50">
-              <div className="p-2 bg-background rounded-lg">
+              <div className="w-10 h-10 flex items-center justify-center bg-background rounded-lg">
                 <span className="material-icons text-muted-foreground">system_update</span>
               </div>
               <div className="flex-1">

+ 36 - 15
frontend/src/pages/TableControlPage.tsx

@@ -165,6 +165,15 @@ export function TableControlPage() {
     }
   }
 
+  const handleSoftReset = async () => {
+    try {
+      await handleAction('reset', '/soft_reset')
+      toast.success('Reset sent. Homing required.')
+    } catch {
+      toast.error('Failed to send reset')
+    }
+  }
+
   const handleMoveToCenter = async () => {
     if (checkPatternRunning('move to center')) return
     try {
@@ -371,7 +380,7 @@ export function TableControlPage() {
 
   return (
     <TooltipProvider>
-      <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-3 sm:px-4">
+      <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
         {/* Page Header */}
         <div className="space-y-0.5 sm:space-y-1 pl-1">
           <h1 className="text-xl font-semibold tracking-tight">Table Control</h1>
@@ -391,12 +400,13 @@ export function TableControlPage() {
               <CardDescription>Calibrate or stop the table</CardDescription>
             </CardHeader>
             <CardContent>
-              <div className="grid grid-cols-2 gap-3">
+              <div className="grid grid-cols-3 gap-3">
                 <Tooltip>
                   <TooltipTrigger asChild>
                     <Button
                       onClick={handleHome}
                       disabled={isLoading === 'home'}
+                      variant="primary"
                       className="h-16 gap-1 flex-col items-center justify-center"
                     >
                       {isLoading === 'home' ? (
@@ -426,7 +436,26 @@ export function TableControlPage() {
                       <span className="text-xs">Stop</span>
                     </Button>
                   </TooltipTrigger>
-                  <TooltipContent>Emergency stop</TooltipContent>
+                  <TooltipContent>Gracefully stop</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleSoftReset}
+                      disabled={isLoading === 'reset'}
+                      variant="secondary"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'reset' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">restart_alt</span>
+                      )}
+                      <span className="text-xs">Reset</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Reset DLC32/ESP32, requires homing</TooltipContent>
                 </Tooltip>
               </div>
             </CardContent>
@@ -697,18 +726,19 @@ export function TableControlPage() {
                     onClick={() => setSerialHistory([])}
                     title="Clear history"
                   >
-                    <span className="material-icons-outlined">delete</span>
+                    <span className="material-icons-outlined">delete_sweep</span>
                   </Button>
                 )}
               </div>
             </div>
             {/* Controls row - stacks better on mobile */}
             <div className="flex flex-wrap items-center gap-2">
-              {/* Port selector */}
+              {/* Port selector - auto-refreshes on focus */}
               <select
-                className="h-9 flex-1 min-w-[140px] max-w-[200px] rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
+                className="h-9 flex-1 min-w-[140px] max-w-[200px] rounded-full border border-input bg-background px-4 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
                 value={selectedSerialPort}
                 onChange={(e) => setSelectedSerialPort(e.target.value)}
+                onFocus={fetchSerialPorts}
                 disabled={serialConnected || serialLoading}
               >
                 <option value="">Select port...</option>
@@ -716,15 +746,6 @@ export function TableControlPage() {
                   <option key={port} value={port}>{port}</option>
                 ))}
               </select>
-              <Button
-                variant="ghost"
-                size="icon"
-                onClick={fetchSerialPorts}
-                disabled={serialConnected || serialLoading}
-                title="Refresh ports"
-              >
-                <span className="material-icons-outlined">refresh</span>
-              </Button>
               {!serialConnected ? (
                 <Button
                   size="sm"

+ 1 - 0
frontend/vite.config.ts

@@ -67,6 +67,7 @@ export default defineConfig({
       '/send_home': 'http://localhost:8080',
       '/send_coordinate': 'http://localhost:8080',
       '/stop_execution': 'http://localhost:8080',
+      '/soft_reset': 'http://localhost:8080',
       '/pause_execution': 'http://localhost:8080',
       '/resume_execution': 'http://localhost:8080',
       '/skip_pattern': 'http://localhost:8080',

+ 62 - 0
main.py

@@ -1601,6 +1601,29 @@ async def stop_execution():
     await pattern_manager.stop_actions()
     return {"success": True}
 
+@app.post("/soft_reset")
+async def soft_reset():
+    """Send Ctrl+X soft reset to the controller (DLC32/ESP32). Requires re-homing after."""
+    if not (state.conn.is_connected() if state.conn else False):
+        logger.warning("Attempted to soft reset without a connection")
+        raise HTTPException(status_code=400, detail="Connection not established")
+
+    try:
+        # Stop any running patterns first
+        await pattern_manager.stop_actions()
+
+        # Send Ctrl+X (0x18) - GRBL/FluidNC soft reset command
+        state.conn.send('\x18')
+        logger.info("Soft reset command (Ctrl+X) sent to controller")
+
+        # Mark as needing homing since position is now unknown
+        state.is_homed = False
+
+        return {"success": True, "message": "Soft reset sent. Homing required."}
+    except Exception as e:
+        logger.error(f"Error sending soft reset: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
 @app.post("/send_home")
 async def send_home():
     try:
@@ -1803,6 +1826,45 @@ async def get_pattern_history(pattern_name: str):
         return history
     return {"actual_time_seconds": None, "actual_time_formatted": None, "speed": None, "timestamp": None}
 
+@app.get("/api/pattern_history_all")
+async def get_all_pattern_history():
+    """Get execution history for all patterns in a single request.
+
+    Returns a dict mapping pattern names to their most recent execution history.
+    """
+    from modules.core.pattern_manager import EXECUTION_LOG_FILE
+    import json
+
+    if not os.path.exists(EXECUTION_LOG_FILE):
+        return {}
+
+    try:
+        history_map = {}
+        with open(EXECUTION_LOG_FILE, 'r') as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    entry = json.loads(line)
+                    # Only consider fully completed patterns
+                    if entry.get('completed', False):
+                        pattern_name = entry.get('pattern_name')
+                        if pattern_name:
+                            # Keep the most recent match (last one in file wins)
+                            history_map[pattern_name] = {
+                                "actual_time_seconds": entry.get('actual_time_seconds'),
+                                "actual_time_formatted": entry.get('actual_time_formatted'),
+                                "speed": entry.get('speed'),
+                                "timestamp": entry.get('timestamp')
+                            }
+                except json.JSONDecodeError:
+                    continue
+        return history_map
+    except Exception as e:
+        logger.error(f"Failed to read execution time log: {e}")
+        return {}
+
 @app.get("/preview/{encoded_filename}")
 async def serve_preview(encoded_filename: str):
     """Serve a preview image for a pattern file."""