Browse Source

Added update module

Martin Ziegler 5 months ago
parent
commit
8571236bc0

+ 1 - 1
backend/app/api/routes/settings.py

@@ -46,7 +46,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
         if setting.key in settings_dict:
             # Parse the value based on the expected type
-            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled"]:
+            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates"]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
                 settings_dict[setting.key] = float(setting.value)

+ 276 - 0
backend/app/api/routes/updates.py

@@ -0,0 +1,276 @@
+"""Update checking and management routes."""
+
+import asyncio
+import logging
+import subprocess
+import sys
+from pathlib import Path
+
+import httpx
+from fastapi import APIRouter, BackgroundTasks, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
+from backend.app.core.database import get_db
+from backend.app.api.routes.settings import get_setting
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/updates", tags=["updates"])
+
+# Global state for update progress
+_update_status = {
+    "status": "idle",  # idle, checking, downloading, installing, complete, error
+    "progress": 0,
+    "message": "",
+    "error": None,
+}
+
+
+def parse_version(version: str) -> tuple[int, ...]:
+    """Parse version string into tuple for comparison."""
+    # Remove 'v' prefix if present
+    version = version.lstrip("v")
+    # Split and convert to integers
+    parts = []
+    for part in version.split("."):
+        try:
+            parts.append(int(part))
+        except ValueError:
+            # Handle pre-release versions like "1.0.0-beta"
+            num = "".join(c for c in part if c.isdigit())
+            parts.append(int(num) if num else 0)
+    return tuple(parts)
+
+
+def is_newer_version(latest: str, current: str) -> bool:
+    """Check if latest version is newer than current."""
+    try:
+        return parse_version(latest) > parse_version(current)
+    except Exception:
+        return False
+
+
+@router.get("/version")
+async def get_version():
+    """Get current application version."""
+    return {
+        "version": APP_VERSION,
+        "repo": GITHUB_REPO,
+    }
+
+
+@router.get("/check")
+async def check_for_updates(db: AsyncSession = Depends(get_db)):
+    """Check GitHub for available updates."""
+    global _update_status
+
+    _update_status = {
+        "status": "checking",
+        "progress": 0,
+        "message": "Checking for updates...",
+        "error": None,
+    }
+
+    try:
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
+                headers={"Accept": "application/vnd.github.v3+json"},
+                timeout=10.0,
+            )
+
+            if response.status_code == 404:
+                # No releases yet
+                _update_status = {
+                    "status": "idle",
+                    "progress": 100,
+                    "message": "No releases found",
+                    "error": None,
+                }
+                return {
+                    "update_available": False,
+                    "current_version": APP_VERSION,
+                    "latest_version": None,
+                    "message": "No releases found",
+                }
+
+            response.raise_for_status()
+            release_data = response.json()
+
+            latest_version = release_data.get("tag_name", "").lstrip("v")
+            release_name = release_data.get("name", latest_version)
+            release_notes = release_data.get("body", "")
+            release_url = release_data.get("html_url", "")
+            published_at = release_data.get("published_at", "")
+
+            update_available = is_newer_version(latest_version, APP_VERSION)
+
+            _update_status = {
+                "status": "idle",
+                "progress": 100,
+                "message": "Update available" if update_available else "Up to date",
+                "error": None,
+            }
+
+            return {
+                "update_available": update_available,
+                "current_version": APP_VERSION,
+                "latest_version": latest_version,
+                "release_name": release_name,
+                "release_notes": release_notes,
+                "release_url": release_url,
+                "published_at": published_at,
+            }
+
+    except httpx.HTTPError as e:
+        logger.error(f"Failed to check for updates: {e}")
+        _update_status = {
+            "status": "error",
+            "progress": 0,
+            "message": "Failed to check for updates",
+            "error": str(e),
+        }
+        return {
+            "update_available": False,
+            "current_version": APP_VERSION,
+            "latest_version": None,
+            "error": str(e),
+        }
+
+
+async def _perform_update():
+    """Perform the actual update using git pull."""
+    global _update_status
+
+    try:
+        _update_status = {
+            "status": "downloading",
+            "progress": 20,
+            "message": "Pulling latest changes...",
+            "error": None,
+        }
+
+        # Run git pull in the project directory
+        base_dir = settings.base_dir
+        process = await asyncio.create_subprocess_exec(
+            "git", "pull", "--rebase",
+            cwd=str(base_dir),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            error_msg = stderr.decode() if stderr else "Git pull failed"
+            logger.error(f"Git pull failed: {error_msg}")
+            _update_status = {
+                "status": "error",
+                "progress": 0,
+                "message": "Failed to pull updates",
+                "error": error_msg,
+            }
+            return
+
+        _update_status = {
+            "status": "installing",
+            "progress": 50,
+            "message": "Installing dependencies...",
+            "error": None,
+        }
+
+        # Install Python dependencies
+        process = await asyncio.create_subprocess_exec(
+            sys.executable, "-m", "pip", "install", "-r", "requirements.txt", "-q",
+            cwd=str(base_dir),
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await process.communicate()
+
+        if process.returncode != 0:
+            logger.warning(f"pip install warning: {stderr.decode() if stderr else 'unknown'}")
+
+        _update_status = {
+            "status": "installing",
+            "progress": 70,
+            "message": "Building frontend...",
+            "error": None,
+        }
+
+        # Build frontend
+        frontend_dir = base_dir / "frontend"
+        if frontend_dir.exists():
+            # npm install
+            process = await asyncio.create_subprocess_exec(
+                "npm", "install",
+                cwd=str(frontend_dir),
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+            await process.communicate()
+
+            # npm run build
+            process = await asyncio.create_subprocess_exec(
+                "npm", "run", "build",
+                cwd=str(frontend_dir),
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.PIPE,
+            )
+            stdout, stderr = await process.communicate()
+
+            if process.returncode != 0:
+                logger.warning(f"Frontend build warning: {stderr.decode() if stderr else 'unknown'}")
+
+        _update_status = {
+            "status": "complete",
+            "progress": 100,
+            "message": "Update complete! Please restart the application.",
+            "error": None,
+        }
+
+        logger.info("Update completed successfully")
+
+    except Exception as e:
+        logger.error(f"Update failed: {e}")
+        _update_status = {
+            "status": "error",
+            "progress": 0,
+            "message": "Update failed",
+            "error": str(e),
+        }
+
+
+@router.post("/apply")
+async def apply_update(background_tasks: BackgroundTasks):
+    """Apply available update (git pull + rebuild)."""
+    global _update_status
+
+    if _update_status["status"] in ["downloading", "installing"]:
+        return {
+            "success": False,
+            "message": "Update already in progress",
+            "status": _update_status,
+        }
+
+    # Start update in background
+    background_tasks.add_task(_perform_update)
+
+    _update_status = {
+        "status": "downloading",
+        "progress": 10,
+        "message": "Starting update...",
+        "error": None,
+    }
+
+    return {
+        "success": True,
+        "message": "Update started",
+        "status": _update_status,
+    }
+
+
+@router.get("/status")
+async def get_update_status():
+    """Get current update status."""
+    return _update_status

+ 4 - 0
backend/app/core/config.py

@@ -1,6 +1,10 @@
 from pathlib import Path
 from pydantic_settings import BaseSettings
 
+# Application version - single source of truth
+APP_VERSION = "0.1.4"
+GITHUB_REPO = "maziggy/bambusy"
+
 
 class Settings(BaseSettings):
     app_name: str = "BambuTrack"

+ 4 - 3
backend/app/main.py

@@ -9,7 +9,7 @@ from logging.handlers import RotatingFileHandler
 from fastapi import FastAPI
 
 # Import settings first for logging configuration
-from backend.app.core.config import settings as app_settings
+from backend.app.core.config import settings as app_settings, APP_VERSION
 
 # Configure logging based on settings
 # DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
@@ -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, spoolman
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates
 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 (
@@ -963,7 +963,7 @@ async def lifespan(app: FastAPI):
 app = FastAPI(
     title=app_settings.app_name,
     description="Archive and manage Bambu Lab 3MF files",
-    version="0.1.2",
+    version=APP_VERSION,
     lifespan=lifespan,
 )
 
@@ -978,6 +978,7 @@ app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.router, prefix=app_settings.api_prefix)
+app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 4 - 0
backend/app/schemas/settings.py

@@ -17,6 +17,9 @@ class AppSettings(BaseModel):
     spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
     spoolman_sync_mode: str = Field(default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press")
 
+    # Updates
+    check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -31,3 +34,4 @@ class AppSettingsUpdate(BaseModel):
     spoolman_enabled: bool | None = None
     spoolman_url: str | None = None
     spoolman_sync_mode: str | None = None
+    check_updates: bool | None = None

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

@@ -153,6 +153,7 @@ export interface AppSettings {
   currency: string;
   energy_cost_per_kwh: number;
   energy_tracking_mode: 'print' | 'total';
+  check_updates: boolean;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -492,6 +493,31 @@ export interface SpoolmanSyncResult {
   errors: string[];
 }
 
+// Update types
+export interface VersionInfo {
+  version: string;
+  repo: string;
+}
+
+export interface UpdateCheckResult {
+  update_available: boolean;
+  current_version: string;
+  latest_version: string | null;
+  release_name?: string;
+  release_notes?: string;
+  release_url?: string;
+  published_at?: string;
+  error?: string;
+  message?: string;
+}
+
+export interface UpdateStatus {
+  status: 'idle' | 'checking' | 'downloading' | 'installing' | 'complete' | 'error';
+  progress: number;
+  message: string;
+  error: string | null;
+}
+
 // API functions
 export const api = {
   // Printers
@@ -906,4 +932,13 @@ export const api = {
     request<{ spools: unknown[] }>('/spoolman/spools'),
   getSpoolmanFilaments: () =>
     request<{ filaments: unknown[] }>('/spoolman/filaments'),
+
+  // Updates
+  getVersion: () => request<VersionInfo>('/updates/version'),
+  checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
+  applyUpdate: () =>
+    request<{ success: boolean; message: string; status: UpdateStatus }>('/updates/apply', {
+      method: 'POST',
+    }),
+  getUpdateStatus: () => request<UpdateStatus>('/updates/status'),
 };

+ 46 - 2
frontend/src/components/Layout.tsx

@@ -1,8 +1,10 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, type LucideIcon } from 'lucide-react';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
+import { useQuery } from '@tanstack/react-query';
+import { api } from '../api/client';
 
 interface NavItem {
   id: string;
@@ -76,6 +78,27 @@ export function Layout() {
   const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
   const hasRedirected = useRef(false);
 
+  // Check for updates
+  const { data: versionInfo } = useQuery({
+    queryKey: ['version'],
+    queryFn: api.getVersion,
+    staleTime: Infinity,
+  });
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+  });
+
+  const { data: updateCheck } = useQuery({
+    queryKey: ['updateCheck'],
+    queryFn: api.checkForUpdates,
+    enabled: settings?.check_updates !== false,
+    staleTime: 60 * 60 * 1000, // 1 hour
+    refetchInterval: 60 * 60 * 1000, // Check every hour
+  });
+
   // Redirect to default view on initial load
   useEffect(() => {
     if (!hasRedirected.current && location.pathname === '/') {
@@ -239,7 +262,19 @@ export function Layout() {
         <div className="p-2 border-t border-bambu-dark-tertiary">
           {sidebarExpanded ? (
             <div className="flex items-center justify-between px-2">
-              <span className="text-sm text-bambu-gray">v0.1.4</span>
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
+                {updateCheck?.update_available && (
+                  <button
+                    onClick={() => navigate('/settings')}
+                    className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
+                    title={`Update available: v${updateCheck.latest_version}`}
+                  >
+                    <ArrowUpCircle className="w-4 h-4" />
+                    <span>Update</span>
+                  </button>
+                )}
+              </div>
               <div className="flex items-center gap-1">
                 <a
                   href="https://github.com/maziggy/bambusy"
@@ -268,6 +303,15 @@ export function Layout() {
             </div>
           ) : (
             <div className="flex flex-col items-center gap-1">
+              {updateCheck?.update_available && (
+                <button
+                  onClick={() => navigate('/settings')}
+                  className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
+                  title={`Update available: v${updateCheck.latest_version}`}
+                >
+                  <ArrowUpCircle className="w-5 h-5" />
+                </button>
+              )}
               <a
                 href="https://github.com/maziggy/bambusy"
                 target="_blank"

+ 157 - 17
frontend/src/pages/SettingsPage.tsx

@@ -1,7 +1,7 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink } from 'lucide-react';
 import { api } from '../api/client';
-import type { AppSettings, SmartPlug, NotificationProvider } from '../api/client';
+import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -53,6 +53,37 @@ export function SettingsPage() {
     queryFn: api.checkFfmpeg,
   });
 
+  const { data: versionInfo } = useQuery({
+    queryKey: ['version'],
+    queryFn: api.getVersion,
+  });
+
+  const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
+    queryKey: ['updateCheck'],
+    queryFn: api.checkForUpdates,
+    staleTime: 5 * 60 * 1000,
+  });
+
+  const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({
+    queryKey: ['updateStatus'],
+    queryFn: api.getUpdateStatus,
+    refetchInterval: (query) => {
+      const status = query.state.data as UpdateStatus | undefined;
+      // Poll while update is in progress
+      if (status?.status === 'downloading' || status?.status === 'installing') {
+        return 1000;
+      }
+      return false;
+    },
+  });
+
+  const applyUpdateMutation = useMutation({
+    mutationFn: api.applyUpdate,
+    onSuccess: () => {
+      refetchUpdateStatus();
+    },
+  });
+
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
@@ -70,7 +101,8 @@ export function SettingsPage() {
         settings.default_filament_cost !== localSettings.default_filament_cost ||
         settings.currency !== localSettings.currency ||
         settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
-        settings.energy_tracking_mode !== localSettings.energy_tracking_mode;
+        settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
+        settings.check_updates !== localSettings.check_updates;
       setHasChanges(changed);
     }
   }, [settings, localSettings]);
@@ -326,29 +358,137 @@ export function SettingsPage() {
               </div>
             </CardContent>
           </Card>
+        </div>
 
+        {/* Second Column - Spoolman & Updates */}
+        <div className="space-y-6 flex-1 max-w-md">
           <SpoolmanSettings />
 
           <Card>
             <CardHeader>
-              <h2 className="text-lg font-semibold text-white">About</h2>
+              <h2 className="text-lg font-semibold text-white">Updates</h2>
             </CardHeader>
-            <CardContent>
-              <div className="space-y-2 text-sm">
-                <p className="text-white">Bambusy v0.1.2</p>
-                <p className="text-bambu-gray">
-                  Archive and manage your Bambu Lab 3MF files
-                </p>
-                <p className="text-bambu-gray">
-                  Connect to printers via LAN mode (developer mode required)
-                </p>
+            <CardContent className="space-y-4">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">Check for updates</p>
+                  <p className="text-sm text-bambu-gray">
+                    Automatically check for new versions on startup
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.check_updates}
+                    onChange={(e) => updateSetting('check_updates', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              <div className="border-t border-bambu-dark-tertiary pt-4">
+                <div className="flex items-center justify-between mb-2">
+                  <div>
+                    <p className="text-white">Current version</p>
+                    <p className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</p>
+                  </div>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={() => refetchUpdateCheck()}
+                    disabled={isCheckingUpdate}
+                  >
+                    {isCheckingUpdate ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <RefreshCw className="w-4 h-4" />
+                    )}
+                    Check now
+                  </Button>
+                </div>
+
+                {updateCheck?.update_available ? (
+                  <div className="mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
+                    <div className="flex items-start justify-between">
+                      <div>
+                        <p className="text-bambu-green font-medium">
+                          Update available: v{updateCheck.latest_version}
+                        </p>
+                        {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
+                          <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
+                        )}
+                        {updateCheck.release_notes && (
+                          <p className="text-sm text-bambu-gray mt-2 whitespace-pre-line line-clamp-3">
+                            {updateCheck.release_notes}
+                          </p>
+                        )}
+                      </div>
+                      {updateCheck.release_url && (
+                        <a
+                          href={updateCheck.release_url}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="text-bambu-gray hover:text-white transition-colors"
+                          title="View release on GitHub"
+                        >
+                          <ExternalLink className="w-4 h-4" />
+                        </a>
+                      )}
+                    </div>
+
+                    {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
+                      <div className="mt-3">
+                        <div className="flex items-center gap-2 text-sm text-bambu-gray">
+                          <Loader2 className="w-4 h-4 animate-spin" />
+                          <span>{updateStatus.message}</span>
+                        </div>
+                        <div className="mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2">
+                          <div
+                            className="bg-bambu-green h-2 rounded-full transition-all duration-300"
+                            style={{ width: `${updateStatus.progress}%` }}
+                          />
+                        </div>
+                      </div>
+                    ) : updateStatus?.status === 'complete' ? (
+                      <div className="mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green">
+                        {updateStatus.message}
+                      </div>
+                    ) : updateStatus?.status === 'error' ? (
+                      <div className="mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400">
+                        {updateStatus.error || updateStatus.message}
+                      </div>
+                    ) : (
+                      <Button
+                        className="mt-3"
+                        onClick={() => applyUpdateMutation.mutate()}
+                        disabled={applyUpdateMutation.isPending}
+                      >
+                        {applyUpdateMutation.isPending ? (
+                          <Loader2 className="w-4 h-4 animate-spin" />
+                        ) : (
+                          <Download className="w-4 h-4" />
+                        )}
+                        Install Update
+                      </Button>
+                    )}
+                  </div>
+                ) : updateCheck?.error ? (
+                  <div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-400">
+                    Failed to check for updates: {updateCheck.error}
+                  </div>
+                ) : updateCheck && !updateCheck.update_available ? (
+                  <p className="mt-2 text-sm text-bambu-gray">
+                    You're running the latest version
+                  </p>
+                ) : null}
               </div>
             </CardContent>
           </Card>
         </div>
 
-        {/* Middle Column - Smart Plugs */}
-        <div className="w-96 flex-shrink-0">
+        {/* Third Column - Smart Plugs */}
+        <div className="w-80 flex-shrink-0">
           <Card>
             <CardHeader>
               <div className="flex items-center justify-between">
@@ -400,8 +540,8 @@ export function SettingsPage() {
           </Card>
         </div>
 
-        {/* Right Column - Notifications */}
-        <div className="w-96 flex-shrink-0">
+        {/* Fourth Column - Notifications */}
+        <div className="w-80 flex-shrink-0">
           <Card>
             <CardHeader>
               <div className="flex items-center justify-between">

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CLNqfrMK.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CfyzT_2B.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DFEXODPu.js


+ 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-zNwYBAHC.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CfyzT_2B.css">
+    <script type="module" crossorigin src="/assets/index-DFEXODPu.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CLNqfrMK.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff