ソースを参照

Add logo and favicon customization

tuanchris 2 ヶ月 前
コミット
3abf116187
6 ファイル変更223 行追加10 行削除
  1. 4 1
      .gitignore
  2. 168 7
      main.py
  3. 6 0
      modules/core/state.py
  4. 0 0
      static/custom/.gitkeep
  5. 8 2
      templates/base.html
  6. 37 0
      templates/settings.html

+ 4 - 1
.gitignore

@@ -11,4 +11,7 @@ patterns/cached_images/custom_*
 # Node.js and build files
 node_modules/
 *.log
-*.png
+*.png
+# Custom branding assets (user uploads)
+static/custom/*
+!static/custom/.gitkeep

+ 168 - 7
main.py

@@ -328,6 +328,7 @@ class GetCoordinatesRequest(BaseModel):
 
 class AppSettingsUpdate(BaseModel):
     name: Optional[str] = None
+    custom_logo: Optional[str] = None  # Filename or empty string to clear (favicon auto-generated)
 
 class ConnectionSettingsUpdate(BaseModel):
     preferred_port: Optional[str] = None
@@ -474,11 +475,11 @@ async def websocket_cache_progress_endpoint(websocket: WebSocket):
 # FastAPI routes
 @app.get("/")
 async def index(request: Request):
-    return templates.TemplateResponse("index.html", {"request": request, "app_name": state.app_name})
+    return templates.TemplateResponse("index.html", {"request": request, "app_name": state.app_name, "custom_logo": state.custom_logo})
 
 @app.get("/settings")
 async def settings(request: Request):
-    return templates.TemplateResponse("settings.html", {"request": request, "app_name": state.app_name})
+    return templates.TemplateResponse("settings.html", {"request": request, "app_name": state.app_name, "custom_logo": state.custom_logo})
 
 # ============================================================================
 # Unified Settings API
@@ -494,7 +495,8 @@ async def get_all_settings():
     """
     return {
         "app": {
-            "name": state.app_name
+            "name": state.app_name,
+            "custom_logo": state.custom_logo
         },
         "connection": {
             "preferred_port": state.preferred_port
@@ -573,6 +575,8 @@ async def update_settings(settings_update: SettingsUpdate):
     if settings_update.app:
         if settings_update.app.name is not None:
             state.app_name = settings_update.app.name or "Dune Weaver"
+        if settings_update.app.custom_logo is not None:
+            state.custom_logo = settings_update.app.custom_logo or None
         updated_categories.append("app")
 
     # Connection settings
@@ -1864,6 +1868,163 @@ async def set_app_name(request: dict):
     logger.info(f"Application name updated to: {app_name}")
     return {"success": True, "app_name": app_name}
 
+# ============================================================================
+# Custom Branding Upload Endpoints
+# ============================================================================
+
+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.
+
+    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
+            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
+
+        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))
+
+            # 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,
+                format='ICO',
+                append_images=circular_images[1:],
+                sizes=[(s, s) for s in sizes]
+            )
+
+        return True
+    except Exception as e:
+        logger.error(f"Failed to generate favicon: {str(e)}")
+        return False
+
+@app.post("/api/upload-logo", tags=["settings"])
+async def upload_logo(file: UploadFile = File(...)):
+    """Upload a custom logo image.
+
+    Supported formats: PNG, JPG, JPEG, GIF, WebP, SVG
+    Maximum size: 5MB
+
+    The uploaded file will be stored and used as the application logo.
+    A favicon will be automatically generated from the logo.
+    """
+    try:
+        # Validate file extension
+        file_ext = os.path.splitext(file.filename)[1].lower()
+        if file_ext not in ALLOWED_IMAGE_EXTENSIONS:
+            raise HTTPException(
+                status_code=400,
+                detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}"
+            )
+
+        # Read and validate file size
+        content = await file.read()
+        if len(content) > MAX_LOGO_SIZE:
+            raise HTTPException(
+                status_code=400,
+                detail=f"File too large. Maximum size: {MAX_LOGO_SIZE // (1024*1024)}MB"
+            )
+
+        # Ensure custom branding directory exists
+        os.makedirs(CUSTOM_BRANDING_DIR, exist_ok=True)
+
+        # Delete old logo and favicon if they exist
+        if state.custom_logo:
+            old_logo_path = os.path.join(CUSTOM_BRANDING_DIR, state.custom_logo)
+            if os.path.exists(old_logo_path):
+                os.remove(old_logo_path)
+            # Also remove old favicon
+            old_favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
+            if os.path.exists(old_favicon_path):
+                os.remove(old_favicon_path)
+
+        # Generate a unique filename to prevent caching issues
+        import uuid
+        filename = f"logo-{uuid.uuid4().hex[:8]}{file_ext}"
+        file_path = os.path.join(CUSTOM_BRANDING_DIR, filename)
+
+        # Save the logo file
+        with open(file_path, "wb") as f:
+            f.write(content)
+
+        # Generate favicon from logo (for non-SVG files)
+        favicon_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)
+
+        # Update state
+        state.custom_logo = filename
+        state.save()
+
+        logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}")
+        return {
+            "success": True,
+            "filename": filename,
+            "url": f"/static/custom/{filename}",
+            "favicon_generated": favicon_generated
+        }
+
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Error uploading logo: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.delete("/api/custom-logo", tags=["settings"])
+async def delete_custom_logo():
+    """Remove custom logo and favicon, reverting to defaults."""
+    try:
+        if state.custom_logo:
+            # Remove logo
+            logo_path = os.path.join(CUSTOM_BRANDING_DIR, state.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)
+
+            state.custom_logo = None
+            state.save()
+            logger.info("Custom logo and favicon removed")
+        return {"success": True}
+    except Exception as e:
+        logger.error(f"Error removing logo: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
 @app.get("/api/mqtt-config", deprecated=True, tags=["settings-deprecated"])
 async def get_mqtt_config():
     """DEPRECATED: Use GET /api/settings instead. Get current MQTT configuration.
@@ -2090,15 +2251,15 @@ async def preview_thr_batch(request: dict):
 @app.get("/playlists")
 async def playlists(request: Request):
     logger.debug("Rendering playlists page")
-    return templates.TemplateResponse("playlists.html", {"request": request, "app_name": state.app_name})
+    return templates.TemplateResponse("playlists.html", {"request": request, "app_name": state.app_name, "custom_logo": state.custom_logo})
 
 @app.get("/image2sand")
 async def image2sand(request: Request):
-    return templates.TemplateResponse("image2sand.html", {"request": request, "app_name": state.app_name})
+    return templates.TemplateResponse("image2sand.html", {"request": request, "app_name": state.app_name, "custom_logo": state.custom_logo})
 
 @app.get("/led")
 async def led_control_page(request: Request):
-    return templates.TemplateResponse("led.html", {"request": request, "app_name": state.app_name})
+    return templates.TemplateResponse("led.html", {"request": request, "app_name": state.app_name, "custom_logo": state.custom_logo})
 
 # DW LED control endpoints
 @app.get("/api/dw_leds/status")
@@ -2458,7 +2619,7 @@ async def dw_leds_get_idle_timeout():
 
 @app.get("/table_control")
 async def table_control(request: Request):
-    return templates.TemplateResponse("table_control.html", {"request": request, "app_name": state.app_name})
+    return templates.TemplateResponse("table_control.html", {"request": request, "app_name": state.app_name, "custom_logo": state.custom_logo})
 
 @app.get("/cache-progress")
 async def get_cache_progress_endpoint():

+ 6 - 0
modules/core/state.py

@@ -93,6 +93,10 @@ class AppState:
         
         # Application name setting
         self.app_name = "Dune Weaver"  # Default app name
+
+        # Custom branding settings (filenames only, files stored in static/custom/)
+        # Favicon is auto-generated from logo as logo-favicon.ico
+        self.custom_logo = None  # Custom logo filename (e.g., "logo-abc123.png")
         
         # auto_play mode settings
         self.auto_play_enabled = False
@@ -260,6 +264,7 @@ class AppState:
             "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
             "dw_led_idle_timeout_minutes": self.dw_led_idle_timeout_minutes,
             "app_name": self.app_name,
+            "custom_logo": self.custom_logo,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_playlist": self.auto_play_playlist,
             "auto_play_run_mode": self.auto_play_run_mode,
@@ -343,6 +348,7 @@ class AppState:
         self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
 
         self.app_name = data.get("app_name", "Dune Weaver")
+        self.custom_logo = data.get("custom_logo", None)
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_playlist = data.get("auto_play_playlist", None)
         self.auto_play_run_mode = data.get("auto_play_run_mode", "loop")

+ 0 - 0
static/custom/.gitkeep


+ 8 - 2
templates/base.html

@@ -22,10 +22,16 @@
     <link rel="preload" href="/static/fonts/material-icons/MaterialIcons-Regular.woff2" as="font" type="font/woff2" crossorigin>
     <link rel="preload" href="/static/fonts/material-icons/MaterialIconsOutlined-Regular.woff2" as="font" type="font/woff2" crossorigin>
     <title>{% block title %}{{ app_name or 'Dune Weaver' }}{% endblock %}</title>
+    {% if custom_logo %}
+    {# Favicon is auto-generated from logo as favicon.ico #}
+    <link rel="apple-touch-icon" sizes="180x180" href="/static/custom/{{ custom_logo }}">
+    <link rel="icon" type="image/x-icon" href="/static/custom/favicon.ico">
+    {% else %}
     <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
     <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
     <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
     <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
+    {% endif %}
     <link rel="manifest" href="/static/site.webmanifest?v=2">
     <link rel="stylesheet" href="/static/css/tailwind.css">
     <link rel="stylesheet" href="/static/css/material-icons.css">
@@ -260,8 +266,8 @@
         >
           <div class="flex items-center gap-3 text-gray-800">
             <a href="/" class="flex items-center gap-3 text-gray-800 hover:opacity-80 transition-opacity">
-            <div class="text-blue-600 w-9 h-9 rounded-full shadow">
-              <img src="/static/apple-touch-icon.png" alt="Dune Weaver Logo"/>
+            <div class="text-blue-600 w-9 h-9 rounded-full shadow overflow-hidden">
+              <img src="{% if custom_logo %}/static/custom/{{ custom_logo }}{% else %}/static/apple-touch-icon.png{% endif %}" alt="{{ app_name or 'Dune Weaver' }} Logo" class="w-full h-full object-cover"/>
             </div>
             <h1
               class="text-gray-800 text-xl font-bold leading-tight tracking-tight flex items-center gap-2"

+ 37 - 0
templates/settings.html

@@ -572,6 +572,43 @@ input:checked + .slider:before {
           This name will appear in the browser tab and at the top of every page.
         </p>
       </label>
+
+      <!-- Custom Logo Section -->
+      <div class="border-t border-slate-200 pt-6">
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">Custom Logo & Favicon</span>
+          <p class="text-xs text-slate-500 mb-2">
+            Upload a custom logo to replace the default. The favicon (browser tab icon) will be automatically generated from your logo. Recommended size: 180x180 pixels. Supported formats: PNG, JPG, GIF, WebP, SVG.
+          </p>
+          <div class="flex gap-4 items-start">
+            <!-- Logo Preview -->
+            <div class="flex-shrink-0">
+              <div id="logoPreviewContainer" class="w-16 h-16 rounded-full shadow border border-slate-200 overflow-hidden bg-slate-100 flex items-center justify-center">
+                <img id="logoPreview" src="{% if custom_logo %}/static/custom/{{ custom_logo }}{% else %}/static/apple-touch-icon.png{% endif %}" alt="Logo Preview" class="w-full h-full object-cover"/>
+              </div>
+            </div>
+            <!-- Upload Controls -->
+            <div class="flex-1 space-y-2">
+              <div class="flex gap-2 flex-wrap">
+                <label class="flex items-center justify-center gap-2 cursor-pointer rounded-lg h-10 px-4 bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors">
+                  <span class="material-icons text-lg">upload</span>
+                  <span>Upload Logo</span>
+                  <input type="file" id="logoFileInput" accept=".png,.jpg,.jpeg,.gif,.webp,.svg" class="hidden" />
+                </label>
+                <button
+                  type="button"
+                  id="resetLogoBtn"
+                  class="flex items-center justify-center gap-2 cursor-pointer rounded-lg h-10 px-4 border border-slate-300 hover:bg-slate-50 text-slate-700 text-sm font-medium leading-normal transition-colors {% if not custom_logo %}hidden{% endif %}"
+                >
+                  <span class="material-icons text-lg">restart_alt</span>
+                  <span>Reset to Default</span>
+                </button>
+              </div>
+              <p id="logoUploadStatus" class="text-xs text-slate-500"></p>
+            </div>
+          </div>
+        </label>
+      </div>
     </div>
   </section>
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">