Parcourir la source

- External links can now be embedded into sidebar navigation

maziggy il y a 5 mois
Parent
commit
36b57bbafb

+ 268 - 0
backend/app/api/routes/external_links.py

@@ -0,0 +1,268 @@
+"""API routes for external sidebar links."""
+
+import logging
+import os
+import uuid
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from fastapi.responses import FileResponse
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import get_db
+from backend.app.models.external_link import ExternalLink
+from backend.app.schemas.external_link import (
+    ExternalLinkCreate,
+    ExternalLinkUpdate,
+    ExternalLinkResponse,
+    ExternalLinkReorder,
+)
+
+# Directory for storing custom icons
+ICONS_DIR = app_settings.base_dir / "icons"
+ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/external-links", tags=["external-links"])
+
+
+@router.get("/", response_model=list[ExternalLinkResponse])
+async def list_external_links(db: AsyncSession = Depends(get_db)):
+    """List all external links ordered by sort_order."""
+    result = await db.execute(
+        select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id)
+    )
+    links = result.scalars().all()
+    return links
+
+
+@router.post("/", response_model=ExternalLinkResponse)
+async def create_external_link(
+    link_data: ExternalLinkCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new external link."""
+    # Get the highest sort_order to place new link at end
+    result = await db.execute(
+        select(ExternalLink).order_by(ExternalLink.sort_order.desc()).limit(1)
+    )
+    last_link = result.scalar_one_or_none()
+    next_order = (last_link.sort_order + 1) if last_link else 0
+
+    link = ExternalLink(
+        name=link_data.name,
+        url=link_data.url,
+        icon=link_data.icon,
+        sort_order=next_order,
+    )
+
+    db.add(link)
+    await db.commit()
+    await db.refresh(link)
+
+    logger.info(f"Created external link: {link.name} -> {link.url}")
+
+    return link
+
+
+@router.get("/{link_id}", response_model=ExternalLinkResponse)
+async def get_external_link(
+    link_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a specific external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    return link
+
+
+@router.patch("/{link_id}", response_model=ExternalLinkResponse)
+async def update_external_link(
+    link_id: int,
+    update_data: ExternalLinkUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    # Update only provided fields
+    update_dict = update_data.model_dump(exclude_unset=True)
+    for key, value in update_dict.items():
+        setattr(link, key, value)
+
+    await db.commit()
+    await db.refresh(link)
+
+    logger.info(f"Updated external link: {link.name}")
+
+    return link
+
+
+@router.delete("/{link_id}")
+async def delete_external_link(
+    link_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    name = link.name
+    await db.delete(link)
+    await db.commit()
+
+    logger.info(f"Deleted external link: {name}")
+
+    return {"message": f"External link '{name}' deleted"}
+
+
+@router.put("/reorder", response_model=list[ExternalLinkResponse])
+async def reorder_external_links(
+    reorder_data: ExternalLinkReorder,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update the sort order of external links."""
+    # Update sort_order for each link based on position in the list
+    for index, link_id in enumerate(reorder_data.ids):
+        result = await db.execute(
+            select(ExternalLink).where(ExternalLink.id == link_id)
+        )
+        link = result.scalar_one_or_none()
+        if link:
+            link.sort_order = index
+
+    await db.commit()
+
+    # Return updated list
+    result = await db.execute(
+        select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id)
+    )
+    links = result.scalars().all()
+
+    logger.info(f"Reordered {len(reorder_data.ids)} external links")
+
+    return links
+
+
+@router.post("/{link_id}/icon", response_model=ExternalLinkResponse)
+async def upload_icon(
+    link_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload a custom icon for an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    # Validate file extension
+    if not file.filename:
+        raise HTTPException(status_code=400, detail="No filename provided")
+
+    ext = Path(file.filename).suffix.lower()
+    if ext not in ALLOWED_EXTENSIONS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
+        )
+
+    # Create icons directory if it doesn't exist
+    ICONS_DIR.mkdir(parents=True, exist_ok=True)
+
+    # Delete old custom icon if exists
+    if link.custom_icon:
+        old_path = ICONS_DIR / link.custom_icon
+        if old_path.exists():
+            old_path.unlink()
+
+    # Generate unique filename
+    filename = f"{uuid.uuid4().hex}{ext}"
+    filepath = ICONS_DIR / filename
+
+    # Save file
+    content = await file.read()
+    with open(filepath, "wb") as f:
+        f.write(content)
+
+    # Update link
+    link.custom_icon = filename
+    await db.commit()
+    await db.refresh(link)
+
+    logger.info(f"Uploaded custom icon for link {link.name}: {filename}")
+
+    return link
+
+
+@router.delete("/{link_id}/icon", response_model=ExternalLinkResponse)
+async def delete_icon(
+    link_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete the custom icon for an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    if link.custom_icon:
+        filepath = ICONS_DIR / link.custom_icon
+        if filepath.exists():
+            filepath.unlink()
+        link.custom_icon = None
+        await db.commit()
+        await db.refresh(link)
+        logger.info(f"Deleted custom icon for link {link.name}")
+
+    return link
+
+
+@router.get("/{link_id}/icon")
+async def get_icon(
+    link_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the custom icon for an external link."""
+    result = await db.execute(
+        select(ExternalLink).where(ExternalLink.id == link_id)
+    )
+    link = result.scalar_one_or_none()
+
+    if not link:
+        raise HTTPException(status_code=404, detail="External link not found")
+
+    if not link.custom_icon:
+        raise HTTPException(status_code=404, detail="No custom icon set")
+
+    filepath = ICONS_DIR / link.custom_icon
+    if not filepath.exists():
+        raise HTTPException(status_code=404, detail="Icon file not found")
+
+    return FileResponse(filepath)

+ 1 - 1
backend/app/core/config.py

@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
 import logging
 
 # Application version - single source of truth
-APP_VERSION = "0.1.5b"
+APP_VERSION = "0.1.5b3"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # Base directory for path calculations

+ 1 - 1
backend/app/core/database.py

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 async def init_db():
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link  # noqa: F401
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)

+ 2 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -1031,6 +1031,7 @@ app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
+app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 25 - 0
backend/app/models/external_link.py

@@ -0,0 +1,25 @@
+from datetime import datetime
+from typing import Optional
+from sqlalchemy import String, Integer, DateTime, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class ExternalLink(Base):
+    """External links for sidebar navigation."""
+
+    __tablename__ = "external_links"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(50))
+    url: Mapped[str] = mapped_column(String(500))
+    icon: Mapped[str] = mapped_column(String(50), default="link")
+    custom_icon: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)  # Filename of uploaded icon
+    sort_order: Mapped[int] = mapped_column(Integer, default=0)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now()
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )

+ 58 - 0
backend/app/schemas/external_link.py

@@ -0,0 +1,58 @@
+from datetime import datetime
+from pydantic import BaseModel, Field, field_validator
+
+
+class ExternalLinkBase(BaseModel):
+    """Base schema for external links."""
+
+    name: str = Field(..., min_length=1, max_length=50, description="Display name for the link")
+    url: str = Field(..., min_length=1, max_length=500, description="External URL")
+    icon: str = Field(default="link", max_length=50, description="Lucide icon name")
+
+    @field_validator("url")
+    @classmethod
+    def validate_url(cls, v: str) -> str:
+        """Validate URL format."""
+        if not v.startswith(("http://", "https://")):
+            raise ValueError("URL must start with http:// or https://")
+        return v
+
+
+class ExternalLinkCreate(ExternalLinkBase):
+    """Schema for creating an external link."""
+
+    pass
+
+
+class ExternalLinkUpdate(BaseModel):
+    """Schema for updating an external link (all fields optional)."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=50)
+    url: str | None = Field(default=None, min_length=1, max_length=500)
+    icon: str | None = Field(default=None, max_length=50)
+
+    @field_validator("url")
+    @classmethod
+    def validate_url(cls, v: str | None) -> str | None:
+        """Validate URL format."""
+        if v is not None and not v.startswith(("http://", "https://")):
+            raise ValueError("URL must start with http:// or https://")
+        return v
+
+
+class ExternalLinkResponse(ExternalLinkBase):
+    """Response schema for external links."""
+
+    id: int
+    custom_icon: str | None = None
+    sort_order: int
+    created_at: datetime
+    updated_at: datetime
+
+    model_config = {"from_attributes": True}
+
+
+class ExternalLinkReorder(BaseModel):
+    """Schema for reordering external links."""
+
+    ids: list[int] = Field(..., description="List of link IDs in desired order")

+ 2 - 0
frontend/src/App.tsx

@@ -9,6 +9,7 @@ import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { MaintenancePage } from './pages/MaintenancePage';
 import { CameraPage } from './pages/CameraPage';
+import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -46,6 +47,7 @@ function App() {
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="settings" element={<SettingsPage />} />
+                  <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
               </Routes>
             </BrowserRouter>

+ 61 - 0
frontend/src/api/client.ts

@@ -881,6 +881,30 @@ export interface MaintenanceSummary {
   }>;
 }
 
+// External Links (sidebar)
+export interface ExternalLink {
+  id: number;
+  name: string;
+  url: string;
+  icon: string;
+  custom_icon: string | null;
+  sort_order: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface ExternalLinkCreate {
+  name: string;
+  url: string;
+  icon: string;
+}
+
+export interface ExternalLinkUpdate {
+  name?: string;
+  url?: string;
+  icon?: string;
+}
+
 // API functions
 export const api = {
   // Printers
@@ -1506,4 +1530,41 @@ export const api = {
     `${API_BASE}/printers/${printerId}/camera/snapshot`,
   testCameraConnection: (printerId: number) =>
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
+
+  // External Links
+  getExternalLinks: () => request<ExternalLink[]>('/external-links/'),
+  getExternalLink: (id: number) => request<ExternalLink>(`/external-links/${id}`),
+  createExternalLink: (data: ExternalLinkCreate) =>
+    request<ExternalLink>('/external-links/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateExternalLink: (id: number, data: ExternalLinkUpdate) =>
+    request<ExternalLink>(`/external-links/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteExternalLink: (id: number) =>
+    request<{ message: string }>(`/external-links/${id}`, { method: 'DELETE' }),
+  reorderExternalLinks: (ids: number[]) =>
+    request<ExternalLink[]>('/external-links/reorder', {
+      method: 'PUT',
+      body: JSON.stringify({ ids }),
+    }),
+  uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  deleteExternalLinkIcon: (id: number) =>
+    request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
+  getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`,
 };

+ 299 - 0
frontend/src/components/AddExternalLinkModal.tsx

@@ -0,0 +1,299 @@
+import { useState, useEffect, useRef } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
+import { Button } from './Button';
+import { IconPicker, getIconByName } from './IconPicker';
+import { useTheme } from '../contexts/ThemeContext';
+
+interface AddExternalLinkModalProps {
+  link?: ExternalLink | null;
+  onClose: () => void;
+}
+
+export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
+  const queryClient = useQueryClient();
+  const { theme } = useTheme();
+  const isEditing = !!link;
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const [name, setName] = useState(link?.name || '');
+  const [url, setUrl] = useState(link?.url || '');
+  const [icon, setIcon] = useState(link?.icon || 'link');
+  const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);
+  const [customIconPreview, setCustomIconPreview] = useState<string | null>(
+    link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
+  );
+  const [pendingIconFile, setPendingIconFile] = useState<File | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Create mutation
+  const createMutation = useMutation({
+    mutationFn: async (data: ExternalLinkCreate) => {
+      const created = await api.createExternalLink(data);
+      // If there's a pending icon file, upload it
+      if (pendingIconFile) {
+        return await api.uploadExternalLinkIcon(created.id, pendingIconFile);
+      }
+      return created;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  // Update mutation
+  const updateMutation = useMutation({
+    mutationFn: async (data: ExternalLinkUpdate) => {
+      let updated = await api.updateExternalLink(link!.id, data);
+      // Handle icon changes
+      if (pendingIconFile) {
+        // Upload new icon
+        updated = await api.uploadExternalLinkIcon(link!.id, pendingIconFile);
+      } else if (!useCustomIcon && link?.custom_icon) {
+        // Remove custom icon if switching to preset
+        updated = await api.deleteExternalLinkIcon(link!.id);
+      }
+      return updated;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+      onClose();
+    },
+    onError: (err: Error) => {
+      setError(err.message);
+    },
+  });
+
+  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (file) {
+      // Validate file type
+      const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml', 'image/webp', 'image/x-icon'];
+      if (!validTypes.includes(file.type)) {
+        setError('Please select a valid image file (PNG, JPG, GIF, SVG, WebP, or ICO)');
+        return;
+      }
+
+      // Validate file size (max 1MB)
+      if (file.size > 1024 * 1024) {
+        setError('Image file must be less than 1MB');
+        return;
+      }
+
+      setPendingIconFile(file);
+      setUseCustomIcon(true);
+
+      // Create preview
+      const reader = new FileReader();
+      reader.onload = (e) => {
+        setCustomIconPreview(e.target?.result as string);
+      };
+      reader.readAsDataURL(file);
+    }
+  };
+
+  const handleRemoveCustomIcon = () => {
+    setPendingIconFile(null);
+    setCustomIconPreview(null);
+    setUseCustomIcon(false);
+    if (fileInputRef.current) {
+      fileInputRef.current.value = '';
+    }
+  };
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    setError(null);
+
+    if (!name.trim()) {
+      setError('Name is required');
+      return;
+    }
+
+    if (!url.trim()) {
+      setError('URL is required');
+      return;
+    }
+
+    // Validate URL
+    if (!url.startsWith('http://') && !url.startsWith('https://')) {
+      setError('URL must start with http:// or https://');
+      return;
+    }
+
+    const data = {
+      name: name.trim(),
+      url: url.trim(),
+      icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
+    };
+
+    if (isEditing) {
+      updateMutation.mutate(data);
+    } else {
+      createMutation.mutate(data);
+    }
+  };
+
+  const isPending = createMutation.isPending || updateMutation.isPending;
+  const PresetIcon = getIconByName(icon);
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
+              {useCustomIcon && customIconPreview ? (
+                <img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${theme === 'dark' ? 'invert brightness-200' : ''}`} />
+              ) : (
+                <PresetIcon className="w-5 h-5" />
+              )}
+            </div>
+            <h2 className="text-lg font-semibold text-white">
+              {isEditing ? 'Edit Link' : 'Add External Link'}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Form */}
+        <form onSubmit={handleSubmit} className="p-6 space-y-4">
+          {error && (
+            <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+              {error}
+            </div>
+          )}
+
+          {/* Name */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">Name *</label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder="My Link"
+              maxLength={50}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            />
+          </div>
+
+          {/* URL */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">URL *</label>
+            <input
+              type="text"
+              value={url}
+              onChange={(e) => setUrl(e.target.value)}
+              placeholder="https://example.com"
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            />
+          </div>
+
+          {/* Icon Section */}
+          <div className="space-y-3">
+            <label className="block text-sm text-bambu-gray">Icon</label>
+
+            {/* Custom Icon Upload */}
+            <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+              <div className="flex items-center justify-between mb-2">
+                <span className="text-sm text-white">Custom Icon</span>
+                <input
+                  ref={fileInputRef}
+                  type="file"
+                  accept="image/png,image/jpeg,image/gif,image/svg+xml,image/webp,image/x-icon"
+                  className="hidden"
+                  onChange={handleFileSelect}
+                />
+                {useCustomIcon && customIconPreview ? (
+                  <div className="flex items-center gap-2">
+                    <img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${theme === 'dark' ? 'invert brightness-200' : ''}`} />
+                    <button
+                      type="button"
+                      onClick={handleRemoveCustomIcon}
+                      className="p-1 text-red-400 hover:text-red-300 transition-colors"
+                      title="Remove custom icon"
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </button>
+                  </div>
+                ) : (
+                  <Button
+                    type="button"
+                    variant="secondary"
+                    size="sm"
+                    onClick={() => fileInputRef.current?.click()}
+                  >
+                    <Upload className="w-4 h-4" />
+                    Upload
+                  </Button>
+                )}
+              </div>
+              <p className="text-xs text-bambu-gray">
+                PNG, JPG, GIF, SVG, WebP, or ICO. Max 1MB.
+              </p>
+            </div>
+
+            {/* Preset Icon Picker */}
+            {!useCustomIcon && (
+              <div>
+                <span className="text-sm text-bambu-gray block mb-2">Or choose a preset icon</span>
+                <IconPicker value={icon} onChange={setIcon} />
+              </div>
+            )}
+          </div>
+
+          {/* Actions */}
+          <div className="flex gap-3 pt-2">
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={onClose}
+              className="flex-1"
+            >
+              Cancel
+            </Button>
+            <Button
+              type="submit"
+              disabled={isPending}
+              className="flex-1"
+            >
+              {isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Save className="w-4 h-4" />
+              )}
+              {isEditing ? 'Save' : 'Add'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 187 - 0
frontend/src/components/ExternalLinksSettings.tsx

@@ -0,0 +1,187 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Link2, Plus, Pencil, Trash2, GripVertical, Loader2, ExternalLink as ExternalLinkIcon } from 'lucide-react';
+import { api } from '../api/client';
+import type { ExternalLink } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { AddExternalLinkModal } from './AddExternalLinkModal';
+import { ConfirmModal } from './ConfirmModal';
+import { getIconByName } from './IconPicker';
+
+export function ExternalLinksSettings() {
+  const queryClient = useQueryClient();
+  const [showAddModal, setShowAddModal] = useState(false);
+  const [editingLink, setEditingLink] = useState<ExternalLink | null>(null);
+  const [deletingLink, setDeletingLink] = useState<ExternalLink | null>(null);
+  const [draggedId, setDraggedId] = useState<number | null>(null);
+
+  // Fetch external links
+  const { data: links, isLoading } = useQuery({
+    queryKey: ['external-links'],
+    queryFn: api.getExternalLinks,
+  });
+
+  // Delete mutation
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteExternalLink(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+    },
+  });
+
+  // Reorder mutation
+  const reorderMutation = useMutation({
+    mutationFn: (ids: number[]) => api.reorderExternalLinks(ids),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['external-links'] });
+    },
+  });
+
+  const handleDragStart = (e: React.DragEvent, id: number) => {
+    setDraggedId(id);
+    e.dataTransfer.effectAllowed = 'move';
+  };
+
+  const handleDragOver = (e: React.DragEvent) => {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = 'move';
+  };
+
+  const handleDrop = (e: React.DragEvent, targetId: number) => {
+    e.preventDefault();
+    if (draggedId === null || draggedId === targetId || !links) return;
+
+    const currentIds = links.map((l) => l.id);
+    const draggedIndex = currentIds.indexOf(draggedId);
+    const targetIndex = currentIds.indexOf(targetId);
+
+    if (draggedIndex === -1 || targetIndex === -1) return;
+
+    // Reorder
+    const newIds = [...currentIds];
+    newIds.splice(draggedIndex, 1);
+    newIds.splice(targetIndex, 0, draggedId);
+
+    reorderMutation.mutate(newIds);
+    setDraggedId(null);
+  };
+
+  const handleDelete = (link: ExternalLink) => {
+    setDeletingLink(link);
+  };
+
+  const confirmDelete = () => {
+    if (deletingLink) {
+      deleteMutation.mutate(deletingLink.id);
+      setDeletingLink(null);
+    }
+  };
+
+  return (
+    <>
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <Link2 className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-lg font-semibold text-white">Sidebar Links</h2>
+            </div>
+            <Button size="sm" onClick={() => setShowAddModal(true)}>
+              <Plus className="w-4 h-4" />
+              Add Link
+            </Button>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <p className="text-sm text-bambu-gray mb-4">
+            Add external links to the sidebar navigation. Drag to reorder.
+          </p>
+
+          {isLoading ? (
+            <div className="flex justify-center py-8">
+              <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+            </div>
+          ) : links && links.length > 0 ? (
+            <div className="space-y-2">
+              {links.map((link) => {
+                const Icon = getIconByName(link.icon);
+                return (
+                  <div
+                    key={link.id}
+                    draggable
+                    onDragStart={(e) => handleDragStart(e, link.id)}
+                    onDragOver={handleDragOver}
+                    onDrop={(e) => handleDrop(e, link.id)}
+                    className={`flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary transition-colors ${
+                      draggedId === link.id ? 'opacity-50' : ''
+                    }`}
+                  >
+                    <GripVertical className="w-4 h-4 text-bambu-gray cursor-grab flex-shrink-0" />
+                    <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-gray">
+                      <Icon className="w-4 h-4" />
+                    </div>
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center gap-2">
+                        <span className="text-white font-medium truncate">{link.name}</span>
+                        <ExternalLinkIcon className="w-3 h-3 text-bambu-gray flex-shrink-0" />
+                      </div>
+                      <span className="text-sm text-bambu-gray truncate block">{link.url}</span>
+                    </div>
+                    <div className="flex items-center gap-1 flex-shrink-0">
+                      <button
+                        onClick={() => setEditingLink(link)}
+                        className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+                        title="Edit"
+                      >
+                        <Pencil className="w-4 h-4" />
+                      </button>
+                      <button
+                        onClick={() => handleDelete(link)}
+                        disabled={deleteMutation.isPending}
+                        className="p-2 rounded-lg hover:bg-red-500/20 text-bambu-gray hover:text-red-400 transition-colors disabled:opacity-50"
+                        title="Delete"
+                      >
+                        <Trash2 className="w-4 h-4" />
+                      </button>
+                    </div>
+                  </div>
+                );
+              })}
+            </div>
+          ) : (
+            <div className="text-center py-8 text-bambu-gray">
+              <Link2 className="w-8 h-8 mx-auto mb-2 opacity-50" />
+              <p>No external links configured</p>
+              <p className="text-sm">Click "Add Link" to add one</p>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Add/Edit Modal */}
+      {(showAddModal || editingLink) && (
+        <AddExternalLinkModal
+          link={editingLink}
+          onClose={() => {
+            setShowAddModal(false);
+            setEditingLink(null);
+          }}
+        />
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deletingLink && (
+        <ConfirmModal
+          title="Delete Link"
+          message={`Are you sure you want to delete "${deletingLink.name}"? This action cannot be undone.`}
+          confirmText="Delete"
+          cancelText="Cancel"
+          variant="danger"
+          onConfirm={confirmDelete}
+          onCancel={() => setDeletingLink(null)}
+        />
+      )}
+    </>
+  );
+}

+ 132 - 0
frontend/src/components/IconPicker.tsx

@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import {
+  Globe,
+  Link,
+  ExternalLink,
+  Book,
+  FileText,
+  Home,
+  Star,
+  Heart,
+  Bookmark,
+  ShoppingCart,
+  Music,
+  Video,
+  Image,
+  Camera,
+  Map,
+  Compass,
+  Coffee,
+  Gift,
+  Wrench,
+  Zap,
+  Cloud,
+  Database,
+  Folder,
+  Mail,
+  Phone,
+  User,
+  Users,
+  Server,
+  Terminal,
+  Code,
+  type LucideIcon,
+} from 'lucide-react';
+
+// Available icons for external links
+export const AVAILABLE_ICONS: { name: string; icon: LucideIcon }[] = [
+  { name: 'globe', icon: Globe },
+  { name: 'link', icon: Link },
+  { name: 'external-link', icon: ExternalLink },
+  { name: 'book', icon: Book },
+  { name: 'file-text', icon: FileText },
+  { name: 'home', icon: Home },
+  { name: 'star', icon: Star },
+  { name: 'heart', icon: Heart },
+  { name: 'bookmark', icon: Bookmark },
+  { name: 'shopping-cart', icon: ShoppingCart },
+  { name: 'music', icon: Music },
+  { name: 'video', icon: Video },
+  { name: 'image', icon: Image },
+  { name: 'camera', icon: Camera },
+  { name: 'map', icon: Map },
+  { name: 'compass', icon: Compass },
+  { name: 'coffee', icon: Coffee },
+  { name: 'gift', icon: Gift },
+  { name: 'wrench', icon: Wrench },
+  { name: 'zap', icon: Zap },
+  { name: 'cloud', icon: Cloud },
+  { name: 'database', icon: Database },
+  { name: 'folder', icon: Folder },
+  { name: 'mail', icon: Mail },
+  { name: 'phone', icon: Phone },
+  { name: 'user', icon: User },
+  { name: 'users', icon: Users },
+  { name: 'server', icon: Server },
+  { name: 'terminal', icon: Terminal },
+  { name: 'code', icon: Code },
+];
+
+// Helper to get icon component by name
+export function getIconByName(name: string): LucideIcon {
+  const found = AVAILABLE_ICONS.find((i) => i.name === name);
+  return found?.icon || Link;
+}
+
+interface IconPickerProps {
+  value: string;
+  onChange: (value: string) => void;
+}
+
+export function IconPicker({ value, onChange }: IconPickerProps) {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const SelectedIcon = getIconByName(value);
+
+  return (
+    <div className="relative">
+      <button
+        type="button"
+        onClick={() => setIsOpen(!isOpen)}
+        className="flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white hover:border-bambu-gray focus:border-bambu-green focus:outline-none w-full"
+      >
+        <SelectedIcon className="w-5 h-5" />
+        <span className="text-sm text-bambu-gray flex-1 text-left">{value}</span>
+      </button>
+
+      {isOpen && (
+        <>
+          {/* Backdrop */}
+          <div
+            className="fixed inset-0 z-40"
+            onClick={() => setIsOpen(false)}
+          />
+
+          {/* Dropdown */}
+          <div className="absolute z-50 mt-1 w-full max-h-64 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg">
+            <div className="grid grid-cols-5 gap-1 p-2">
+              {AVAILABLE_ICONS.map(({ name, icon: Icon }) => (
+                <button
+                  key={name}
+                  type="button"
+                  onClick={() => {
+                    onChange(name);
+                    setIsOpen(false);
+                  }}
+                  className={`p-2 rounded-lg transition-colors flex items-center justify-center ${
+                    value === name
+                      ? 'bg-bambu-green text-white'
+                      : 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                  }`}
+                  title={name}
+                >
+                  <Icon className="w-5 h-5" />
+                </button>
+              ))}
+            </div>
+          </div>
+        </>
+      )}
+    </div>
+  );
+}

+ 228 - 108
frontend/src/components/Layout.tsx

@@ -6,6 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { useQuery } from '@tanstack/react-query';
 import { api } from '../api/client';
+import { getIconByName } from './IconPicker';
 
 interface NavItem {
   id: string;
@@ -24,36 +25,27 @@ export const defaultNavItems: NavItem[] = [
   { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
 ];
 
-// Get ordered nav items from localStorage
-function getOrderedNavItems(): NavItem[] {
+// Get unified sidebar order from localStorage
+function getSidebarOrder(): string[] {
   const stored = localStorage.getItem('sidebarOrder');
   if (stored) {
     try {
-      const order: string[] = JSON.parse(stored);
-      const itemMap = new Map(defaultNavItems.map(item => [item.id, item]));
-      const ordered: NavItem[] = [];
-      for (const id of order) {
-        const item = itemMap.get(id);
-        if (item) {
-          ordered.push(item);
-          itemMap.delete(id);
-        }
-      }
-      // Add any new items that weren't in the stored order
-      for (const item of itemMap.values()) {
-        ordered.push(item);
-      }
-      return ordered;
+      return JSON.parse(stored);
     } catch {
-      return defaultNavItems;
+      return defaultNavItems.map(i => i.id);
     }
   }
-  return defaultNavItems;
+  return defaultNavItems.map(i => i.id);
+}
+
+// Save unified sidebar order to localStorage
+function saveSidebarOrder(order: string[]) {
+  localStorage.setItem('sidebarOrder', JSON.stringify(order));
 }
 
-// Save nav item order to localStorage
-function saveNavOrder(items: NavItem[]) {
-  localStorage.setItem('sidebarOrder', JSON.stringify(items.map(i => i.id)));
+// Check if an ID is an external link
+function isExternalLinkId(id: string): boolean {
+  return id.startsWith('ext-');
 }
 
 // Get default view from localStorage
@@ -76,9 +68,9 @@ export function Layout() {
     return stored !== 'false';
   });
   const [showShortcuts, setShowShortcuts] = useState(false);
-  const [navItems, setNavItems] = useState<NavItem[]>(getOrderedNavItems);
-  const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
-  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
+  const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
+  const [draggedId, setDraggedId] = useState<string | null>(null);
+  const [dragOverId, setDragOverId] = useState<string | null>(null);
   const hasRedirected = useRef(false);
   const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
     sessionStorage.getItem('dismissedUpdateVersion')
@@ -105,73 +97,128 @@ export function Layout() {
     refetchInterval: 60 * 60 * 1000, // Check every hour
   });
 
-  // Show update banner if update available and not dismissed for this version
-  const showUpdateBanner = updateCheck?.update_available &&
-    updateCheck.latest_version &&
-    updateCheck.latest_version !== dismissedUpdateVersion;
+  // Fetch external links for sidebar
+  const { data: externalLinks } = useQuery({
+    queryKey: ['external-links'],
+    queryFn: api.getExternalLinks,
+  });
 
-  const dismissUpdateBanner = () => {
-    if (updateCheck?.latest_version) {
-      sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);
-      setDismissedUpdateVersion(updateCheck.latest_version);
+  // Build the unified sidebar items list
+  const navItemsMap = new Map(defaultNavItems.map(item => [item.id, item]));
+  const extLinksMap = new Map((externalLinks || []).map(link => [`ext-${link.id}`, link]));
+
+  // Compute the ordered sidebar: include stored order + any new items
+  const orderedSidebarIds = (() => {
+    const result: string[] = [];
+    const seen = new Set<string>();
+
+    // Add items in stored order
+    for (const id of sidebarOrder) {
+      if (navItemsMap.has(id) || extLinksMap.has(id)) {
+        result.push(id);
+        seen.add(id);
+      }
     }
-  };
 
-  // Redirect to default view on initial load
-  useEffect(() => {
-    if (!hasRedirected.current && location.pathname === '/') {
-      const defaultView = getDefaultView();
-      if (defaultView !== '/') {
-        hasRedirected.current = true;
-        navigate(defaultView, { replace: true });
+    // Add any new internal nav items not in stored order
+    for (const item of defaultNavItems) {
+      if (!seen.has(item.id)) {
+        result.push(item.id);
+        seen.add(item.id);
       }
     }
-  }, [location.pathname, navigate]);
 
-  useEffect(() => {
-    localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
-  }, [sidebarExpanded]);
+    // Add any new external links not in stored order
+    for (const link of externalLinks || []) {
+      const extId = `ext-${link.id}`;
+      if (!seen.has(extId)) {
+        result.push(extId);
+        seen.add(extId);
+      }
+    }
 
-  // Drag and drop handlers
-  const handleDragStart = (e: React.DragEvent, index: number) => {
-    setDraggedIndex(index);
+    return result;
+  })();
+
+  // Unified drag handlers
+  const handleDragStart = (e: React.DragEvent, id: string) => {
+    setDraggedId(id);
     e.dataTransfer.effectAllowed = 'move';
-    e.dataTransfer.setData('text/plain', String(index));
+    e.dataTransfer.setData('text/plain', id);
   };
 
-  const handleDragOver = (e: React.DragEvent, index: number) => {
+  const handleDragOver = (e: React.DragEvent, id: string) => {
     e.preventDefault();
     e.dataTransfer.dropEffect = 'move';
-    setDragOverIndex(index);
+    setDragOverId(id);
   };
 
   const handleDragLeave = () => {
-    setDragOverIndex(null);
+    setDragOverId(null);
   };
 
-  const handleDrop = (e: React.DragEvent, dropIndex: number) => {
+  const handleDrop = (e: React.DragEvent, targetId: string) => {
     e.preventDefault();
-    if (draggedIndex === null || draggedIndex === dropIndex) {
-      setDraggedIndex(null);
-      setDragOverIndex(null);
+    if (draggedId === null || draggedId === targetId) {
+      setDraggedId(null);
+      setDragOverId(null);
       return;
     }
 
-    const newItems = [...navItems];
-    const [draggedItem] = newItems.splice(draggedIndex, 1);
-    newItems.splice(dropIndex, 0, draggedItem);
+    const currentOrder = [...orderedSidebarIds];
+    const draggedIndex = currentOrder.indexOf(draggedId);
+    const targetIndex = currentOrder.indexOf(targetId);
+
+    if (draggedIndex === -1 || targetIndex === -1) {
+      setDraggedId(null);
+      setDragOverId(null);
+      return;
+    }
+
+    // Reorder
+    currentOrder.splice(draggedIndex, 1);
+    currentOrder.splice(targetIndex, 0, draggedId);
+
+    // Save to localStorage and update state
+    setSidebarOrder(currentOrder);
+    saveSidebarOrder(currentOrder);
 
-    setNavItems(newItems);
-    saveNavOrder(newItems);
-    setDraggedIndex(null);
-    setDragOverIndex(null);
+    setDraggedId(null);
+    setDragOverId(null);
   };
 
   const handleDragEnd = () => {
-    setDraggedIndex(null);
-    setDragOverIndex(null);
+    setDraggedId(null);
+    setDragOverId(null);
   };
 
+  // Show update banner if update available and not dismissed for this version
+  const showUpdateBanner = updateCheck?.update_available &&
+    updateCheck.latest_version &&
+    updateCheck.latest_version !== dismissedUpdateVersion;
+
+  const dismissUpdateBanner = () => {
+    if (updateCheck?.latest_version) {
+      sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);
+      setDismissedUpdateVersion(updateCheck.latest_version);
+    }
+  };
+
+  // Redirect to default view on initial load
+  useEffect(() => {
+    if (!hasRedirected.current && location.pathname === '/') {
+      const defaultView = getDefaultView();
+      if (defaultView !== '/') {
+        hasRedirected.current = true;
+        navigate(defaultView, { replace: true });
+      }
+    }
+  }, [location.pathname, navigate]);
+
+  useEffect(() => {
+    localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
+  }, [sidebarExpanded]);
+
   // Global keyboard shortcuts for navigation
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
     const target = e.target as HTMLElement;
@@ -180,13 +227,17 @@ export function Layout() {
       return;
     }
 
-    // Number keys for navigation (1-6) - follows sidebar order
+    // Number keys for navigation (1-9) - follows sidebar order for internal nav items only
     if (!e.metaKey && !e.ctrlKey && !e.altKey) {
       const keyNum = parseInt(e.key);
-      if (keyNum >= 1 && keyNum <= navItems.length) {
-        e.preventDefault();
-        navigate(navItems[keyNum - 1].to);
-        return;
+      const internalItems = orderedSidebarIds.filter(id => !isExternalLinkId(id));
+      if (keyNum >= 1 && keyNum <= internalItems.length) {
+        const navItem = navItemsMap.get(internalItems[keyNum - 1]);
+        if (navItem) {
+          e.preventDefault();
+          navigate(navItem.to);
+          return;
+        }
       }
 
       switch (e.key) {
@@ -199,7 +250,7 @@ export function Layout() {
           break;
       }
     }
-  }, [navigate, navItems]);
+  }, [navigate, orderedSidebarIds, navItemsMap]);
 
   useEffect(() => {
     document.addEventListener('keydown', handleKeyDown);
@@ -224,42 +275,103 @@ export function Layout() {
         {/* Navigation */}
         <nav className="flex-1 p-2">
           <ul className="space-y-2">
-            {navItems.map(({ id, to, icon: Icon, labelKey }, index) => (
-              <li
-                key={id}
-                draggable
-                onDragStart={(e) => handleDragStart(e, index)}
-                onDragOver={(e) => handleDragOver(e, index)}
-                onDragLeave={handleDragLeave}
-                onDrop={(e) => handleDrop(e, index)}
-                onDragEnd={handleDragEnd}
-                className={`relative ${
-                  draggedIndex === index ? 'opacity-50' : ''
-                } ${
-                  dragOverIndex === index && draggedIndex !== index
-                    ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
-                    : ''
-                }`}
-              >
-                <NavLink
-                  to={to}
-                  className={({ isActive }) =>
-                    `flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
-                      isActive
-                        ? 'bg-bambu-green text-white'
-                        : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
-                    }`
-                  }
-                  title={!sidebarExpanded ? t(labelKey) : undefined}
-                >
-                  {sidebarExpanded && (
-                    <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
-                  )}
-                  <Icon className="w-5 h-5 flex-shrink-0" />
-                  {sidebarExpanded && <span>{t(labelKey)}</span>}
-                </NavLink>
-              </li>
-            ))}
+            {orderedSidebarIds.map((id) => {
+              const isExternal = isExternalLinkId(id);
+
+              if (isExternal) {
+                // Render external link
+                const link = extLinksMap.get(id);
+                if (!link) return null;
+
+                const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
+                return (
+                  <li
+                    key={id}
+                    draggable
+                    onDragStart={(e) => handleDragStart(e, id)}
+                    onDragOver={(e) => handleDragOver(e, id)}
+                    onDragLeave={handleDragLeave}
+                    onDrop={(e) => handleDrop(e, id)}
+                    onDragEnd={handleDragEnd}
+                    className={`relative ${
+                      draggedId === id ? 'opacity-50' : ''
+                    } ${
+                      dragOverId === id && draggedId !== id
+                        ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
+                        : ''
+                    }`}
+                  >
+                    <NavLink
+                      to={`/external/${link.id}`}
+                      className={({ isActive }) =>
+                        `flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                          isActive
+                            ? 'bg-bambu-green text-white'
+                            : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
+                        }`
+                      }
+                      title={!sidebarExpanded ? link.name : undefined}
+                    >
+                      {sidebarExpanded && (
+                        <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
+                      )}
+                      {link.custom_icon ? (
+                        <img
+                          src={`/api/v1/external-links/${link.id}/icon`}
+                          alt=""
+                          className={`w-5 h-5 flex-shrink-0 ${theme === 'dark' ? 'invert brightness-200' : ''}`}
+                        />
+                      ) : (
+                        LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
+                      )}
+                      {sidebarExpanded && <span>{link.name}</span>}
+                    </NavLink>
+                  </li>
+                );
+              } else {
+                // Render internal nav item
+                const navItem = navItemsMap.get(id);
+                if (!navItem) return null;
+
+                const { to, icon: Icon, labelKey } = navItem;
+                return (
+                  <li
+                    key={id}
+                    draggable
+                    onDragStart={(e) => handleDragStart(e, id)}
+                    onDragOver={(e) => handleDragOver(e, id)}
+                    onDragLeave={handleDragLeave}
+                    onDrop={(e) => handleDrop(e, id)}
+                    onDragEnd={handleDragEnd}
+                    className={`relative ${
+                      draggedId === id ? 'opacity-50' : ''
+                    } ${
+                      dragOverId === id && draggedId !== id
+                        ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
+                        : ''
+                    }`}
+                  >
+                    <NavLink
+                      to={to}
+                      className={({ isActive }) =>
+                        `flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
+                          isActive
+                            ? 'bg-bambu-green text-white'
+                            : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
+                        }`
+                      }
+                      title={!sidebarExpanded ? t(labelKey) : undefined}
+                    >
+                      {sidebarExpanded && (
+                        <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
+                      )}
+                      <Icon className="w-5 h-5 flex-shrink-0" />
+                      {sidebarExpanded && <span>{t(labelKey)}</span>}
+                    </NavLink>
+                  </li>
+                );
+              }
+            })}
           </ul>
         </nav>
 
@@ -391,7 +503,15 @@ export function Layout() {
       </main>
 
       {/* Keyboard Shortcuts Modal */}
-      {showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} navItems={navItems} />}
+      {showShortcuts && (
+        <KeyboardShortcutsModal
+          onClose={() => setShowShortcuts(false)}
+          navItems={orderedSidebarIds
+            .filter(id => !isExternalLinkId(id))
+            .map(id => navItemsMap.get(id)!)
+            .filter(Boolean)}
+        />
+      )}
     </div>
   );
 }

+ 40 - 0
frontend/src/pages/ExternalLinkPage.tsx

@@ -0,0 +1,40 @@
+import { useParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { Loader2, AlertTriangle } from 'lucide-react';
+import { api } from '../api/client';
+
+export function ExternalLinkPage() {
+  const { id } = useParams<{ id: string }>();
+
+  const { data: link, isLoading, error } = useQuery({
+    queryKey: ['external-link', id],
+    queryFn: () => api.getExternalLink(Number(id)),
+    enabled: !!id,
+  });
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center h-full">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  if (error || !link) {
+    return (
+      <div className="flex flex-col items-center justify-center h-full gap-4 text-bambu-gray">
+        <AlertTriangle className="w-12 h-12" />
+        <p>Link not found</p>
+      </div>
+    );
+  }
+
+  return (
+    <iframe
+      src={link.url}
+      className="h-full w-full border-0 bg-white"
+      title={link.name}
+      sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
+    />
+  );
+}

+ 3 - 0
frontend/src/pages/SettingsPage.tsx

@@ -15,6 +15,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { BackupModal } from '../components/BackupModal';
 import { RestoreModal } from '../components/RestoreModal';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
+import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
@@ -734,6 +735,8 @@ export function SettingsPage() {
           </Card>
 
           <SpoolmanSettings />
+
+          <ExternalLinksSettings />
         </div>
 
         {/* Third Column - Updates */}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
icons/57eeee2303f848be9d6159c1079f100d.svg


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
icons/7a3afd1aa53c47e38ea7e55356403f99.svg


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
icons/df3231e72d3b4bc0a08c47e95599e64d.svg


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-CcC-KyfM.css


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-DHVVjLkT.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-Ob3MFXab.css


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-CycmYzoY.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Ob3MFXab.css">
+    <script type="module" crossorigin src="/assets/index-DHVVjLkT.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CcC-KyfM.css">
   </head>
   <body>
     <div id="root"></div>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff