Browse Source

Make NowPlayingBar mobile changes desktop-responsive

- Marquee animation only on mobile (desktop shows static truncated text)
- Progress bar at bottom only on mobile, inline on desktop (above controls)
- Collapsed bar: 256px mobile, 200px desktop
- Expanded bar: full height mobile, 50vh desktop
- Bottom offset: raised on mobile only (bottom-20 vs bottom-16)
- Center preview and controls in expanded view on desktop
- Add spacing between Up Next section and header buttons
- Simplify connection indicator (dot only, no icon)
- Add icon-sm button size variant

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 3 weeks ago
parent
commit
f5464e7780

+ 16 - 9
frontend/src/components/NowPlayingBar.tsx

@@ -528,8 +528,8 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       {/* Now Playing Bar - slides up to full height on mobile, 50vh on desktop when expanded */}
       <div
         ref={barRef}
-        className={`fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300 ${isLogsOpen ? 'bottom-80' : isExpanded ? 'bottom-16' : 'bottom-20'}`}
-        style={{ height: isExpanded ? 'calc(100vh - 64px - 64px)' : '256px' }}
+        className={`fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300 ${isLogsOpen ? 'bottom-80' : isExpanded ? 'bottom-16' : 'bottom-16 max-md:bottom-20'}`}
+        data-now-playing-bar={isExpanded ? 'expanded' : 'collapsed'}
         onTouchStart={handleTouchStart}
         onTouchEnd={handleTouchEnd}
       >
@@ -611,6 +611,13 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                         )}
                       </div>
 
+                      {/* Progress Bar - Desktop only (inline, above controls) */}
+                      <div className="hidden md:flex items-center gap-3">
+                        <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
+                        <Progress value={progressPercent} className="h-2 flex-1" />
+                        <span className="text-sm text-muted-foreground w-12 text-right font-mono">-{formatTime(remainingTime)}</span>
+                      </div>
+
                       {/* Playback Controls - Centered */}
                       <div className="flex items-center justify-center gap-3">
                         <Button
@@ -662,7 +669,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
                     {/* Next Pattern Preview - hidden on mobile */}
                     {status.playlist?.next_file && (
-                      <div className="hidden md:flex shrink-0 flex-col items-center gap-1">
+                      <div className="hidden md:flex shrink-0 flex-col items-center gap-1 mr-16">
                         <p className="text-xs text-muted-foreground font-medium">Up Next</p>
                         <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
                           {nextPreviewUrl ? (
@@ -690,9 +697,9 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                 )}
               </div>
 
-              {/* Progress Bar - Full width at bottom */}
+              {/* Progress Bar - Mobile only (full width at bottom) */}
               {isPlaying && status && (
-                <div className="flex items-center gap-3 px-6 pb-3">
+                <div className="flex md:hidden items-center gap-3 px-6 pb-3">
                   <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
                   <Progress value={progressPercent} className="h-2 flex-1" />
                   <span className="text-sm text-muted-foreground w-12 text-right font-mono">-{formatTime(remainingTime)}</span>
@@ -703,10 +710,10 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
           {/* Expanded view - Real-time canvas preview */}
           {isExpanded && isPlaying && (
-            <div className="flex-1 flex flex-col md:justify-center px-4 py-2 md:py-4 overflow-hidden">
-              <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row gap-3 md:gap-6 md:-ml-16">
+            <div className="flex-1 flex flex-col md:items-center md:justify-center px-4 py-2 md:py-4 overflow-hidden">
+              <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row md:items-center md:justify-center gap-3 md:gap-6">
                 {/* Canvas - full width on mobile */}
-                <div className="flex items-center justify-center flex-1 min-h-0">
+                <div className="flex items-center justify-center flex-1 md:flex-none min-h-0">
                   <canvas
                     ref={canvasRef}
                     width={600}
@@ -719,7 +726,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                 {/* 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 md:text-left">
+                <div className="text-center">
                   <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
                   {status?.playlist && (
                     <p className="text-sm text-muted-foreground">

+ 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>

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

@@ -24,6 +24,7 @@ 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",
       },
     },
     defaultVariants: {

+ 75 - 4
frontend/src/index.css

@@ -133,10 +133,81 @@ body {
 
 .animate-marquee {
   display: inline-block;
-  animation: marquee 8s ease-in-out infinite;
-  animation-play-state: running;
 }
 
-.animate-marquee:hover {
-  animation-play-state: paused;
+/* Marquee animation only on mobile */
+@media (max-width: 767px) {
+  .animate-marquee {
+    animation: marquee 8s ease-in-out infinite;
+    animation-play-state: running;
+  }
+
+  .animate-marquee:hover {
+    animation-play-state: paused;
+  }
+}
+
+/* Now Playing Bar heights - responsive for mobile vs desktop */
+[data-now-playing-bar="collapsed"] {
+  height: 200px;
+}
+
+[data-now-playing-bar="expanded"] {
+  height: 50vh;
+}
+
+@media (max-width: 767px) {
+  [data-now-playing-bar="collapsed"] {
+    height: 256px;
+  }
+
+  [data-now-playing-bar="expanded"] {
+    height: calc(100vh - 64px - 64px);
+  }
+}
+
+/* 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>