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

feat(ui): add draggable now playing button and optimize logo uploads

- Now Playing button can be dragged to snap to left, center, or right
- Position persists across sessions via localStorage
- Touch and mouse support with 8px drag threshold

- Increase logo upload limit from 5MB to 10MB
- Auto-optimize uploaded logos: resize to 512px max, convert to WebP
- Typical 95%+ file size reduction for large images
- SVG files pass through unchanged

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

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

@@ -176,6 +176,21 @@ export function Layout() {
   const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
   const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
 
+  // Draggable Now Playing button state
+  type SnapPosition = 'left' | 'center' | 'right'
+  const [nowPlayingButtonPos, setNowPlayingButtonPos] = useState<SnapPosition>(() => {
+    if (typeof window !== 'undefined') {
+      const saved = localStorage.getItem('nowPlayingButtonPos')
+      if (saved === 'left' || saved === 'center' || saved === 'right') return saved
+    }
+    return 'center'
+  })
+  const [isDraggingButton, setIsDraggingButton] = useState(false)
+  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
+  const buttonRef = useRef<HTMLButtonElement>(null)
+  const dragStartRef = useRef<{ x: number; y: number; buttonX: number } | null>(null)
+  const wasDraggingRef = useRef(false) // Track if a meaningful drag occurred
+
   // Derive isCurrentlyPlaying from currentPlayingFile
   const isCurrentlyPlaying = Boolean(currentPlayingFile)
 
@@ -984,6 +999,128 @@ export function Layout() {
     setCacheAllProgress(null)
   }
 
+  // Now Playing button drag handlers
+  const getSnapPositions = useCallback(() => {
+    const padding = 16
+    const buttonWidth = buttonRef.current?.offsetWidth || 140
+    return {
+      left: padding + buttonWidth / 2,
+      center: window.innerWidth / 2,
+      right: window.innerWidth - padding - buttonWidth / 2,
+    }
+  }, [])
+
+  const handleButtonDragStart = useCallback((clientX: number, clientY: number) => {
+    if (!buttonRef.current) return
+    const rect = buttonRef.current.getBoundingClientRect()
+    const buttonCenterX = rect.left + rect.width / 2
+    dragStartRef.current = { x: clientX, y: clientY, buttonX: buttonCenterX }
+    wasDraggingRef.current = false // Reset drag flag
+    setIsDraggingButton(true)
+    setDragOffset({ x: 0, y: 0 })
+  }, [])
+
+  const handleButtonDragMove = useCallback((clientX: number) => {
+    if (!dragStartRef.current || !isDraggingButton) return
+    const deltaX = clientX - dragStartRef.current.x
+    // Mark as dragging if moved more than 8px (to distinguish from clicks)
+    if (Math.abs(deltaX) > 8) {
+      wasDraggingRef.current = true
+    }
+    setDragOffset({ x: deltaX, y: 0 })
+  }, [isDraggingButton])
+
+  const handleButtonDragEnd = useCallback(() => {
+    if (!dragStartRef.current || !buttonRef.current) {
+      setIsDraggingButton(false)
+      setDragOffset({ x: 0, y: 0 })
+      return
+    }
+
+    // Calculate current position
+    const currentX = dragStartRef.current.buttonX + dragOffset.x
+    const snapPositions = getSnapPositions()
+
+    // Find nearest snap position
+    const distances = {
+      left: Math.abs(currentX - snapPositions.left),
+      center: Math.abs(currentX - snapPositions.center),
+      right: Math.abs(currentX - snapPositions.right),
+    }
+
+    let nearest: SnapPosition = 'center'
+    let minDistance = distances.center
+    if (distances.left < minDistance) {
+      nearest = 'left'
+      minDistance = distances.left
+    }
+    if (distances.right < minDistance) {
+      nearest = 'right'
+    }
+
+    // Update position and persist
+    setNowPlayingButtonPos(nearest)
+    localStorage.setItem('nowPlayingButtonPos', nearest)
+
+    // Reset drag state
+    setIsDraggingButton(false)
+    setDragOffset({ x: 0, y: 0 })
+    dragStartRef.current = null
+  }, [dragOffset.x, getSnapPositions])
+
+  // Mouse drag handlers
+  useEffect(() => {
+    if (!isDraggingButton) return
+
+    const handleMouseMove = (e: MouseEvent) => {
+      e.preventDefault()
+      handleButtonDragMove(e.clientX)
+    }
+
+    const handleMouseUp = () => {
+      handleButtonDragEnd()
+    }
+
+    window.addEventListener('mousemove', handleMouseMove)
+    window.addEventListener('mouseup', handleMouseUp)
+
+    return () => {
+      window.removeEventListener('mousemove', handleMouseMove)
+      window.removeEventListener('mouseup', handleMouseUp)
+    }
+  }, [isDraggingButton, handleButtonDragMove, handleButtonDragEnd])
+
+  // Get button position style
+  const getButtonPositionStyle = useCallback((): React.CSSProperties => {
+    const baseStyle: React.CSSProperties = {
+      bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))',
+    }
+
+    if (isDraggingButton && dragStartRef.current) {
+      // During drag, use transform for smooth movement
+      const snapPositions = getSnapPositions()
+      const startX = snapPositions[nowPlayingButtonPos]
+      return {
+        ...baseStyle,
+        left: startX,
+        transform: `translateX(calc(-50% + ${dragOffset.x}px))`,
+        transition: 'none',
+        cursor: 'grabbing',
+      }
+    }
+
+    // Snapped positions
+    switch (nowPlayingButtonPos) {
+      case 'left':
+        return { ...baseStyle, left: '1rem', transform: 'translateX(0)' }
+      case 'right':
+        return { ...baseStyle, right: '1rem', left: 'auto', transform: 'translateX(0)' }
+      case 'center':
+      default:
+        return { ...baseStyle, left: '50%', transform: 'translateX(-50%)' }
+    }
+  }, [isDraggingButton, dragOffset.x, nowPlayingButtonPos, getSnapPositions])
+
   const cacheAllPercentage = cacheAllProgress?.total
     ? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
     : 0
@@ -1669,12 +1806,38 @@ export function Layout() {
         )}
       </div>
 
-      {/* Floating Now Playing Button - hidden when Now Playing bar is open */}
+      {/* Floating Now Playing Button - draggable, snaps to left/center/right */}
       {!isNowPlayingOpen && (
         <button
-          onClick={() => setIsNowPlayingOpen(true)}
-          className="fixed z-40 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg transition-all hover:shadow-xl hover:scale-105 active:scale-95"
-          style={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))' }}
+          ref={buttonRef}
+          onClick={() => {
+            // Only open if it wasn't a drag (to distinguish click from drag)
+            if (!wasDraggingRef.current) {
+              setIsNowPlayingOpen(true)
+            }
+            wasDraggingRef.current = false
+          }}
+          onMouseDown={(e) => {
+            e.preventDefault()
+            handleButtonDragStart(e.clientX, e.clientY)
+          }}
+          onTouchStart={(e) => {
+            const touch = e.touches[0]
+            handleButtonDragStart(touch.clientX, touch.clientY)
+          }}
+          onTouchMove={(e) => {
+            const touch = e.touches[0]
+            handleButtonDragMove(touch.clientX)
+          }}
+          onTouchEnd={() => {
+            handleButtonDragEnd()
+          }}
+          className={`fixed z-40 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg select-none touch-none ${
+            isDraggingButton
+              ? 'cursor-grabbing scale-105 shadow-xl'
+              : 'cursor-grab transition-all duration-300 hover:shadow-xl hover:scale-105 active:scale-95'
+          }`}
+          style={getButtonPositionStyle()}
           aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
         >
           <span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>

+ 69 - 8
main.py

@@ -2798,7 +2798,61 @@ async def set_app_name(request: dict):
 
 CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
 ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
-MAX_LOGO_SIZE = 5 * 1024 * 1024  # 5MB
+MAX_LOGO_SIZE = 10 * 1024 * 1024  # 10MB
+MAX_LOGO_DIMENSION = 512  # Max width/height for optimized logo
+
+
+def optimize_logo_image(content: bytes, original_ext: str) -> tuple[bytes, str]:
+    """Optimize logo image by resizing and converting to WebP.
+
+    Args:
+        content: Original image bytes
+        original_ext: Original file extension (e.g., '.png', '.jpg')
+
+    Returns:
+        Tuple of (optimized_bytes, new_extension)
+
+    For SVG files, returns the original content unchanged.
+    For raster images, resizes to MAX_LOGO_DIMENSION and converts to WebP.
+    """
+    # SVG files are already lightweight vectors - keep as-is
+    if original_ext.lower() == ".svg":
+        return content, original_ext
+
+    try:
+        from PIL import Image
+        import io
+
+        with Image.open(io.BytesIO(content)) as img:
+            # Convert to RGBA for transparency support
+            if img.mode in ('P', 'LA') or (img.mode == 'RGBA' and 'transparency' in img.info):
+                img = img.convert('RGBA')
+            elif img.mode != 'RGBA':
+                img = img.convert('RGB')
+
+            # Resize if larger than max dimension (maintain aspect ratio)
+            width, height = img.size
+            if width > MAX_LOGO_DIMENSION or height > MAX_LOGO_DIMENSION:
+                ratio = min(MAX_LOGO_DIMENSION / width, MAX_LOGO_DIMENSION / height)
+                new_size = (int(width * ratio), int(height * ratio))
+                img = img.resize(new_size, Image.Resampling.LANCZOS)
+                logger.info(f"Logo resized from {width}x{height} to {new_size[0]}x{new_size[1]}")
+
+            # Save as WebP with good quality/size balance
+            output = io.BytesIO()
+            img.save(output, format='WEBP', quality=85, method=6)
+            optimized_bytes = output.getvalue()
+
+            original_size = len(content)
+            new_size = len(optimized_bytes)
+            reduction = ((original_size - new_size) / original_size) * 100
+            logger.info(f"Logo optimized: {original_size:,} bytes -> {new_size:,} bytes ({reduction:.1f}% reduction)")
+
+            return optimized_bytes, ".webp"
+
+    except Exception as e:
+        logger.warning(f"Logo optimization failed, using original: {str(e)}")
+        return content, original_ext
 
 def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
     """Generate circular favicons with transparent background from the uploaded logo.
@@ -2912,10 +2966,14 @@ async def upload_logo(file: UploadFile = File(...)):
     """Upload a custom logo image.
 
     Supported formats: PNG, JPG, JPEG, GIF, WebP, SVG
-    Maximum size: 5MB
+    Maximum upload size: 10MB
 
-    The uploaded file will be stored and used as the application logo.
-    A favicon will be automatically generated from the logo.
+    Images are automatically optimized:
+    - Resized to max 512x512 pixels
+    - Converted to WebP format for smaller file size
+    - SVG files are kept as-is (already lightweight)
+
+    A favicon and PWA icons will be automatically generated from the logo.
     """
     try:
         # Validate file extension
@@ -2947,19 +3005,22 @@ async def upload_logo(file: UploadFile = File(...)):
             if os.path.exists(old_favicon_path):
                 os.remove(old_favicon_path)
 
+        # Optimize the image (resize + convert to WebP for smaller file size)
+        optimized_content, optimized_ext = optimize_logo_image(content, file_ext)
+
         # Generate a unique filename to prevent caching issues
         import uuid
-        filename = f"logo-{uuid.uuid4().hex[:8]}{file_ext}"
+        filename = f"logo-{uuid.uuid4().hex[:8]}{optimized_ext}"
         file_path = os.path.join(CUSTOM_BRANDING_DIR, filename)
 
-        # Save the logo file
+        # Save the optimized logo file
         with open(file_path, "wb") as f:
-            f.write(content)
+            f.write(optimized_content)
 
         # Generate favicon and PWA icons from logo (for non-SVG files)
         favicon_generated = False
         pwa_icons_generated = False
-        if file_ext != ".svg":
+        if optimized_ext != ".svg":
             favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
             pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)