mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
- Spoolman: Add link spool feature, UUID display, and sync feedback
- Link to Spoolman: Manually link existing Spoolman spools to AMS trays
- Hover over AMS slot to see "Link to Spoolman" button
- Select from list of unlinked Spoolman spools
- Automatically sets extra.tag field in Spoolman
- Spool UUID display: View and copy Bambu Lab spool UUID in AMS hover card
- Shows first 8 chars with copy button (full UUID to clipboard)
- Fallback copy method for HTTP contexts
- Sync feedback: Shows synced count and skipped spools with reasons
- Expandable list when more than 5 skipped
- Color swatches for identification
- Fixed Spoolman 400 Bad Request: extra field values must be valid JSON
- Updated documentation (wiki, website)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { Droplets } from 'lucide-react';
|
||||
import { Droplets, Link2, Copy, Check } from 'lucide-react';
|
||||
|
||||
interface FilamentData {
|
||||
vendor: 'Bambu Lab' | 'Generic';
|
||||
@@ -8,6 +8,12 @@ interface FilamentData {
|
||||
colorHex: string | null;
|
||||
kFactor: string;
|
||||
fillLevel: number | null; // null = unknown
|
||||
trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
|
||||
}
|
||||
|
||||
interface SpoolmanConfig {
|
||||
enabled: boolean;
|
||||
onLinkSpool?: (trayUuid: string) => void;
|
||||
}
|
||||
|
||||
interface FilamentHoverCardProps {
|
||||
@@ -15,19 +21,56 @@ interface FilamentHoverCardProps {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
spoolman?: SpoolmanConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hover card that displays filament details when hovering over AMS slots.
|
||||
* Replaces the basic browser tooltip with a styled popover.
|
||||
*/
|
||||
export function FilamentHoverCard({ data, children, disabled, className = '' }: FilamentHoverCardProps) {
|
||||
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman }: FilamentHoverCardProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState<'top' | 'bottom'>('top');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCopyUuid = () => {
|
||||
const uuid = data.trayUuid;
|
||||
if (!uuid) return;
|
||||
|
||||
// Try modern clipboard API first, fallback to execCommand
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(uuid).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}).catch(() => {
|
||||
// Fallback on error
|
||||
fallbackCopy(uuid);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(uuid);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
console.error('Failed to copy to clipboard');
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
// Calculate position when showing
|
||||
useEffect(() => {
|
||||
if (isVisible && triggerRef.current && cardRef.current) {
|
||||
@@ -192,6 +235,49 @@ export function FilamentHoverCard({ data, children, disabled, className = '' }:
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spoolman section - only show if enabled */}
|
||||
{spoolman?.enabled && data.trayUuid && (
|
||||
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
|
||||
{/* Tray UUID with copy button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
|
||||
Spool ID
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyUuid();
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
|
||||
title="Copy spool UUID"
|
||||
>
|
||||
<span className="font-mono text-[10px] truncate max-w-[80px]">
|
||||
{data.trayUuid.slice(0, 8)}...
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-bambu-green" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link Spool button */}
|
||||
{spoolman.onLinkSpool && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
spoolman.onLinkSpool?.(data.trayUuid!);
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green text-xs font-medium rounded transition-colors"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
Link to Spoolman
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { X, Loader2, Link2, Check } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface LinkSpoolModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
trayUuid: string;
|
||||
trayInfo?: {
|
||||
type: string;
|
||||
color: string;
|
||||
location: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
|
||||
|
||||
// Fetch unlinked spools
|
||||
const { data: unlinkedSpools, isLoading } = useQuery({
|
||||
queryKey: ['unlinked-spools'],
|
||||
queryFn: api.getUnlinkedSpools,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Link mutation
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleLink = () => {
|
||||
if (selectedSpoolId) {
|
||||
linkMutation.mutate(selectedSpoolId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Link to Spoolman</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Tray info */}
|
||||
{trayInfo && (
|
||||
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
||||
<p className="text-xs text-bambu-gray mb-1">Linking AMS tray:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{trayInfo.color && (
|
||||
<span
|
||||
className="w-4 h-4 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: `#${trayInfo.color}` }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-white font-medium">{trayInfo.type}</span>
|
||||
<span className="text-bambu-gray">({trayInfo.location})</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spool UUID */}
|
||||
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
||||
<p className="text-xs text-bambu-gray mb-1">Spool UUID:</p>
|
||||
<code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
|
||||
</div>
|
||||
|
||||
{/* Spool list */}
|
||||
<div>
|
||||
<p className="text-sm text-bambu-gray mb-2">
|
||||
Select a Spoolman spool to link:
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : unlinkedSpools && unlinkedSpools.length > 0 ? (
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{unlinkedSpools.map((spool) => (
|
||||
<button
|
||||
key={spool.id}
|
||||
onClick={() => setSelectedSpoolId(spool.id)}
|
||||
className={`w-full p-3 rounded-lg border text-left transition-colors ${
|
||||
selectedSpoolId === spool.id
|
||||
? 'bg-bambu-green/20 border-bambu-green'
|
||||
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{spool.filament_color_hex && (
|
||||
<span
|
||||
className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
|
||||
style={{ backgroundColor: `#${spool.filament_color_hex}` }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate">
|
||||
{spool.filament_name || 'Unknown filament'}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{spool.filament_material || 'Unknown'}
|
||||
{spool.remaining_weight !== null && ` - ${Math.round(spool.remaining_weight)}g`}
|
||||
{spool.location && ` - ${spool.location}`}
|
||||
</p>
|
||||
</div>
|
||||
{selectedSpoolId === spool.id && (
|
||||
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<p>No unlinked spools found in Spoolman.</p>
|
||||
<p className="text-xs mt-1">All spools are already linked to AMS trays.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLink}
|
||||
disabled={!selectedSpoolId || linkMutation.isPending}
|
||||
>
|
||||
{linkMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Linking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="w-4 h-4" />
|
||||
Link Spool
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{linkMutation.isError && (
|
||||
<div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
|
||||
{(linkMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown } from 'lucide-react';
|
||||
import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { SpoolmanSyncResult, Printer } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
@@ -40,6 +40,7 @@ export function SpoolmanSettings() {
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showAllSkipped, setShowAllSkipped] = useState(false);
|
||||
|
||||
// Fetch Spoolman settings
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
@@ -192,6 +193,25 @@ export function SpoolmanSettings() {
|
||||
Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
|
||||
</p>
|
||||
|
||||
{/* Info banner about sync requirements */}
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<div className="flex gap-2">
|
||||
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-blue-300">
|
||||
<p className="font-medium mb-1">How Sync Works</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
|
||||
<li>Only official Bambu Lab spools with RFID are synced</li>
|
||||
<li>New spools are auto-created in Spoolman on first sync</li>
|
||||
<li>Non-Bambu Lab spools (third-party, refilled) are skipped</li>
|
||||
</ul>
|
||||
<p className="font-medium mt-2 mb-1">Linking Existing Spools</p>
|
||||
<p className="text-blue-300/80">
|
||||
To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -355,16 +375,72 @@ export function SpoolmanSettings() {
|
||||
|
||||
{/* Sync result */}
|
||||
{syncSuccess && syncResult && (
|
||||
<div
|
||||
className={`mt-2 p-2 rounded text-sm ${
|
||||
syncResult.success
|
||||
? 'bg-green-500/20 border border-green-500/50 text-green-400'
|
||||
: 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{syncResult.success
|
||||
? `Synced ${syncResult.synced_count} trays successfully`
|
||||
: `Synced ${syncResult.synced_count} trays with ${syncResult.errors.length} errors`}
|
||||
<div className="mt-3 space-y-2">
|
||||
{/* Main result */}
|
||||
<div
|
||||
className={`p-2 rounded text-sm ${
|
||||
syncResult.success
|
||||
? 'bg-green-500/20 border border-green-500/50 text-green-400'
|
||||
: 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{syncResult.success
|
||||
? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
|
||||
: `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
|
||||
</div>
|
||||
|
||||
{/* Skipped spools */}
|
||||
{syncResult.skipped_count > 0 && (
|
||||
<div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
|
||||
<div className="flex items-center justify-between text-amber-400 mb-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">
|
||||
{syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
|
||||
</span>
|
||||
</div>
|
||||
{syncResult.skipped_count > 5 && (
|
||||
<button
|
||||
onClick={() => setShowAllSkipped(!showAllSkipped)}
|
||||
className="text-xs text-amber-400 hover:text-amber-300 underline"
|
||||
>
|
||||
{showAllSkipped ? 'Show less' : 'Show all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul className="text-xs text-amber-300/80 space-y-0.5">
|
||||
{(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
{s.color && (
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: `#${s.color}` }}
|
||||
/>
|
||||
)}
|
||||
<span>{s.location}</span>
|
||||
<span className="text-amber-300/60">- {s.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
{!showAllSkipped && syncResult.skipped_count > 5 && (
|
||||
<li className="text-amber-300/60 italic">
|
||||
...and {syncResult.skipped_count - 5} more
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{syncResult.errors.length > 0 && (
|
||||
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
|
||||
<div className="text-red-400 font-medium mb-1">Errors:</div>
|
||||
<ul className="text-xs text-red-300/80 space-y-0.5">
|
||||
{syncResult.errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user