Bläddra i källkod

Merge pull request #581 from aneopsy/refactor-utils

Refactor frontend utility functions to reduce duplication
MartinNYHC 2 månader sedan
förälder
incheckning
3524af4144

+ 1 - 11
frontend/src/components/FilamentHoverCard.tsx

@@ -1,6 +1,7 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
 import { useState, useRef, useEffect, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';
 import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';
+import { isLightColor } from '../utils/colors';
 
 
 interface FilamentData {
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
   vendor: 'Bambu Lab' | 'Generic';
@@ -136,17 +137,6 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
     return '#22c55e'; // green
     return '#22c55e'; // green
   };
   };
 
 
-  // Determine if color is light (for text contrast on swatch)
-  const isLightColor = (hex: string | null): boolean => {
-    if (!hex) return false;
-    const cleanHex = hex.replace('#', '');
-    const r = parseInt(cleanHex.slice(0, 2), 16);
-    const g = parseInt(cleanHex.slice(2, 4), 16);
-    const b = parseInt(cleanHex.slice(4, 6), 16);
-    const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
-    return luminance > 0.6;
-  };
-
   const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
   const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
   const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
   const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
 
 

+ 0 - 1
frontend/src/components/FileManagerModal.tsx

@@ -247,7 +247,6 @@ function formatStorageSize(bytes: number): string {
   return `${mb.toFixed(0)} MB`;
   return `${mb.toFixed(0)} MB`;
 }
 }
 
 
-
 function getFileIcon(filename: string, isDirectory: boolean) {
 function getFileIcon(filename: string, isDirectory: boolean) {
   if (isDirectory) return Folder;
   if (isDirectory) return Folder;
 
 

+ 6 - 6
frontend/src/components/GitHubBackupSettings.tsx

@@ -37,6 +37,12 @@ import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { formatRelativeTime } from '../utils/date';
 import { formatRelativeTime } from '../utils/date';
 
 
+function formatDateTime(dateStr: string | null): string {
+  if (!dateStr) return '-';
+  const date = new Date(dateStr);
+  return date.toLocaleString();
+}
+
 interface StatusBadgeProps {
 interface StatusBadgeProps {
   status: string | null;
   status: string | null;
 }
 }
@@ -66,12 +72,6 @@ function StatusBadge({ status }: StatusBadgeProps) {
   );
   );
 }
 }
 
 
-function formatDateTime(dateStr: string | null): string {
-  if (!dateStr) return '-';
-  const date = new Date(dateStr);
-  return date.toLocaleString();
-}
-
 export function GitHubBackupSettings() {
 export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();

+ 4 - 5
frontend/src/components/SpoolUsageHistory.tsx

@@ -5,17 +5,16 @@ import { api } from '../api/client';
 import type { SpoolUsageRecord } from '../api/client';
 import type { SpoolUsageRecord } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
-
-interface SpoolUsageHistoryProps {
-  spoolId: number;
-}
-
 function formatDate(dateStr: string): string {
 function formatDate(dateStr: string): string {
   const date = new Date(dateStr);
   const date = new Date(dateStr);
   return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' }) +
   return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' }) +
     ' ' + date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
     ' ' + date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
 }
 }
 
 
+interface SpoolUsageHistoryProps {
+  spoolId: number;
+}
+
 const STATUS_COLORS: Record<string, string> = {
 const STATUS_COLORS: Record<string, string> = {
   completed: 'text-bambu-green',
   completed: 'text-bambu-green',
   failed: 'text-red-400',
   failed: 'text-red-400',

+ 5 - 10
frontend/src/components/TimelapseEditorModal.tsx

@@ -18,6 +18,7 @@ import {
 import { Button } from './Button';
 import { Button } from './Button';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { formatMediaTime } from '../utils/date';
 
 
 interface TimelapseEditorModalProps {
 interface TimelapseEditorModalProps {
   archiveId: number;
   archiveId: number;
@@ -28,12 +29,6 @@ interface TimelapseEditorModalProps {
 
 
 const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
 const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
 
 
-function formatTime(seconds: number): string {
-  const mins = Math.floor(seconds / 60);
-  const secs = Math.floor(seconds % 60);
-  return `${mins}:${secs.toString().padStart(2, '0')}`;
-}
-
 export function TimelapseEditorModal({
 export function TimelapseEditorModal({
   archiveId,
   archiveId,
   timelapseSrc,
   timelapseSrc,
@@ -321,7 +316,7 @@ export function TimelapseEditorModal({
               <Scissors className="w-4 h-4" />
               <Scissors className="w-4 h-4" />
               <span>Trim</span>
               <span>Trim</span>
               <span className="ml-auto">
               <span className="ml-auto">
-                {formatTime(trimStart)} - {formatTime(trimEnd)} ({formatTime(trimmedDuration)})
+                {formatMediaTime(trimStart)} - {formatMediaTime(trimEnd)} ({formatMediaTime(trimmedDuration)})
               </span>
               </span>
             </div>
             </div>
 
 
@@ -435,7 +430,7 @@ export function TimelapseEditorModal({
             <div className="flex items-center gap-2 text-sm text-bambu-gray">
             <div className="flex items-center gap-2 text-sm text-bambu-gray">
               <Gauge className="w-4 h-4" />
               <Gauge className="w-4 h-4" />
               <span>Speed</span>
               <span>Speed</span>
-              <span className="ml-auto">{speed}x (output: {formatTime(outputDuration)})</span>
+              <span className="ml-auto">{speed}x (output: {formatMediaTime(outputDuration)})</span>
             </div>
             </div>
             <div className="flex gap-1">
             <div className="flex gap-1">
               {SPEED_OPTIONS.map((s) => (
               {SPEED_OPTIONS.map((s) => (
@@ -523,10 +518,10 @@ export function TimelapseEditorModal({
           {/* Summary */}
           {/* Summary */}
           <div className="p-3 bg-bambu-dark rounded-lg text-sm space-y-1">
           <div className="p-3 bg-bambu-dark rounded-lg text-sm space-y-1">
             <p className="text-bambu-gray">
             <p className="text-bambu-gray">
-              <span className="text-white">Original:</span> {formatTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}
+              <span className="text-white">Original:</span> {formatMediaTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}
             </p>
             </p>
             <p className="text-bambu-gray">
             <p className="text-bambu-gray">
-              <span className="text-white">Output:</span> {formatTime(outputDuration)} @ {speed}x speed
+              <span className="text-white">Output:</span> {formatMediaTime(outputDuration)} @ {speed}x speed
               {audioFile && ` + music overlay`}
               {audioFile && ` + music overlay`}
             </p>
             </p>
           </div>
           </div>

+ 3 - 8
frontend/src/components/TimelapseViewer.tsx

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react';
 import { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';
 import { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';
 import { Button } from './Button';
 import { Button } from './Button';
 import { TimelapseEditorModal } from './TimelapseEditorModal';
 import { TimelapseEditorModal } from './TimelapseEditorModal';
+import { formatMediaTime } from '../utils/date';
 
 
 interface TimelapseViewerProps {
 interface TimelapseViewerProps {
   src: string;
   src: string;
@@ -97,12 +98,6 @@ export function TimelapseViewer({
     video.currentTime = Math.min(duration, video.currentTime + 5);
     video.currentTime = Math.min(duration, video.currentTime + 5);
   };
   };
 
 
-  const formatTime = (time: number) => {
-    const minutes = Math.floor(time / 60);
-    const seconds = Math.floor(time % 60);
-    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
-  };
-
   const handleDownload = () => {
   const handleDownload = () => {
     const link = document.createElement('a');
     const link = document.createElement('a');
     link.href = src;
     link.href = src;
@@ -154,7 +149,7 @@ export function TimelapseViewer({
             {/* Progress bar */}
             {/* Progress bar */}
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">
               <span className="text-xs text-bambu-gray w-12 text-right">
               <span className="text-xs text-bambu-gray w-12 text-right">
-                {formatTime(currentTime)}
+                {formatMediaTime(currentTime)}
               </span>
               </span>
               <input
               <input
                 type="range"
                 type="range"
@@ -168,7 +163,7 @@ export function TimelapseViewer({
                   [&::-webkit-slider-thumb]:cursor-pointer"
                   [&::-webkit-slider-thumb]:cursor-pointer"
               />
               />
               <span className="text-xs text-bambu-gray w-12">
               <span className="text-xs text-bambu-gray w-12">
-                {formatTime(duration)}
+                {formatMediaTime(duration)}
               </span>
               </span>
             </div>
             </div>
 
 

+ 2 - 9
frontend/src/contexts/ToastContext.tsx

@@ -2,6 +2,7 @@ import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCi
 import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
 import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { formatFileSize } from '../utils/file';
 
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
 
 
@@ -76,14 +77,6 @@ export function ToastProvider({ children }: { children: ReactNode }) {
   const dispatchToastId = 'background-dispatch';
   const dispatchToastId = 'background-dispatch';
   const lastDispatchSummaryRef = useRef<string | null>(null);
   const lastDispatchSummaryRef = useRef<string | null>(null);
 
 
-  const formatBytes = useCallback((bytes: number) => {
-    if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
-    if (bytes < 1024) return `${bytes} B`;
-    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-    if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
-  }, []);
-
   // Clean up all timeouts on unmount
   // Clean up all timeouts on unmount
   useEffect(() => {
   useEffect(() => {
     const timeouts = timeoutRefs.current;
     const timeouts = timeoutRefs.current;
@@ -502,7 +495,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
                           )}
                           )}
                           {job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
                           {job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
                             <div className="text-[11px] text-bambu-gray truncate">
                             <div className="text-[11px] text-bambu-gray truncate">
-                              {formatBytes(job.uploadBytes)} / {formatBytes(job.uploadTotalBytes)}
+                              {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
                               {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
                               {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
                             </div>
                             </div>
                           )}
                           )}

+ 5 - 86
frontend/src/pages/PrintersPage.tsx

@@ -72,6 +72,7 @@ import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { getGlobalTrayId } from '../utils/amsHelpers';
 import { getGlobalTrayId } from '../utils/amsHelpers';
 import { getPrinterImage, getWifiStrength } from '../utils/printer';
 import { getPrinterImage, getWifiStrength } from '../utils/printer';
+import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -335,69 +336,6 @@ function getBambuColorName(trayIdName: string | null | undefined): string | null
   return BAMBU_COLOR_CODE_FALLBACK[colorCode] || null;
   return BAMBU_COLOR_CODE_FALLBACK[colorCode] || null;
 }
 }
 
 
-// Convert hex color to basic color name
-function hexToBasicColorName(hex: string | null | undefined): string {
-  if (!hex || hex.length < 6) return 'Unknown';
-
-  // Parse RGB from hex (format: RRGGBBAA or RRGGBB)
-  const r = parseInt(hex.substring(0, 2), 16);
-  const g = parseInt(hex.substring(2, 4), 16);
-  const b = parseInt(hex.substring(4, 6), 16);
-
-  // Calculate HSL for better color classification
-  const max = Math.max(r, g, b) / 255;
-  const min = Math.min(r, g, b) / 255;
-  const l = (max + min) / 2;
-
-  let h = 0;
-  let s = 0;
-
-  if (max !== min) {
-    const d = max - min;
-    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
-
-    const rNorm = r / 255;
-    const gNorm = g / 255;
-    const bNorm = b / 255;
-
-    if (max === rNorm) {
-      h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
-    } else if (max === gNorm) {
-      h = ((bNorm - rNorm) / d + 2) / 6;
-    } else {
-      h = ((rNorm - gNorm) / d + 4) / 6;
-    }
-  }
-
-  // Convert to degrees
-  h = h * 360;
-
-  // Classify by lightness first
-  if (l < 0.15) return 'Black';
-  if (l > 0.85) return 'White';
-
-  // Low saturation = gray
-  if (s < 0.15) {
-    if (l < 0.4) return 'Dark Gray';
-    if (l > 0.6) return 'Light Gray';
-    return 'Gray';
-  }
-
-  // Classify by hue
-  // Brown is orange/yellow hue with lower lightness
-  if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
-  if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
-
-  if (h < 15 || h >= 345) return 'Red';
-  if (h < 45) return 'Orange';
-  if (h < 70) return 'Yellow';
-  if (h < 150) return 'Green';
-  if (h < 200) return 'Cyan';
-  if (h < 260) return 'Blue';
-  if (h < 290) return 'Purple';
-  return 'Pink';
-}
-
 // Format K value with 3 decimal places, default to 0.020 if null
 // Format K value with 3 decimal places, default to 0.020 if null
 function formatKValue(k: number | null | undefined): string {
 function formatKValue(k: number | null | undefined): string {
   const value = k ?? 0.020;
   const value = k ?? 0.020;
@@ -419,25 +357,6 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
   );
   );
 }
 }
 
 
-// Parse RGBA hex to CSS color (skip if empty or all zeros)
-function parseFilamentColor(rgba: string): string | null {
-  if (!rgba || rgba === '00000000' || rgba.length < 6) return null;
-  const r = rgba.slice(0, 2);
-  const g = rgba.slice(2, 4);
-  const b = rgba.slice(4, 6);
-  const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1;
-  if (a === 0) return null;
-  return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
-}
-
-function isLightFilamentColor(rgba: string): boolean {
-  if (!rgba || rgba.length < 6) return false;
-  const r = parseInt(rgba.slice(0, 2), 16);
-  const g = parseInt(rgba.slice(2, 4), 16);
-  const b = parseInt(rgba.slice(4, 6), 16);
-  return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
-}
-
 // Expand nozzle type codes to material names
 // Expand nozzle type codes to material names
 // Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01")
 // Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01")
 // Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide
 // Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide
@@ -831,7 +750,7 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
         {rackSlots.map((slot, i) => {
         {rackSlots.map((slot, i) => {
           const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
           const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
-          const lightBg = filamentBg ? isLightFilamentColor(slot.filament_color) : false;
+          const lightBg = filamentBg ? isLightColor(slot.filament_color) : false;
 
 
           return (
           return (
             <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
             <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
@@ -2947,7 +2866,7 @@ function PrinterCard({
                                 const filamentData = tray?.tray_type ? {
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
                                   profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
-                                  colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
+                                  colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
                                   colorHex: tray.tray_color || null,
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   kFactor: formatKValue(tray.k),
                                   fillLevel: effectiveFill,
                                   fillLevel: effectiveFill,
@@ -3169,7 +3088,7 @@ function PrinterCard({
                         const filamentData = tray?.tray_type ? {
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
                           profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
-                          colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
+                          colorName: getBambuColorName(tray.tray_id_name) || hexToColorName(tray.tray_color),
                           colorHex: tray.tray_color || null,
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           kFactor: formatKValue(tray.k),
                           fillLevel: htEffectiveFill,
                           fillLevel: htEffectiveFill,
@@ -3430,7 +3349,7 @@ function PrinterCard({
                               const extFilamentData = {
                               const extFilamentData = {
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                 profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
                                 profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
-                                colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
+                                colorName: getBambuColorName(extTray.tray_id_name) || hexToColorName(extTray.tray_color),
                                 colorHex: extTray.tray_color || null,
                                 colorHex: extTray.tray_color || null,
                                 kFactor: formatKValue(extTray.k),
                                 kFactor: formatKValue(extTray.k),
                                 fillLevel: extEffectiveFill,
                                 fillLevel: extEffectiveFill,

+ 3 - 17
frontend/src/pages/ProjectDetailPage.tsx

@@ -32,7 +32,7 @@ import {
   Pencil,
   Pencil,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
+import { parseUTCDate, formatDateOnly, formatDateTime, formatDurationFromHours, type TimeFormat } from '../utils/date';
 import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
 import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -45,15 +45,6 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { ProjectModal } from './ProjectsPage';
 import { ProjectModal } from './ProjectsPage';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
 
 
-function formatDuration(hours: number): string {
-  if (hours < 1) {
-    return `${Math.round(hours * 60)}m`;
-  }
-  const h = Math.floor(hours);
-  const m = Math.round((hours - h) * 60);
-  return m > 0 ? `${h}h ${m}m` : `${h}h`;
-}
-
 function formatFilament(grams: number): string {
 function formatFilament(grams: number): string {
   if (grams >= 1000) {
   if (grams >= 1000) {
     return `${(grams / 1000).toFixed(2)}kg`;
     return `${(grams / 1000).toFixed(2)}kg`;
@@ -186,11 +177,6 @@ function PriorityBadge({ priority, t }: { priority: string; t: TFunction }) {
   );
   );
 }
 }
 
 
-function formatDate(dateString: string | null): string {
-  if (!dateString) return '';
-  return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
-}
-
 function getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null {
 function getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null {
   if (!dateString) return null;
   if (!dateString) return null;
   const dueDate = parseUTCDate(dateString);
   const dueDate = parseUTCDate(dateString);
@@ -610,7 +596,7 @@ export function ProjectDetailPage() {
           <StatCard
           <StatCard
             icon={Clock}
             icon={Clock}
             label={t('projectDetail.stats.printTime')}
             label={t('projectDetail.stats.printTime')}
-            value={formatDuration(stats.total_print_time_hours)}
+            value={formatDurationFromHours(stats.total_print_time_hours)}
             color="text-yellow-400"
             color="text-yellow-400"
           />
           />
           <StatCard
           <StatCard
@@ -738,7 +724,7 @@ export function ProjectDetailPage() {
           {project.due_date && (
           {project.due_date && (
             <div className="flex items-center gap-2">
             <div className="flex items-center gap-2">
               <Calendar className="w-4 h-4 text-bambu-gray" />
               <Calendar className="w-4 h-4 text-bambu-gray" />
-              <span className="text-sm text-white">{formatDate(project.due_date)}</span>
+              <span className="text-sm text-white">{formatDateOnly(project.due_date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>
               {getDueDateStatus(project.due_date, t) && (
               {getDueDateStatus(project.due_date, t) && (
                 <span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
                 <span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
                   ({getDueDateStatus(project.due_date, t)!.label})
                   ({getDueDateStatus(project.due_date, t)!.label})

+ 0 - 1
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -4,7 +4,6 @@ import { useOutletContext } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { spoolbuddyApi, type SpoolBuddyDevice, type DaemonUpdateCheck } from '../../api/client';
 import { spoolbuddyApi, type SpoolBuddyDevice, type DaemonUpdateCheck } from '../../api/client';
-
 function formatUptime(seconds: number): string {
 function formatUptime(seconds: number): string {
   if (seconds < 60) return `${seconds}s`;
   if (seconds < 60) return `${seconds}s`;
   if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
   if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;

+ 30 - 0
frontend/src/utils/colors.ts

@@ -83,6 +83,9 @@ export function hexToColorName(hex: string | null | undefined): string {
     if (l > 0.6) return 'Light Gray';
     if (l > 0.6) return 'Light Gray';
     return 'Gray';
     return 'Gray';
   }
   }
+  // Brown is orange/yellow hue with lower lightness
+  if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
+  if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
   if (h < 15 || h >= 345) return 'Red';
   if (h < 15 || h >= 345) return 'Red';
   if (h < 45) return 'Orange';
   if (h < 45) return 'Orange';
   if (h < 70) return 'Yellow';
   if (h < 70) return 'Yellow';
@@ -125,3 +128,30 @@ export function resolveSpoolColorName(colorName: string | null, rgba: string | n
   // Return null (displayed as "-") — better than showing a code
   // Return null (displayed as "-") — better than showing a code
   return null;
   return null;
 }
 }
+
+/**
+ * Parse an RGBA hex string (e.g., "FF0000FF") to a CSS rgba() color.
+ * Returns null for empty, all-zero, or fully transparent colors.
+ */
+export function parseFilamentColor(rgba: string): string | null {
+  if (!rgba || rgba === '00000000' || rgba.length < 6) return null;
+  const r = rgba.slice(0, 2);
+  const g = rgba.slice(2, 4);
+  const b = rgba.slice(4, 6);
+  const a = rgba.length >= 8 ? parseInt(rgba.slice(6, 8), 16) / 255 : 1;
+  if (a === 0) return null;
+  return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
+}
+
+/**
+ * Check if a hex color is light (for choosing text contrast).
+ * Uses luminance formula: 0.299*R + 0.587*G + 0.114*B.
+ */
+export function isLightColor(hex: string | null): boolean {
+  if (!hex || hex.length < 6) return false;
+  const cleanHex = hex.replace('#', '');
+  const r = parseInt(cleanHex.slice(0, 2), 16);
+  const g = parseInt(cleanHex.slice(2, 4), 16);
+  const b = parseInt(cleanHex.slice(4, 6), 16);
+  return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
+}

+ 4 - 28
frontend/src/utils/currency.ts

@@ -31,31 +31,7 @@ export function getCurrencySymbol(currencyCode: string): string {
   return CURRENCY_SYMBOLS[currencyCode.toUpperCase()] || currencyCode;
   return CURRENCY_SYMBOLS[currencyCode.toUpperCase()] || currencyCode;
 }
 }
 
 
-export const SUPPORTED_CURRENCIES = [
-  { code: 'USD', label: 'USD ($)' },
-  { code: 'EUR', label: 'EUR (€)' },
-  { code: 'GBP', label: 'GBP (£)' },
-  { code: 'CHF', label: 'CHF (Fr.)' },
-  { code: 'JPY', label: 'JPY (¥)' },
-  { code: 'CNY', label: 'CNY (¥)' },
-  { code: 'CAD', label: 'CAD ($)' },
-  { code: 'AUD', label: 'AUD ($)' },
-  { code: 'INR', label: 'INR (₹)' },
-  { code: 'HKD', label: 'HKD (HK$)' },
-  { code: 'KRW', label: 'KRW (₩)' },
-  { code: 'SEK', label: 'SEK (kr)' },
-  { code: 'NOK', label: 'NOK (kr)' },
-  { code: 'DKK', label: 'DKK (kr)' },
-  { code: 'PLN', label: 'PLN (zł)' },
-  { code: 'BRL', label: 'BRL (R$)' },
-  { code: 'TWD', label: 'TWD (NT$)' },
-  { code: 'SGD', label: 'SGD (S$)' },
-  { code: 'NZD', label: 'NZD (NZ$)' },
-  { code: 'MXN', label: 'MXN (MX$)' },
-  { code: 'CZK', label: 'CZK (Kč)' },
-  { code: 'THB', label: 'THB (฿)' },
-  { code: 'ZAR', label: 'ZAR (R)' },
-  { code: 'TRY', label: 'TRY (₺)' },
-  { code: 'RUB', label: 'RUB (₽)' },
-  { code: 'HUF', label: 'HUF (Ft)' },
-] as const;
+export const SUPPORTED_CURRENCIES = Object.entries(CURRENCY_SYMBOLS).map(([code, symbol]) => ({
+  code,
+  label: `${code} (${symbol})`,
+}));

+ 78 - 120
frontend/src/utils/date.ts

@@ -13,22 +13,12 @@ export type DateFormat = 'system' | 'us' | 'eu' | 'iso';
  * Get the date input placeholder based on format setting.
  * Get the date input placeholder based on format setting.
  */
  */
 export function getDatePlaceholder(dateFormat: DateFormat = 'system'): string {
 export function getDatePlaceholder(dateFormat: DateFormat = 'system'): string {
-  switch (dateFormat) {
-    case 'us':
-      return 'MM/DD/YYYY';
-    case 'eu':
-      return 'DD/MM/YYYY';
-    case 'iso':
-      return 'YYYY-MM-DD';
-    case 'system':
-    default: {
-      // Try to detect system format
-      const testDate = new Date(2000, 11, 31); // Dec 31, 2000
-      const formatted = testDate.toLocaleDateString();
-      if (formatted.startsWith('12')) return 'MM/DD/YYYY';
-      if (formatted.startsWith('31')) return 'DD/MM/YYYY';
-      return 'YYYY-MM-DD';
-    }
+  const resolved = dateFormat === 'system' ? detectSystemDateFormat() : dateFormat;
+  switch (resolved) {
+    case 'us': return 'MM/DD/YYYY';
+    case 'eu': return 'DD/MM/YYYY';
+    case 'iso': return 'YYYY-MM-DD';
+    default: return resolved satisfies never;
   }
   }
 }
 }
 
 
@@ -98,7 +88,6 @@ export function formatTimeInput(date: Date, timeFormat: TimeFormat = 'system'):
  * Split a date string by common separators (/, ., -).
  * Split a date string by common separators (/, ., -).
  */
  */
 function splitDateParts(value: string): string[] | null {
 function splitDateParts(value: string): string[] | null {
-  // Try common separators: /, ., -
   for (const sep of ['/', '.', '-']) {
   for (const sep of ['/', '.', '-']) {
     const parts = value.split(sep);
     const parts = value.split(sep);
     if (parts.length === 3) return parts;
     if (parts.length === 3) return parts;
@@ -106,6 +95,13 @@ function splitDateParts(value: string): string[] | null {
   return null;
   return null;
 }
 }
 
 
+function detectSystemDateFormat(): 'us' | 'eu' | 'iso' {
+  const formatted = new Date(2000, 11, 31).toLocaleDateString();
+  if (formatted.startsWith('12')) return 'us';
+  if (formatted.startsWith('31')) return 'eu';
+  return 'iso';
+}
+
 /**
 /**
  * Parse a date string based on format setting.
  * Parse a date string based on format setting.
  * Returns null if parsing fails.
  * Returns null if parsing fails.
@@ -114,77 +110,36 @@ function splitDateParts(value: string): string[] | null {
 export function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null {
 export function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null {
   if (!value) return null;
   if (!value) return null;
 
 
+  const parts = splitDateParts(value);
+  if (!parts) return null;
+
+  const resolved = dateFormat === 'system' ? detectSystemDateFormat() : dateFormat;
   let day: number, month: number, year: number;
   let day: number, month: number, year: number;
 
 
-  try {
-    switch (dateFormat) {
-      case 'us': {
-        // MM/DD/YYYY (also accepts . and - separators)
-        const parts = splitDateParts(value);
-        if (!parts) return null;
-        month = parseInt(parts[0], 10);
-        day = parseInt(parts[1], 10);
-        year = parseInt(parts[2], 10);
-        break;
-      }
-      case 'eu': {
-        // DD/MM/YYYY (also accepts . and - separators)
-        const parts = splitDateParts(value);
-        if (!parts) return null;
-        day = parseInt(parts[0], 10);
-        month = parseInt(parts[1], 10);
-        year = parseInt(parts[2], 10);
-        break;
-      }
-      case 'iso': {
-        // YYYY-MM-DD (also accepts . and / separators)
-        const parts = splitDateParts(value);
-        if (!parts) return null;
-        year = parseInt(parts[0], 10);
-        month = parseInt(parts[1], 10);
-        day = parseInt(parts[2], 10);
-        break;
-      }
-      case 'system':
-      default: {
-        // Detect system format and parse accordingly
-        const testDate = new Date(2000, 11, 31); // Dec 31, 2000
-        const formatted = testDate.toLocaleDateString();
-        const parts = splitDateParts(value);
-
-        if (parts) {
-          // Detect format from system locale
-          if (formatted.startsWith('12')) {
-            // US format: MM/DD/YYYY
-            month = parseInt(parts[0], 10);
-            day = parseInt(parts[1], 10);
-            year = parseInt(parts[2], 10);
-          } else if (formatted.startsWith('31')) {
-            // EU format: DD/MM/YYYY
-            day = parseInt(parts[0], 10);
-            month = parseInt(parts[1], 10);
-            year = parseInt(parts[2], 10);
-          } else {
-            // ISO format: YYYY-MM-DD
-            year = parseInt(parts[0], 10);
-            month = parseInt(parts[1], 10);
-            day = parseInt(parts[2], 10);
-          }
-          break;
-        }
-        return null;
-      }
-    }
+  switch (resolved) {
+    case 'us':
+      month = parseInt(parts[0], 10);
+      day = parseInt(parts[1], 10);
+      year = parseInt(parts[2], 10);
+      break;
+    case 'eu':
+      day = parseInt(parts[0], 10);
+      month = parseInt(parts[1], 10);
+      year = parseInt(parts[2], 10);
+      break;
+    case 'iso':
+      year = parseInt(parts[0], 10);
+      month = parseInt(parts[1], 10);
+      day = parseInt(parts[2], 10);
+      break;
+  }
 
 
-    if (isNaN(day) || isNaN(month) || isNaN(year)) return null;
-    if (month < 1 || month > 12) return null;
-    if (day < 1 || day > 31) return null;
-    if (year < 1900 || year > 2100) return null;
+  if (isNaN(day) || isNaN(month) || isNaN(year)) return null;
+  if (month < 1 || month > 12) return null;
+  if (day < 1 || day > 31) return null;
+  if (year < 1900 || year > 2100) return null;
 
 
-    return new Date(year, month - 1, day);
-  } catch {
-    return null;
-  }
+  return new Date(year, month - 1, day);
 }
 }
 
 
 /**
 /**
@@ -194,41 +149,22 @@ export function parseDateInput(value: string, dateFormat: DateFormat = 'system')
 export function parseTimeInput(value: string): { hours: number; minutes: number } | null {
 export function parseTimeInput(value: string): { hours: number; minutes: number } | null {
   if (!value) return null;
   if (!value) return null;
 
 
-  try {
-    const trimmed = value.trim().toUpperCase();
+  const trimmed = value.trim();
 
 
-    // Check for 12h format with AM/PM
-    const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i);
-    if (ampmMatch) {
-      let hours = parseInt(ampmMatch[1], 10);
-      const minutes = parseInt(ampmMatch[2], 10);
-      const ampm = ampmMatch[3]?.toUpperCase();
+  const match = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i);
+  if (!match) return null;
 
 
-      if (ampm === 'PM' && hours < 12) hours += 12;
-      if (ampm === 'AM' && hours === 12) hours = 0;
+  let hours = parseInt(match[1], 10);
+  const minutes = parseInt(match[2], 10);
+  const ampm = match[3]?.toUpperCase();
 
 
-      if (hours < 0 || hours > 23) return null;
-      if (minutes < 0 || minutes > 59) return null;
+  if (ampm === 'PM' && hours < 12) hours += 12;
+  if (ampm === 'AM' && hours === 12) hours = 0;
 
 
-      return { hours, minutes };
-    }
+  if (hours < 0 || hours > 23) return null;
+  if (minutes < 0 || minutes > 59) return null;
 
 
-    // Try 24h format HH:MM
-    const match24 = trimmed.match(/^(\d{1,2}):(\d{2})$/);
-    if (match24) {
-      const hours = parseInt(match24[1], 10);
-      const minutes = parseInt(match24[2], 10);
-
-      if (hours < 0 || hours > 23) return null;
-      if (minutes < 0 || minutes > 59) return null;
-
-      return { hours, minutes };
-    }
-
-    return null;
-  } catch {
-    return null;
-  }
+  return { hours, minutes };
 }
 }
 
 
 /**
 /**
@@ -387,21 +323,18 @@ export function formatTimeOnly(
  */
  */
 export function formatETA(
 export function formatETA(
   remainingMinutes: number,
   remainingMinutes: number,
-  timeFormat: 'system' | '12h' | '24h' = 'system',
+  timeFormat: TimeFormat = 'system',
   t?: (key: string) => string
   t?: (key: string) => string
 ): string {
 ): string {
   const now = new Date();
   const now = new Date();
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
 
 
-  const today = new Date();
+  const today = new Date(now);
   today.setHours(0, 0, 0, 0);
   today.setHours(0, 0, 0, 0);
   const etaDay = new Date(eta);
   const etaDay = new Date(eta);
   etaDay.setHours(0, 0, 0, 0);
   etaDay.setHours(0, 0, 0, 0);
 
 
-  const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
-  if (timeFormat === '12h') timeOptions.hour12 = true;
-  else if (timeFormat === '24h') timeOptions.hour12 = false;
-
+  const timeOptions = applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat);
   const timeStr = eta.toLocaleTimeString([], timeOptions);
   const timeStr = eta.toLocaleTimeString([], timeOptions);
   const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000);
   const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000);
 
 
@@ -485,3 +418,28 @@ export function formatRelativeTime(
   // Older than 7 days
   // Older than 7 days
   return formatDateTime(dateStr, timeFormat);
   return formatDateTime(dateStr, timeFormat);
 }
 }
+
+/**
+ * Format seconds as MM:SS for media/video player display.
+ *
+ * @param seconds - Total seconds
+ * @returns Formatted string (e.g., "2:05", "0:30")
+ */
+export function formatMediaTime(seconds: number): string {
+  const mins = Math.floor(seconds / 60);
+  const secs = Math.floor(seconds % 60);
+  return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
+/**
+ * Format a duration given in hours to a human-readable string.
+ *
+ * @param hours - Duration in hours (e.g., 2.5)
+ * @returns Formatted string (e.g., "2h 30m", "45m", "3h")
+ */
+export function formatDurationFromHours(hours: number): string {
+  if (hours < 1) return `${Math.round(hours * 60)}m`;
+  const h = Math.floor(hours);
+  const m = Math.round((hours - h) * 60);
+  return m > 0 ? `${h}h ${m}m` : `${h}h`;
+}

+ 1 - 1
frontend/src/utils/file.ts

@@ -5,7 +5,7 @@
  * @returns A formatted string with the appropriate unit (B, KB, MB, GB, or TB).
  * @returns A formatted string with the appropriate unit (B, KB, MB, GB, or TB).
  */
  */
 export function formatFileSize(bytes: number): string {
 export function formatFileSize(bytes: number): string {
-  if (bytes === 0) return '0 B';
+  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
 
 
   const units = ['B', 'KB', 'MB', 'GB', 'TB'];
   const units = ['B', 'KB', 'MB', 'GB', 'TB'];
   const k = 1024;
   const k = 1024;

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-BAl7JF0v.js


Vissa filer visades inte eftersom för många filer har ändrats