Quellcode durchsuchen

Add PWA support and improve mobile experience

- Add vite-plugin-pwa for service worker and caching
- Add iOS safe area support (Dynamic Island, notch)
- Add mobile drill-down navigation for Playlists with swipe-back gesture
- Update favicon/PWA icons to square format (OS applies masks)
- Add circular favicons with transparent background for browser tabs
- Add dynamic manifest endpoint for custom branding support
- Generate proper PWA icons on custom logo upload
- Allow dismissing homing splash screen
- Add scroll-to-top on page navigation
- Enable header blur only on Browse page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris vor 2 Wochen
Ursprung
Commit
33c585c313

+ 16 - 2
frontend/index.html

@@ -3,13 +3,23 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
+    <meta name="description" content="Control your kinetic sand table" />
+
+    <!-- PWA Meta Tags -->
+    <meta name="theme-color" content="#0a0a0a" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="apple-mobile-web-app-title" content="Dune Weaver" />
+    <meta name="mobile-web-app-capable" content="yes" />
 
     <!-- Favicons - will be updated dynamically if custom logo exists -->
     <link rel="icon" type="image/x-icon" href="/static/favicon.ico" id="favicon-ico" />
+    <link rel="icon" type="image/png" sizes="128x128" href="/static/favicon-128x128.png" id="favicon-128" />
+    <link rel="icon" type="image/png" sizes="96x96" href="/static/favicon-96x96.png" id="favicon-96" />
     <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png" id="favicon-32" />
     <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png" id="favicon-16" />
     <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" id="apple-touch-icon" />
-    <link rel="manifest" href="/static/site.webmanifest" />
+    <link rel="manifest" href="/api/manifest.webmanifest" id="manifest" />
 
     <title>Dune Weaver</title>
 
@@ -34,11 +44,15 @@
           .then(function(r) { return r.json(); })
           .then(function(settings) {
             if (settings.app && settings.app.custom_logo) {
+              // Use generated icons with proper padding (not the raw uploaded logo)
               document.getElementById('favicon-ico').href = baseUrl + '/static/custom/favicon.ico';
-              document.getElementById('apple-touch-icon').href = baseUrl + '/static/custom/' + settings.app.custom_logo;
+              document.getElementById('apple-touch-icon').href = baseUrl + '/static/custom/apple-touch-icon.png';
             }
             if (settings.app && settings.app.name) {
               document.title = settings.app.name;
+              // Also update PWA title
+              var appTitleMeta = document.querySelector('meta[name="apple-mobile-web-app-title"]');
+              if (appTitleMeta) appTitleMeta.content = settings.app.name;
             }
           })
           .catch(function() {});

Datei-Diff unterdrückt, da er zu groß ist
+ 6417 - 2853
frontend/package-lock.json


+ 2 - 1
frontend/package.json

@@ -61,6 +61,7 @@
     "tailwindcss-animate": "^1.0.7",
     "typescript": "~5.9.3",
     "typescript-eslint": "^8.46.4",
-    "vite": "^7.2.4"
+    "vite": "^7.2.4",
+    "vite-plugin-pwa": "^1.2.0"
   }
 }

+ 34 - 6
frontend/src/components/layout/Layout.tsx

@@ -24,6 +24,11 @@ const DEFAULT_APP_NAME = 'Dune Weaver'
 export function Layout() {
   const location = useLocation()
 
+  // Scroll to top on route change
+  useEffect(() => {
+    window.scrollTo(0, 0)
+  }, [location.pathname])
+
   // Multi-table context - must be called before any hooks that depend on activeTable
   const { activeTable, tables } = useTable()
 
@@ -53,6 +58,7 @@ export function Layout() {
   const [isConnected, setIsConnected] = useState(false)
   const [isBackendConnected, setIsBackendConnected] = useState(false)
   const [isHoming, setIsHoming] = useState(false)
+  const [homingDismissed, setHomingDismissed] = useState(false)
   const [homingJustCompleted, setHomingJustCompleted] = useState(false)
   const [homingCountdown, setHomingCountdown] = useState(0)
   const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false)
@@ -236,11 +242,16 @@ export function Layout() {
             // Update homing status and detect completion
             if (data.data.is_homing !== undefined) {
               const newIsHoming = data.data.is_homing
+              // Detect transition from not homing to homing - reset dismissal
+              if (!wasHomingRef.current && newIsHoming) {
+                setHomingDismissed(false)
+              }
               // Detect transition from homing to not homing
               if (wasHomingRef.current && !newIsHoming) {
                 // Homing just completed - show completion state with countdown
                 setHomingJustCompleted(true)
                 setHomingCountdown(5)
+                setHomingDismissed(false)
               }
               wasHomingRef.current = newIsHoming
               setIsHoming(newIsHoming)
@@ -999,7 +1010,7 @@ export function Layout() {
       )}
 
       {/* Backend Connection / Homing Blocking Overlay */}
-      {(!isBackendConnected || isHoming || homingJustCompleted) && (
+      {(!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
           <div className="w-full max-w-2xl space-y-6">
             {/* Status Header */}
@@ -1150,11 +1161,25 @@ export function Layout() {
               </div>
             )}
 
+            {/* Dismiss button during homing */}
+            {isHoming && !homingJustCompleted && (
+              <div className="flex justify-center">
+                <Button
+                  variant="ghost"
+                  onClick={() => setHomingDismissed(true)}
+                  className="gap-2 text-muted-foreground"
+                >
+                  <span className="material-icons text-base">visibility_off</span>
+                  Dismiss
+                </Button>
+              </div>
+            )}
+
             {/* Hint */}
             {!homingJustCompleted && (
               <p className="text-center text-xs text-muted-foreground">
                 {isHoming
-                  ? 'The table is calibrating its position'
+                  ? 'Homing will continue in the background'
                   : 'Make sure the backend server is running on port 8080'
                 }
               </p>
@@ -1164,9 +1189,11 @@ export function Layout() {
       )}
 
       {/* Header - Floating Pill */}
-      <header className="fixed top-0 left-0 right-0 z-40">
-        {/* Blurry backdrop behind header */}
-        <div className="absolute inset-0 h-20 bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/50" />
+      <header className="fixed top-0 left-0 right-0 z-40 pt-safe">
+        {/* Blurry backdrop behind header - only on Browse page where content scrolls under */}
+        {location.pathname === '/' && (
+          <div className="absolute inset-0 bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/50" style={{ height: 'calc(5rem + env(safe-area-inset-top, 0px))' }} />
+        )}
         <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
           <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
           <Link to="/" className="flex items-center gap-2">
@@ -1350,11 +1377,12 @@ export function Layout() {
 
       {/* Main Content */}
       <main
-        className={`container mx-auto px-4 pt-[4.5rem] transition-all duration-300 ${
+        className={`container mx-auto px-4 transition-all duration-300 ${
           !isLogsOpen && !isNowPlayingOpen ? 'pb-20' :
           !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
         }`}
         style={{
+          paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
           paddingBottom: isLogsOpen
             ? isNowPlayingOpen
               ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav

+ 9 - 1
frontend/src/index.css

@@ -103,11 +103,19 @@ body {
   min-height: 100dvh; /* Use dynamic viewport height for mobile */
 }
 
-/* Safe area utilities for iOS notch/home indicator */
+/* Safe area utilities for iOS notch/Dynamic Island/home indicator */
+.pt-safe {
+  padding-top: env(safe-area-inset-top, 0px);
+}
+
 .pb-safe {
   padding-bottom: env(safe-area-inset-bottom, 0px);
 }
 
+.mt-safe {
+  margin-top: env(safe-area-inset-top, 0px);
+}
+
 .mb-safe {
   margin-bottom: env(safe-area-inset-bottom, 0px);
 }

+ 5 - 2
frontend/src/pages/BrowsePage.tsx

@@ -879,7 +879,10 @@ export function BrowsePage() {
       </div>
 
       {/* Filter Bar */}
-      <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="sticky 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"
+        style={{ top: 'calc(4.5rem + env(safe-area-inset-top, 0px))' }}
+      >
         <div className="flex items-center gap-2 sm:gap-3">
           {/* Search - Pill shaped, white background */}
           <div className="relative flex-1 min-w-0">
@@ -1023,7 +1026,7 @@ export function BrowsePage() {
       {/* Pattern Details Sheet */}
       <Sheet open={isPanelOpen} onOpenChange={setIsPanelOpen}>
         <SheetContent
-          className="flex flex-col p-0 overflow-hidden"
+          className="flex flex-col p-0 overflow-hidden pt-safe"
           onTouchStart={handleSheetTouchStart}
           onTouchEnd={handleSheetTouchEnd}
         >

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

@@ -59,6 +59,29 @@ export function PlaylistsPage() {
   const [newPlaylistName, setNewPlaylistName] = useState('')
   const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
 
+  // Mobile view state - show content panel when a playlist is selected
+  const [mobileShowContent, setMobileShowContent] = useState(false)
+
+  // Swipe gesture to go back on mobile
+  const swipeTouchStartRef = useRef<{ x: number; y: number } | null>(null)
+  const handleSwipeTouchStart = (e: React.TouchEvent) => {
+    swipeTouchStartRef.current = {
+      x: e.touches[0].clientX,
+      y: e.touches[0].clientY,
+    }
+  }
+  const handleSwipeTouchEnd = (e: React.TouchEvent) => {
+    if (!swipeTouchStartRef.current || !mobileShowContent) return
+    const deltaX = e.changedTouches[0].clientX - swipeTouchStartRef.current.x
+    const deltaY = e.changedTouches[0].clientY - swipeTouchStartRef.current.y
+
+    // Swipe right to go back (positive X, more horizontal than vertical)
+    if (deltaX > 80 && deltaX > Math.abs(deltaY)) {
+      setMobileShowContent(false)
+    }
+    swipeTouchStartRef.current = null
+  }
+
   // Playback settings - initialized from localStorage
   const [runMode, setRunMode] = useState<RunMode>(() => {
     const cached = localStorage.getItem('playlist-runMode')
@@ -308,6 +331,12 @@ export function PlaylistsPage() {
   const handleSelectPlaylist = (name: string) => {
     setSelectedPlaylist(name)
     fetchPlaylistPatterns(name)
+    setMobileShowContent(true) // Show content panel on mobile
+  }
+
+  // Go back to playlist list on mobile
+  const handleMobileBack = () => {
+    setMobileShowContent(false)
   }
 
   const handleCreatePlaylist = async () => {
@@ -510,9 +539,11 @@ export function PlaylistsPage() {
       <Separator className="shrink-0" />
 
       {/* Main Content Area */}
-      <div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0">
-        {/* Playlists Sidebar */}
-        <aside className="w-full lg:w-64 shrink-0 bg-card border rounded-lg flex flex-col max-h-48 lg:max-h-none">
+      <div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0 relative overflow-hidden">
+        {/* Playlists Sidebar - Full screen on mobile, sidebar on desktop */}
+        <aside className={`w-full lg:w-64 shrink-0 bg-card border rounded-lg flex flex-col h-full overflow-hidden transition-transform duration-300 ease-in-out ${
+          mobileShowContent ? '-translate-x-full lg:translate-x-0 absolute lg:relative inset-0 lg:inset-auto' : 'translate-x-0'
+        }`}>
           <div className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
             <div>
               <h2 className="text-lg font-semibold">My Playlists</h2>
@@ -588,11 +619,26 @@ export function PlaylistsPage() {
         </nav>
       </aside>
 
-        {/* Main Content */}
-        <main className="flex-1 bg-card border rounded-lg flex flex-col overflow-hidden min-h-0 relative">
+        {/* Main Content - Slides in from right on mobile, swipe right to go back */}
+        <main
+          className={`flex-1 bg-card border rounded-lg flex flex-col overflow-hidden min-h-0 relative transition-transform duration-300 ease-in-out ${
+            mobileShowContent ? 'translate-x-0' : 'translate-x-full lg:translate-x-0 absolute lg:relative inset-0 lg:inset-auto'
+          }`}
+          onTouchStart={handleSwipeTouchStart}
+          onTouchEnd={handleSwipeTouchEnd}
+        >
           {/* Header */}
           <header className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
-            <div className="flex items-center gap-3 min-w-0">
+            <div className="flex items-center gap-2 min-w-0">
+              {/* Back button - mobile only */}
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-8 w-8 lg:hidden shrink-0"
+                onClick={handleMobileBack}
+              >
+                <span className="material-icons-outlined">arrow_back</span>
+              </Button>
               <div className="min-w-0">
                 <h2 className="text-lg font-semibold truncate">
                   {selectedPlaylist || 'Select a Playlist'}

+ 56 - 1
frontend/vite.config.ts

@@ -1,10 +1,65 @@
 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
+import { VitePWA } from 'vite-plugin-pwa'
 import path from 'path'
 
 // https://vite.dev/config/
 export default defineConfig({
-  plugins: [react()],
+  plugins: [
+    react(),
+    VitePWA({
+      registerType: 'autoUpdate',
+      includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'android-chrome-192x192.png', 'android-chrome-512x512.png'],
+      manifest: false, // We use our own manifest at /static/site.webmanifest
+      workbox: {
+        // Cache static assets
+        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
+        // Runtime caching rules
+        runtimeCaching: [
+          {
+            // Cache pattern preview images
+            urlPattern: /\/static\/.*\.webp$/,
+            handler: 'CacheFirst',
+            options: {
+              cacheName: 'pattern-previews',
+              expiration: {
+                maxEntries: 500,
+                maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
+              },
+            },
+          },
+          {
+            // Cache static assets from backend
+            urlPattern: /\/static\/.*\.(png|jpg|ico|svg)$/,
+            handler: 'CacheFirst',
+            options: {
+              cacheName: 'static-assets',
+              expiration: {
+                maxEntries: 100,
+                maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
+              },
+            },
+          },
+          {
+            // Network-first for API calls (always want fresh data, but cache as fallback)
+            urlPattern: /\/api\//,
+            handler: 'NetworkFirst',
+            options: {
+              cacheName: 'api-cache',
+              expiration: {
+                maxEntries: 50,
+                maxAgeSeconds: 60 * 5, // 5 minutes
+              },
+              networkTimeoutSeconds: 10,
+            },
+          },
+        ],
+      },
+      devOptions: {
+        enabled: false, // Disable in dev mode to avoid caching issues
+      },
+    }),
+  ],
   resolve: {
     alias: {
       '@': path.resolve(__dirname, './src'),

+ 147 - 28
main.py

@@ -723,6 +723,50 @@ async def get_all_settings():
         }
     }
 
+@app.get("/api/manifest.webmanifest", tags=["settings"])
+async def get_dynamic_manifest():
+    """
+    Get a dynamically generated web manifest.
+
+    Returns manifest with custom icons and app name if custom branding is configured,
+    otherwise returns defaults.
+    """
+    # Determine icon paths based on whether custom logo exists
+    if state.custom_logo:
+        icon_base = "/static/custom"
+    else:
+        icon_base = "/static"
+
+    # Use custom app name or default
+    app_name = state.app_name or "Dune Weaver"
+
+    return {
+        "name": app_name,
+        "short_name": app_name,
+        "description": "Control your kinetic sand table",
+        "icons": [
+            {
+                "src": f"{icon_base}/android-chrome-192x192.png",
+                "sizes": "192x192",
+                "type": "image/png",
+                "purpose": "any"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-512x512.png",
+                "sizes": "512x512",
+                "type": "image/png",
+                "purpose": "any"
+            }
+        ],
+        "start_url": "/",
+        "scope": "/",
+        "display": "standalone",
+        "orientation": "any",
+        "theme_color": "#0a0a0a",
+        "background_color": "#0a0a0a",
+        "categories": ["utilities", "entertainment"]
+    }
+
 @app.patch("/api/settings", tags=["settings"])
 async def update_settings(settings_update: SettingsUpdate):
     """
@@ -2565,26 +2609,26 @@ CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
 ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
 MAX_LOGO_SIZE = 5 * 1024 * 1024  # 5MB
 
-def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
-    """Generate a circular-cropped favicon from the uploaded logo using PIL.
+def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
+    """Generate circular favicons with transparent background from the uploaded logo.
+
+    Creates:
+    - favicon.ico (multi-size: 256, 128, 64, 48, 32, 16)
+    - favicon-16x16.png, favicon-32x32.png, favicon-96x96.png, favicon-128x128.png
 
-    Creates a multi-size ICO file (16x16, 32x32, 48x48) with circular crop.
     Returns True on success, False on failure.
     """
     try:
         from PIL import Image, ImageDraw
 
-        def create_circular_image(img, size):
-            """Create a circular-cropped image at the specified size."""
-            # Resize to target size
+        def create_circular_transparent(img, size):
+            """Create circular image with transparent background."""
             resized = img.resize((size, size), Image.Resampling.LANCZOS)
 
-            # Create circular mask
             mask = Image.new('L', (size, size), 0)
             draw = ImageDraw.Draw(mask)
             draw.ellipse((0, 0, size - 1, size - 1), fill=255)
 
-            # Apply circular mask - create transparent background
             output = Image.new('RGBA', (size, size), (0, 0, 0, 0))
             output.paste(resized, (0, 0), mask)
             return output
@@ -2601,16 +2645,25 @@ def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
             top = (height - min_dim) // 2
             img = img.crop((left, top, left + min_dim, top + min_dim))
 
-            # Create circular images at each favicon size
-            sizes = [48, 32, 16]
-            circular_images = [create_circular_image(img, size) for size in sizes]
-
-            # Save as ICO - first image is the main one, rest are appended
-            circular_images[0].save(
-                favicon_path,
+            # Generate circular favicon PNGs with transparent background
+            png_sizes = {
+                "favicon-16x16.png": 16,
+                "favicon-32x32.png": 32,
+                "favicon-96x96.png": 96,
+                "favicon-128x128.png": 128,
+            }
+            for filename, size in png_sizes.items():
+                icon = create_circular_transparent(img, size)
+                icon.save(os.path.join(output_dir, filename), format='PNG')
+
+            # Generate high-resolution favicon.ico
+            ico_sizes = [256, 128, 64, 48, 32, 16]
+            ico_images = [create_circular_transparent(img, s) for s in ico_sizes]
+            ico_images[0].save(
+                os.path.join(output_dir, "favicon.ico"),
                 format='ICO',
-                append_images=circular_images[1:],
-                sizes=[(s, s) for s in sizes]
+                append_images=ico_images[1:],
+                sizes=[(s, s) for s in ico_sizes]
             )
 
         return True
@@ -2618,6 +2671,51 @@ def generate_favicon_from_logo(logo_path: str, favicon_path: str) -> bool:
         logger.error(f"Failed to generate favicon: {str(e)}")
         return False
 
+def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
+    """Generate square PWA app icons from the uploaded logo.
+
+    Creates square icons (no circular crop) - OS will apply its own mask.
+
+    Generates:
+    - apple-touch-icon.png (180x180)
+    - android-chrome-192x192.png (192x192)
+    - android-chrome-512x512.png (512x512)
+
+    Returns True on success, False on failure.
+    """
+    try:
+        from PIL import Image
+
+        with Image.open(logo_path) as img:
+            # Convert to RGBA if needed
+            if img.mode != 'RGBA':
+                img = img.convert('RGBA')
+
+            # Crop to square (center crop)
+            width, height = img.size
+            min_dim = min(width, height)
+            left = (width - min_dim) // 2
+            top = (height - min_dim) // 2
+            img = img.crop((left, top, left + min_dim, top + min_dim))
+
+            # Generate square icons at each required size
+            icon_sizes = {
+                "apple-touch-icon.png": 180,
+                "android-chrome-192x192.png": 192,
+                "android-chrome-512x512.png": 512,
+            }
+
+            for filename, size in icon_sizes.items():
+                resized = img.resize((size, size), Image.Resampling.LANCZOS)
+                icon_path = os.path.join(output_dir, filename)
+                resized.save(icon_path, format='PNG')
+                logger.info(f"Generated PWA icon: {filename}")
+
+        return True
+    except Exception as e:
+        logger.error(f"Failed to generate PWA icons: {str(e)}")
+        return False
+
 @app.post("/api/upload-logo", tags=["settings"])
 async def upload_logo(file: UploadFile = File(...)):
     """Upload a custom logo image.
@@ -2667,22 +2765,24 @@ async def upload_logo(file: UploadFile = File(...)):
         with open(file_path, "wb") as f:
             f.write(content)
 
-        # Generate favicon from logo (for non-SVG files)
+        # Generate favicon and PWA icons from logo (for non-SVG files)
         favicon_generated = False
+        pwa_icons_generated = False
         if file_ext != ".svg":
-            favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
-            favicon_generated = generate_favicon_from_logo(file_path, favicon_path)
+            favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
+            pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)
 
         # Update state
         state.custom_logo = filename
         state.save()
 
-        logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}")
+        logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}, PWA icons generated: {pwa_icons_generated}")
         return {
             "success": True,
             "filename": filename,
             "url": f"/static/custom/{filename}",
-            "favicon_generated": favicon_generated
+            "favicon_generated": favicon_generated,
+            "pwa_icons_generated": pwa_icons_generated
         }
 
     except HTTPException:
@@ -2693,7 +2793,7 @@ async def upload_logo(file: UploadFile = File(...)):
 
 @app.delete("/api/custom-logo", tags=["settings"])
 async def delete_custom_logo():
-    """Remove custom logo and favicon, reverting to defaults."""
+    """Remove custom logo, favicon, and PWA icons, reverting to defaults."""
     try:
         if state.custom_logo:
             # Remove logo
@@ -2701,14 +2801,33 @@ async def delete_custom_logo():
             if os.path.exists(logo_path):
                 os.remove(logo_path)
 
-            # Remove generated favicon
-            favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
-            if os.path.exists(favicon_path):
-                os.remove(favicon_path)
+            # Remove generated favicons
+            favicon_files = [
+                "favicon.ico",
+                "favicon-16x16.png",
+                "favicon-32x32.png",
+                "favicon-96x96.png",
+                "favicon-128x128.png",
+            ]
+            for favicon_name in favicon_files:
+                favicon_path = os.path.join(CUSTOM_BRANDING_DIR, favicon_name)
+                if os.path.exists(favicon_path):
+                    os.remove(favicon_path)
+
+            # Remove generated PWA icons
+            pwa_icons = [
+                "apple-touch-icon.png",
+                "android-chrome-192x192.png",
+                "android-chrome-512x512.png",
+            ]
+            for icon_name in pwa_icons:
+                icon_path = os.path.join(CUSTOM_BRANDING_DIR, icon_name)
+                if os.path.exists(icon_path):
+                    os.remove(icon_path)
 
             state.custom_logo = None
             state.save()
-            logger.info("Custom logo and favicon removed")
+            logger.info("Custom logo, favicon, and PWA icons removed")
         return {"success": True}
     except Exception as e:
         logger.error(f"Error removing logo: {str(e)}")

BIN
static/android-chrome-192x192.png


BIN
static/android-chrome-512x512.png


BIN
static/apple-touch-icon.png


BIN
static/favicon-16x16.png


BIN
static/favicon-32x32.png


BIN
static/favicon.ico


BIN
static/icon.jpeg


+ 26 - 1
static/site.webmanifest

@@ -1 +1,26 @@
-{"name":"","short_name":"","icons":[{"src":"/static/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+{
+  "name": "Dune Weaver",
+  "short_name": "Dune Weaver",
+  "description": "Control your kinetic sand table",
+  "icons": [
+    {
+      "src": "/static/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "any"
+    },
+    {
+      "src": "/static/android-chrome-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "any"
+    }
+  ],
+  "start_url": "/",
+  "scope": "/",
+  "display": "standalone",
+  "orientation": "any",
+  "theme_color": "#0a0a0a",
+  "background_color": "#0a0a0a",
+  "categories": ["utilities", "entertainment"]
+}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.