소스 검색

Improve UI consistency and mobile experience

- NowPlayingBar: Add pattern preview in expanded view, optimistic drag-drop
- NowPlayingBar: Show move to top/bottom buttons always on mobile
- Layout: Unify desktop/mobile header with same 3-icon layout (play, table, menu)
- Layout: Tighter icon spacing, fix active table name display
- TableSelector: Remove dropdown arrow, move checkmark to far right
- BrowsePage: Add swipe to dismiss for pattern detail sheet

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 주 전
부모
커밋
f5ecbebb8a
4개의 변경된 파일190개의 추가작업 그리고 82개의 파일을 삭제
  1. 102 27
      frontend/src/components/NowPlayingBar.tsx
  2. 6 8
      frontend/src/components/TableSelector.tsx
  3. 54 46
      frontend/src/components/layout/Layout.tsx
  4. 28 1
      frontend/src/pages/BrowsePage.tsx

+ 102 - 27
frontend/src/components/NowPlayingBar.tsx

@@ -153,8 +153,8 @@ function SortableQueueItem({
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
       </div>
 
-      {/* Move to top/bottom buttons - visible on hover */}
-      <div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
+      {/* Move to top/bottom buttons - always visible on mobile, hover on desktop */}
+      <div className="flex flex-col gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
         <button
           onClick={onMoveToTop}
           disabled={isFirst}
@@ -688,6 +688,42 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
   const [showQueue, setShowQueue] = useState(false)
   const [queuePreviews, setQueuePreviews] = useState<Record<string, string>>({})
 
+  // Optimistic queue state for smooth drag-and-drop
+  const [optimisticQueue, setOptimisticQueue] = useState<string[] | null>(null)
+  const optimisticTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  // Sync optimistic queue with server state after a delay
+  // This allows the optimistic update to "stick" while the server catches up
+  useEffect(() => {
+    if (optimisticQueue && status?.playlist?.files) {
+      // Clear any pending timeout
+      if (optimisticTimeoutRef.current) {
+        clearTimeout(optimisticTimeoutRef.current)
+      }
+      // After server confirms (via WebSocket), clear optimistic state
+      // We check if server state matches our optimistic state
+      const serverOrder = status.playlist.files.join(',')
+      const optimisticOrder = optimisticQueue.join(',')
+      if (serverOrder === optimisticOrder) {
+        // Server caught up, clear optimistic state
+        setOptimisticQueue(null)
+      } else {
+        // Give server time to catch up, then accept server state
+        optimisticTimeoutRef.current = setTimeout(() => {
+          setOptimisticQueue(null)
+        }, 2000)
+      }
+    }
+    return () => {
+      if (optimisticTimeoutRef.current) {
+        clearTimeout(optimisticTimeoutRef.current)
+      }
+    }
+  }, [status?.playlist?.files, optimisticQueue])
+
+  // Use optimistic queue if available, otherwise use server state
+  const displayQueue = optimisticQueue || status?.playlist?.files || []
+
   // Drag and drop sensors
   const sensors = useSensors(
     useSensor(PointerSensor, {
@@ -757,6 +793,14 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
     }
   }, [showQueue, status?.playlist?.files])
 
+  // Helper to reorder array (move item from one index to another)
+  const reorderArray = (arr: string[], fromIndex: number, toIndex: number): string[] => {
+    const result = [...arr]
+    const [removed] = result.splice(fromIndex, 1)
+    result.splice(toIndex, 0, removed)
+    return result
+  }
+
   // Handle drag end for reordering queue
   // Since playlist now contains only main patterns, indices map directly
   const handleDragEnd = async (event: DragEndEvent) => {
@@ -782,25 +826,40 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
       return
     }
 
+    // Optimistically update the queue immediately
+    const currentQueue = optimisticQueue || status.playlist.files
+    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
+    setOptimisticQueue(newQueue)
+
     try {
       await apiClient.post('/reorder_playlist', {
         from_index: fromIndex,
         to_index: toIndex
       })
     } catch {
+      // Revert optimistic update on failure
+      setOptimisticQueue(null)
       toast.error('Failed to reorder')
     }
   }
 
   // Helper to move queue item to a specific position
   const moveToPosition = async (fromIndex: number, toIndex: number) => {
-    if (fromIndex === toIndex) return
+    if (fromIndex === toIndex || !status?.playlist?.files) return
+
+    // Optimistically update the queue immediately
+    const currentQueue = optimisticQueue || status.playlist.files
+    const newQueue = reorderArray(currentQueue, fromIndex, toIndex)
+    setOptimisticQueue(newQueue)
+
     try {
       await apiClient.post('/reorder_playlist', {
         from_index: fromIndex,
         to_index: toIndex
       })
     } catch {
+      // Revert optimistic update on failure
+      setOptimisticQueue(null)
       toast.error('Failed to reorder')
     }
   }
@@ -1105,28 +1164,44 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                 {/* Controls */}
                 <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
                 {/* Pattern Info */}
-                <div className="text-center">
-                  {isWaiting ? (
-                    <>
-                      <h2 className="text-lg md:text-xl font-semibold text-muted-foreground">
-                        Waiting for next pattern...
-                      </h2>
-                      {status?.playlist?.next_file && (
-                        <p className="text-sm text-muted-foreground">
-                          Up next: {formatPatternName(status.playlist.next_file)}
-                        </p>
-                      )}
-                    </>
-                  ) : (
-                    <>
-                      <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
-                      {status?.playlist && (
-                        <p className="text-sm text-muted-foreground">
-                          Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
-                        </p>
-                      )}
-                    </>
-                  )}
+                <div className="flex items-center justify-center gap-3">
+                  {/* Current pattern preview */}
+                  <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
+                    {previewUrl ? (
+                      <img
+                        src={previewUrl}
+                        alt={patternName}
+                        className="w-full h-full object-cover pattern-preview"
+                      />
+                    ) : (
+                      <div className="w-full h-full flex items-center justify-center">
+                        <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                      </div>
+                    )}
+                  </div>
+                  <div className="text-left min-w-0">
+                    {isWaiting ? (
+                      <>
+                        <h2 className="text-lg md:text-xl font-semibold text-muted-foreground">
+                          Waiting for next pattern...
+                        </h2>
+                        {status?.playlist?.next_file && (
+                          <p className="text-sm text-muted-foreground">
+                            Up next: {formatPatternName(status.playlist.next_file)}
+                          </p>
+                        )}
+                      </>
+                    ) : (
+                      <>
+                        <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
+                        {status?.playlist && (
+                          <p className="text-sm text-muted-foreground">
+                            Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                          </p>
+                        )}
+                      </>
+                    )}
+                  </div>
                 </div>
 
                 {/* Progress */}
@@ -1255,11 +1330,11 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
           </DialogHeader>
 
           <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2">
-            {status?.playlist?.files && status.playlist.files.length > 0 ? (
+            {status?.playlist && displayQueue.length > 0 ? (
               (() => {
                 // Only show upcoming patterns (after current)
                 const currentIndex = status.playlist!.current_index
-                const upcomingFiles = status.playlist!.files
+                const upcomingFiles = displayQueue
                   .map((file, index) => ({ file, index }))
                   .filter(({ index }) => index > currentIndex)
 

+ 6 - 8
frontend/src/components/TableSelector.tsx

@@ -31,7 +31,6 @@ import {
   WifiOff,
   Pencil,
   Trash2,
-  ChevronDown,
 } from 'lucide-react'
 
 export function TableSelector() {
@@ -124,13 +123,12 @@ export function TableSelector() {
           <Button
             variant="ghost"
             size="sm"
-            className="gap-2 h-9 px-3"
+            className="gap-2 h-9 px-2"
           >
             <Layers className="h-4 w-4" />
             <span className="hidden sm:inline max-w-[120px] truncate">
               {activeTable?.appName || activeTable?.name || 'Select Table'}
             </span>
-            <ChevronDown className="h-3 w-3 opacity-50" />
           </Button>
         </PopoverTrigger>
         <PopoverContent className="w-72 p-2" align="end">
@@ -172,11 +170,6 @@ export function TableSelector() {
                     </span>
                   </div>
 
-                  {/* Selected indicator */}
-                  {activeTable?.id === table.id && (
-                    <Check className="h-4 w-4 text-primary flex-shrink-0" />
-                  )}
-
                   {/* Actions - always visible on mobile, hover on desktop */}
                   <div className="flex md:opacity-0 md:group-hover:opacity-100 items-center gap-1 transition-opacity">
                     <Button
@@ -206,6 +199,11 @@ export function TableSelector() {
                       </Button>
                     )}
                   </div>
+
+                  {/* Selected indicator - far right */}
+                  {activeTable?.id === table.id && (
+                    <Check className="h-4 w-4 text-primary flex-shrink-0" />
+                  )}
                 </div>
               ))}
             </div>

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

@@ -43,8 +43,11 @@ export function Layout() {
   const [appName, setAppName] = useState(DEFAULT_APP_NAME)
   const [customLogo, setCustomLogo] = useState<string | null>(null)
 
-  // Display name: use table name when multiple tables exist, otherwise use settings app name
-  const displayName = hasMultipleTables && activeTable?.name ? activeTable.name : appName
+  // Display name: when multiple tables exist, use the active table's name; otherwise use app settings
+  // Get the table from the tables array (most up-to-date source) to ensure we have current data
+  const activeTableData = tables.find(t => t.id === activeTable?.id)
+  const tableName = activeTableData?.name || activeTable?.name
+  const displayName = hasMultipleTables && tableName ? tableName : appName
 
   // Connection status
   const [isConnected, setIsConnected] = useState(false)
@@ -1199,7 +1202,7 @@ export function Layout() {
           </Link>
 
           {/* Desktop actions */}
-          <div className="hidden md:flex items-center gap-1 ml-6">
+          <div className="hidden md:flex items-center gap-0 ml-2">
             {/* Now Playing button */}
             <Button
               variant="ghost"
@@ -1213,53 +1216,58 @@ export function Layout() {
                 {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
               </span>
             </Button>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={() => setIsDark(!isDark)}
-              className="rounded-full"
-              aria-label="Toggle dark mode"
-              title="Toggle Theme"
-            >
-              <span className="material-icons-outlined">
-                {isDark ? 'light_mode' : 'dark_mode'}
-              </span>
-            </Button>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={handleToggleLogs}
-              className="rounded-full"
-              aria-label="View logs"
-              title="View Application Logs"
-            >
-              <span className="material-icons-outlined">article</span>
-            </Button>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={handleRestart}
-              className="rounded-full text-amber-500 hover:text-amber-600"
-              aria-label="Restart Docker"
-              title="Restart Docker"
-            >
-              <span className="material-icons-outlined">restart_alt</span>
-            </Button>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={handleShutdown}
-              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>
             <TableSelector />
+            <Popover>
+              <PopoverTrigger asChild>
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  className="rounded-full"
+                  aria-label="Open menu"
+                >
+                  <span className="material-icons-outlined">menu</span>
+                </Button>
+              </PopoverTrigger>
+              <PopoverContent align="end" className="w-56 p-2">
+                <div className="flex flex-col gap-1">
+                  <button
+                    onClick={() => setIsDark(!isDark)}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">
+                      {isDark ? 'light_mode' : 'dark_mode'}
+                    </span>
+                    {isDark ? 'Light Mode' : 'Dark Mode'}
+                  </button>
+                  <button
+                    onClick={handleToggleLogs}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
+                  >
+                    <span className="material-icons-outlined text-xl">article</span>
+                    View Logs
+                  </button>
+                  <Separator className="my-1" />
+                  <button
+                    onClick={handleRestart}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
+                  >
+                    <span className="material-icons-outlined text-xl">restart_alt</span>
+                    Restart Docker
+                  </button>
+                  <button
+                    onClick={handleShutdown}
+                    className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
+                  >
+                    <span className="material-icons-outlined text-xl">power_settings_new</span>
+                    Shutdown
+                  </button>
+                </div>
+              </PopoverContent>
+            </Popover>
           </div>
 
           {/* Mobile actions */}
-          <div className="flex md:hidden items-center gap-1 ml-4">
+          <div className="flex md:hidden items-center gap-0 ml-2">
             {/* Now Playing button */}
             <Button
               variant="ghost"

+ 28 - 1
frontend/src/pages/BrowsePage.tsx

@@ -128,6 +128,29 @@ export function BrowsePage() {
   const fileInputRef = useRef<HTMLInputElement>(null)
   const [isUploading, setIsUploading] = useState(false)
 
+  // Swipe to dismiss sheet on mobile
+  const sheetTouchStartRef = useRef<{ x: number; y: number } | null>(null)
+  const handleSheetTouchStart = (e: React.TouchEvent) => {
+    sheetTouchStartRef.current = {
+      x: e.touches[0].clientX,
+      y: e.touches[0].clientY,
+    }
+  }
+  const handleSheetTouchEnd = (e: React.TouchEvent) => {
+    if (!sheetTouchStartRef.current) return
+    const deltaX = e.changedTouches[0].clientX - sheetTouchStartRef.current.x
+    const deltaY = e.changedTouches[0].clientY - sheetTouchStartRef.current.y
+
+    // Swipe right (positive X) or swipe down (positive Y) to dismiss
+    // Require at least 80px movement and more horizontal/vertical than the other direction
+    if (deltaX > 80 && deltaX > Math.abs(deltaY)) {
+      setIsPanelOpen(false)
+    } else if (deltaY > 80 && deltaY > Math.abs(deltaX)) {
+      setIsPanelOpen(false)
+    }
+    sheetTouchStartRef.current = null
+  }
+
   // Close panel when playback starts
   useEffect(() => {
     const handlePlaybackStarted = () => {
@@ -999,7 +1022,11 @@ export function BrowsePage() {
 
       {/* Pattern Details Sheet */}
       <Sheet open={isPanelOpen} onOpenChange={setIsPanelOpen}>
-        <SheetContent className="flex flex-col p-0 overflow-hidden">
+        <SheetContent
+          className="flex flex-col p-0 overflow-hidden"
+          onTouchStart={handleSheetTouchStart}
+          onTouchEnd={handleSheetTouchEnd}
+        >
           <SheetHeader className="px-6 py-4 shrink-0">
             <SheetTitle className="flex items-center gap-2 pr-8">
               {selectedPattern && (