Moved Spoolbuddy frontend to Bambuddy

This commit is contained in:
maziggy
2026-02-23 07:58:00 +01:00
parent 9381edf0cb
commit d5c9f2b9ea
24 changed files with 2405 additions and 420 deletions
@@ -0,0 +1,132 @@
import { useTranslation } from 'react-i18next';
import type { AMSUnit, AMSTray } from '../../api/client';
function trayColorToCSS(color: string | null): string {
if (!color) return '#808080';
return `#${color.slice(0, 6)}`;
}
function isTrayEmpty(tray: AMSTray): boolean {
return !tray.tray_type || tray.tray_type === '';
}
interface SpoolSlotProps {
tray: AMSTray;
slotIndex: number;
isActive: boolean;
}
function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
const isEmpty = isTrayEmpty(tray);
const color = trayColorToCSS(tray.tray_color);
return (
<div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-green-500' : ''}`}>
{/* Spool visualization */}
<div className="relative w-14 h-14 mb-1">
{isEmpty ? (
<div className="w-full h-full rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center">
<div className="w-3 h-3 rounded-full bg-zinc-700" />
</div>
) : (
<svg viewBox="0 0 56 56" className="w-full h-full">
<circle cx="28" cy="28" r="26" fill={color} />
<circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
<ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
<circle cx="28" cy="28" r="8" fill="#27272a" />
<circle cx="28" cy="28" r="5" fill="#18181b" />
</svg>
)}
{isActive && (
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-2 h-2 bg-green-500 rounded-full" />
)}
</div>
{/* Material type */}
<span className="text-xs text-zinc-400 truncate max-w-full">
{isEmpty ? 'Empty' : tray.tray_type || 'Unknown'}
</span>
{/* Fill level bar */}
{!isEmpty && tray.remain !== null && tray.remain !== undefined && tray.remain >= 0 && (
<div className="w-full h-1 bg-zinc-700 rounded-full overflow-hidden mt-1">
<div
className="h-full rounded-full transition-all"
style={{
width: `${tray.remain}%`,
backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 15 ? '#f59e0b' : '#ef4444',
}}
/>
</div>
)}
{/* Slot number */}
<span className="absolute top-1 right-1 text-[10px] text-zinc-600">{slotIndex + 1}</span>
</div>
);
}
interface AmsUnitCardProps {
unit: AMSUnit;
activeSlot: number | null;
}
export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
const { t } = useTranslation();
const trays = unit.tray || [];
const isHt = unit.is_ams_ht;
const getAmsName = (id: number): string => {
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
return `AMS ${id}`;
};
const slotCount = isHt ? 1 : 4;
return (
<div className="bg-zinc-800 rounded-lg p-3">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<span className="text-white font-medium">{getAmsName(unit.id)}</span>
{unit.humidity !== null && unit.humidity !== undefined && (
<div className="flex items-center gap-1 text-xs text-zinc-500">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
</svg>
<span>{unit.humidity > 5 ? `${unit.humidity}%` : `${t('spoolbuddy.ams.level', 'Level')} ${unit.humidity}`}</span>
</div>
)}
</div>
{/* Slots grid */}
<div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px] mx-auto' : 'grid-cols-4'} gap-2`}>
{Array.from({ length: slotCount }).map((_, i) => {
const tray = trays[i] || {
id: i,
tray_color: null,
tray_type: '',
tray_sub_brands: null,
tray_id_name: null,
tray_info_idx: null,
remain: -1,
k: null,
cali_idx: null,
tag_uid: null,
tray_uuid: null,
nozzle_temp_min: null,
nozzle_temp_max: null,
};
return (
<SpoolSlot
key={i}
tray={tray}
slotIndex={i}
isActive={activeSlot === i}
/>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const navItems = [
{
to: '/spoolbuddy',
labelKey: 'spoolbuddy.nav.dashboard',
fallback: 'Dashboard',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
to: '/spoolbuddy/ams',
labelKey: 'spoolbuddy.nav.ams',
fallback: 'AMS',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
to: '/spoolbuddy/inventory',
labelKey: 'spoolbuddy.nav.inventory',
fallback: 'Inventory',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
},
{
to: '/spoolbuddy/settings',
labelKey: 'spoolbuddy.nav.settings',
fallback: 'Settings',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
];
export function SpoolBuddyBottomNav() {
const { t } = useTranslation();
return (
<nav className="h-12 bg-zinc-950 border-t border-zinc-800 flex items-stretch shrink-0">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/spoolbuddy'}
className={({ isActive }) =>
`flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${
isActive
? 'text-green-500 bg-zinc-900'
: 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-900/50'
}`
}
>
{item.icon}
<span className="text-[10px] font-medium">{t(item.labelKey, item.fallback)}</span>
</NavLink>
))}
</nav>
);
}
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
export function SpoolBuddyLayout() {
const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
const sbState = useSpoolBuddyState();
// Force dark theme on mount, restore on unmount
useEffect(() => {
const root = document.documentElement;
const hadDark = root.classList.contains('dark');
root.classList.add('dark');
return () => {
if (!hadDark) root.classList.remove('dark');
};
}, []);
return (
<div className="w-screen h-screen bg-zinc-900 text-zinc-100 flex flex-col overflow-hidden">
<SpoolBuddyTopBar
selectedPrinterId={selectedPrinterId}
onPrinterChange={setSelectedPrinterId}
deviceOnline={sbState.deviceOnline}
/>
<main className="flex-1 overflow-y-auto">
<Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState }} />
</main>
<SpoolBuddyBottomNav />
</div>
);
}
// Hook for child pages to access shared context
export interface SpoolBuddyOutletContext {
selectedPrinterId: number | null;
setSelectedPrinterId: (id: number) => void;
sbState: ReturnType<typeof useSpoolBuddyState>;
}
@@ -0,0 +1,83 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api, type Printer } from '../../api/client';
interface SpoolBuddyTopBarProps {
selectedPrinterId: number | null;
onPrinterChange: (id: number) => void;
deviceOnline: boolean;
}
export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnline }: SpoolBuddyTopBarProps) {
const { t } = useTranslation();
const [currentTime, setCurrentTime] = useState(new Date());
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
});
// Auto-select first printer
useEffect(() => {
if (!selectedPrinterId && printers.length > 0) {
onPrinterChange(printers[0].id);
}
}, [printers, selectedPrinterId, onPrinterChange]);
// Clock
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 30000);
return () => clearInterval(timer);
}, []);
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (
<div className="h-11 bg-zinc-950 border-b border-zinc-800 flex items-center px-3 gap-4 shrink-0">
{/* Logo */}
<div className="flex items-center gap-2 shrink-0">
<div className="w-6 h-6 rounded bg-green-600 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<span className="text-white font-semibold text-sm hidden sm:inline">SpoolBuddy</span>
</div>
{/* Printer selector - centered */}
<div className="flex-1 flex justify-center">
<select
value={selectedPrinterId ?? ''}
onChange={(e) => onPrinterChange(Number(e.target.value))}
className="bg-zinc-800 text-white text-sm px-3 py-1.5 rounded border border-zinc-700 focus:outline-none focus:border-green-500 min-w-[150px]"
>
{printers.length === 0 ? (
<option value="">{t('spoolbuddy.status.noPrinters', 'No printers')}</option>
) : (
printers.map((printer: Printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))
)}
</select>
</div>
{/* Right side indicators */}
<div className="flex items-center gap-3 shrink-0">
{/* Device LED */}
<div className="flex items-center gap-1.5" title={deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}>
<div className={`w-2.5 h-2.5 rounded-full ${deviceOnline ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]' : 'bg-zinc-600'}`} />
<span className="text-xs text-zinc-400">{deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}</span>
</div>
{/* Clock */}
<span className="text-zinc-400 text-sm font-mono min-w-[50px] text-right">
{formatTime(currentTime)}
</span>
</div>
</div>
);
}
@@ -0,0 +1,166 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
import { spoolbuddyApi } from '../../api/client';
interface SpoolInfoCardProps {
spool: MatchedSpool;
scaleWeight: number | null;
weightStable: boolean;
onClose?: () => void;
}
export function SpoolInfoCard({ spool, scaleWeight, weightStable, onClose }: SpoolInfoCardProps) {
const { t } = useTranslation();
const [syncing, setSyncing] = useState(false);
const [synced, setSynced] = useState(false);
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
const remainingPct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
const netWeight = scaleWeight !== null ? Math.max(0, scaleWeight - spool.core_weight) : null;
const handleSyncWeight = async () => {
if (scaleWeight === null || !weightStable) return;
setSyncing(true);
try {
await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
setSynced(true);
setTimeout(() => setSynced(false), 3000);
} catch (e) {
console.error('Failed to sync weight:', e);
} finally {
setSyncing(false);
}
};
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
return (
<div className="bg-zinc-800 rounded-xl p-4 relative">
{/* Close button */}
{onClose && (
<button
onClick={onClose}
className="absolute top-3 right-3 p-1 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Header: color swatch + material info */}
<div className="flex items-center gap-3 mb-4">
<div
className="w-10 h-10 rounded-full border-2 border-zinc-700 shrink-0"
style={{ backgroundColor: colorHex }}
/>
<div className="min-w-0">
<div className="text-base font-medium text-zinc-100 truncate">
{spool.material}
{spool.color_name && <span className="text-zinc-400 ml-1.5">- {spool.color_name}</span>}
</div>
{spool.brand && (
<div className="text-sm text-zinc-400 truncate">{spool.brand}</div>
)}
</div>
</div>
{/* Remaining weight bar */}
<div className="mb-4">
<div className="flex justify-between text-xs text-zinc-400 mb-1">
<span>{t('spoolbuddy.spool.remaining', 'Remaining')}</span>
<span>{Math.round(remaining)}g / {spool.label_weight}g</span>
</div>
<div className="w-full h-2 bg-zinc-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, remainingPct)}%`,
backgroundColor: remainingPct > 50 ? '#22c55e' : remainingPct > 15 ? '#f59e0b' : '#ef4444',
}}
/>
</div>
</div>
{/* Weight details grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm mb-4">
<div className="flex justify-between">
<span className="text-zinc-500">{t('spoolbuddy.spool.labelWeight', 'Label')}</span>
<span className="text-zinc-300">{spool.label_weight}g</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
<span className="text-zinc-300">{spool.core_weight}g</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
<span className="text-zinc-300">{scaleWeight !== null ? `${scaleWeight.toFixed(1)}g` : '--'}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">{t('spoolbuddy.spool.netWeight', 'Net')}</span>
<span className="text-zinc-300">{netWeight !== null ? `${netWeight.toFixed(1)}g` : '--'}</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={handleSyncWeight}
disabled={!weightStable || scaleWeight === null || syncing}
className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
synced
? 'bg-green-600/20 text-green-400'
: 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
}`}
>
{syncing ? t('common.saving', 'Saving...') : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
</button>
</div>
</div>
);
}
interface UnknownTagCardProps {
tagUid: string;
onLinkSpool?: () => void;
onClose?: () => void;
}
export function UnknownTagCard({ tagUid, onLinkSpool, onClose }: UnknownTagCardProps) {
const { t } = useTranslation();
return (
<div className="bg-zinc-800 rounded-xl p-4 relative">
{onClose && (
<button
onClick={onClose}
className="absolute top-3 right-3 p-1 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center shrink-0">
<svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div>
<div className="text-base font-medium text-zinc-100">{t('spoolbuddy.dashboard.unknownTag', 'Unknown Tag')}</div>
<div className="text-xs text-zinc-500 font-mono">{tagUid}</div>
</div>
</div>
<button
onClick={onLinkSpool}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-amber-600 text-white hover:bg-amber-700 transition-colors min-h-[44px]"
>
{t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
</button>
</div>
);
}
@@ -0,0 +1,66 @@
import { useTranslation } from 'react-i18next';
import { spoolbuddyApi } from '../../api/client';
interface WeightDisplayProps {
weight: number | null;
weightStable: boolean;
deviceOnline: boolean;
deviceId: string | null;
}
export function WeightDisplay({ weight, weightStable, deviceOnline, deviceId }: WeightDisplayProps) {
const { t } = useTranslation();
const handleTare = async () => {
if (!deviceId) return;
try {
await spoolbuddyApi.tare(deviceId);
} catch (e) {
console.error('Failed to tare:', e);
}
};
const formatWeight = (w: number | null) => {
if (w === null) return '--.-';
return w.toFixed(1);
};
return (
<div className="flex flex-col items-center gap-3">
{/* Weight readout */}
<div className="flex items-baseline gap-2">
<span className="text-5xl font-light tabular-nums text-zinc-100">
{formatWeight(weight)}
</span>
<span className="text-xl text-zinc-400">g</span>
</div>
{/* Stability indicator */}
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
!deviceOnline
? 'bg-zinc-600'
: weightStable
? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'
: 'bg-amber-500 animate-pulse'
}`} />
<span className="text-xs text-zinc-400">
{!deviceOnline
? t('spoolbuddy.weight.noReading', 'No reading')
: weightStable
? t('spoolbuddy.weight.stable', 'Stable')
: t('spoolbuddy.weight.measuring', 'Measuring...')}
</span>
</div>
{/* Tare button */}
<button
onClick={handleTare}
disabled={!deviceOnline || !deviceId}
className="px-4 py-2 text-sm font-medium rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors min-h-[40px]"
>
{t('spoolbuddy.weight.tare', 'Tare')}
</button>
</div>
);
}