mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
Moved Spoolbuddy frontend to Bambuddy
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user