mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
930 lines
36 KiB
TypeScript
930 lines
36 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
Gauge,
|
|
Loader2,
|
|
RefreshCw,
|
|
Printer,
|
|
Plus,
|
|
X,
|
|
AlertCircle,
|
|
WifiOff,
|
|
Trash2,
|
|
Search,
|
|
} from 'lucide-react';
|
|
import { api } from '../api/client';
|
|
import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
|
|
import { Card, CardContent } from './Card';
|
|
import { Button } from './Button';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
|
interface KProfileCardProps {
|
|
profile: KProfile;
|
|
onEdit: () => void;
|
|
}
|
|
|
|
// Truncate to 3 decimal places (like Bambu Studio) instead of rounding
|
|
const truncateK = (value: string) => {
|
|
const num = parseFloat(value);
|
|
return (Math.trunc(num * 1000) / 1000).toFixed(3);
|
|
};
|
|
|
|
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
|
|
const getFlowTypeLabel = (nozzleId: string) => {
|
|
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
|
|
return 'S'; // Standard Flow (default)
|
|
};
|
|
|
|
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
|
|
const getNozzleTypePrefix = (nozzleId: string) => {
|
|
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
|
|
return match ? match[1] : 'HH00';
|
|
};
|
|
|
|
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
|
|
const extractFilamentName = (profileName: string) => {
|
|
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
|
|
// Remove common prefixes - check both underscore and space separators
|
|
const prefixes = [
|
|
'High Flow_', 'High Flow ', // underscore or space
|
|
'Standard_', 'Standard ',
|
|
'HF_', 'HF ',
|
|
'S_', 'S ',
|
|
];
|
|
for (const prefix of prefixes) {
|
|
if (profileName.startsWith(prefix)) {
|
|
return profileName.slice(prefix.length);
|
|
}
|
|
}
|
|
// If no prefix found, check for underscore separator
|
|
const underscoreIdx = profileName.indexOf('_');
|
|
if (underscoreIdx > 0) {
|
|
return profileName.slice(underscoreIdx + 1);
|
|
}
|
|
return profileName;
|
|
};
|
|
|
|
function KProfileCard({ profile, onEdit }: KProfileCardProps) {
|
|
const flowType = getFlowTypeLabel(profile.nozzle_id);
|
|
const diameter = profile.nozzle_diameter;
|
|
const profileName = profile.name || 'Unnamed';
|
|
// Extract filament name from profile name (e.g., "High Flow_eSUN ABS+" -> "eSUN ABS+")
|
|
const filamentName = extractFilamentName(profile.name || '');
|
|
|
|
return (
|
|
<button
|
|
onClick={onEdit}
|
|
className="w-full text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
|
|
{truncateK(profile.k_value)}
|
|
</span>
|
|
<span className="text-white text-sm truncate flex-1" title={profileName}>
|
|
{profileName}
|
|
</span>
|
|
<span className="text-xs text-bambu-gray whitespace-nowrap">
|
|
{flowType} {diameter}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-bambu-gray mt-0.5 truncate" title={`Filament: ${filamentName}`}>
|
|
Filament: {filamentName || profile.filament_id}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface KProfileModalProps {
|
|
profile?: KProfile;
|
|
printerId: number;
|
|
nozzleDiameter: string;
|
|
existingProfiles?: KProfile[]; // Existing profiles for filament selection
|
|
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
}
|
|
|
|
function KProfileModal({
|
|
profile,
|
|
printerId,
|
|
nozzleDiameter,
|
|
existingProfiles = [],
|
|
isDualNozzle = false,
|
|
onClose,
|
|
onSave,
|
|
}: KProfileModalProps) {
|
|
const { showToast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [name, setName] = useState(profile?.name || '');
|
|
const [kValue, setKValue] = useState(
|
|
profile?.k_value ? truncateK(profile.k_value) : '0.020'
|
|
);
|
|
const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
|
|
// Split nozzle into type and diameter
|
|
const [nozzleType, setNozzleType] = useState(
|
|
profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
|
|
);
|
|
const [modalDiameter, setModalDiameter] = useState(
|
|
profile?.nozzle_diameter || nozzleDiameter
|
|
);
|
|
// For new profiles on dual-nozzle: allow selecting multiple extruders
|
|
// For editing: use single extruder from the profile
|
|
const [selectedExtruders, setSelectedExtruders] = useState<number[]>(
|
|
profile ? [profile.extruder_id] : isDualNozzle ? [0, 1] : [0] // Default: both extruders for new dual-nozzle profiles
|
|
);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
|
|
|
|
// Extract unique filaments from existing K-profiles on the printer
|
|
// These have valid filament_ids that the printer recognizes
|
|
const knownFilaments = React.useMemo(() => {
|
|
const filamentMap = new Map<string, { id: string; name: string }>();
|
|
for (const p of existingProfiles) {
|
|
if (p.filament_id && !filamentMap.has(p.filament_id)) {
|
|
const filamentName = extractFilamentName(p.name || '');
|
|
filamentMap.set(p.filament_id, {
|
|
id: p.filament_id,
|
|
name: filamentName || p.filament_id,
|
|
});
|
|
}
|
|
}
|
|
return Array.from(filamentMap.values()).sort((a, b) =>
|
|
a.name.localeCompare(b.name)
|
|
);
|
|
}, [existingProfiles]);
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: (data: KProfileCreate) => {
|
|
console.log('[KProfile] Calling API...');
|
|
return api.setKProfile(printerId, data);
|
|
},
|
|
onSuccess: (result) => {
|
|
console.log('[KProfile] Save success:', result);
|
|
showToast('K-profile saved');
|
|
// Show syncing indicator while printer processes the command
|
|
setIsSyncing(true);
|
|
// Add delay before refreshing to give printer time to process the save
|
|
// Bambu printers can be slow to apply K-profile changes
|
|
setTimeout(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
|
|
setIsSyncing(false);
|
|
onSave();
|
|
}, 2500);
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error('[KProfile] Save error:', error);
|
|
showToast(error.message, 'error');
|
|
setIsSyncing(false);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (data: KProfileDelete) => {
|
|
console.log('[KProfile] Deleting profile...');
|
|
return api.deleteKProfile(printerId, data);
|
|
},
|
|
onSuccess: (result) => {
|
|
console.log('[KProfile] Delete success:', result);
|
|
showToast('K-profile deleted');
|
|
// Show syncing indicator while printer processes the command
|
|
setIsSyncing(true);
|
|
// Add delay before refreshing to give printer time to process the delete
|
|
// Bambu printers can be slow to apply K-profile changes
|
|
setTimeout(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
|
|
setIsSyncing(false);
|
|
onClose();
|
|
}, 2500);
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error('[KProfile] Delete error:', error);
|
|
showToast(error.message, 'error');
|
|
setIsSyncing(false);
|
|
},
|
|
});
|
|
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
|
|
const handleDelete = () => {
|
|
if (!profile) return;
|
|
deleteMutation.mutate({
|
|
slot_id: profile.slot_id,
|
|
extruder_id: profile.extruder_id,
|
|
nozzle_id: profile.nozzle_id,
|
|
nozzle_diameter: profile.nozzle_diameter,
|
|
filament_id: profile.filament_id,
|
|
setting_id: profile.setting_id,
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
// Validate at least one extruder is selected for dual-nozzle
|
|
if (isDualNozzle && !profile && selectedExtruders.length === 0) {
|
|
showToast('Please select at least one extruder', 'error');
|
|
return;
|
|
}
|
|
|
|
// Format k_value to 6 decimal places for Bambu protocol
|
|
const formattedKValue = parseFloat(kValue).toFixed(6);
|
|
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
|
|
const nozzleId = `${nozzleType}-${modalDiameter}`;
|
|
|
|
// For editing or single extruder: just save one profile
|
|
if (profile || selectedExtruders.length === 1) {
|
|
const payload = {
|
|
name: name,
|
|
k_value: formattedKValue,
|
|
filament_id: filamentId,
|
|
nozzle_id: nozzleId,
|
|
nozzle_diameter: modalDiameter,
|
|
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
|
|
setting_id: profile?.setting_id,
|
|
slot_id: profile?.slot_id ?? 0,
|
|
};
|
|
console.log('[KProfile] Saving profile:', payload);
|
|
saveMutation.mutate(payload);
|
|
return;
|
|
}
|
|
|
|
// For new profiles with multiple extruders: save sequentially
|
|
setIsSyncing(true);
|
|
setSavingProgress({ current: 0, total: selectedExtruders.length });
|
|
|
|
for (let i = 0; i < selectedExtruders.length; i++) {
|
|
const extruderId = selectedExtruders[i];
|
|
const payload = {
|
|
name: name,
|
|
k_value: formattedKValue,
|
|
filament_id: filamentId,
|
|
nozzle_id: nozzleId,
|
|
nozzle_diameter: modalDiameter,
|
|
extruder_id: extruderId,
|
|
setting_id: undefined,
|
|
slot_id: 0,
|
|
};
|
|
|
|
setSavingProgress({ current: i + 1, total: selectedExtruders.length });
|
|
console.log(`[KProfile] Saving profile ${i + 1}/${selectedExtruders.length} for extruder ${extruderId}:`, payload);
|
|
|
|
try {
|
|
await api.setKProfile(printerId, payload);
|
|
// Wait between saves to let printer process
|
|
if (i < selectedExtruders.length - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
}
|
|
} catch (error) {
|
|
console.error(`[KProfile] Failed to save for extruder ${extruderId}:`, error);
|
|
showToast(`Failed to save for ${extruderId === 1 ? 'Left' : 'Right'} extruder`, 'error');
|
|
setIsSyncing(false);
|
|
setSavingProgress({ current: 0, total: 0 });
|
|
return;
|
|
}
|
|
}
|
|
|
|
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
|
|
// Wait for final sync before closing
|
|
setTimeout(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
|
|
setIsSyncing(false);
|
|
setSavingProgress({ current: 0, total: 0 });
|
|
onSave();
|
|
}, 2500);
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<Card className="w-full max-w-md relative">
|
|
{/* Syncing overlay */}
|
|
{isSyncing && (
|
|
<div className="absolute inset-0 bg-bambu-dark-secondary/90 flex flex-col items-center justify-center z-10 rounded-lg">
|
|
<Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
|
|
<p className="text-white font-medium">
|
|
{savingProgress.total > 1
|
|
? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...`
|
|
: 'Syncing with printer...'}
|
|
</p>
|
|
<p className="text-bambu-gray text-sm mt-1">Please wait</p>
|
|
</div>
|
|
)}
|
|
<CardContent className="p-0">
|
|
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
|
<h2 className="text-xl font-semibold text-white">
|
|
{profile ? 'Edit K-Profile' : 'Add K-Profile'}
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-bambu-gray hover:text-white transition-colors"
|
|
disabled={isSyncing}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
|
{/* Profile Name - read-only when editing */}
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">Profile Name</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
disabled={!!profile}
|
|
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
placeholder="My PLA Profile"
|
|
required={!profile}
|
|
/>
|
|
</div>
|
|
|
|
{/* K-Value - always editable */}
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">K-Value</label>
|
|
<input
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={kValue}
|
|
onChange={(e) => {
|
|
// Allow typing any decimal value
|
|
const val = e.target.value;
|
|
if (val === '' || /^\d*\.?\d*$/.test(val)) {
|
|
setKValue(val);
|
|
}
|
|
}}
|
|
onBlur={(e) => {
|
|
// Format to 3 decimal places on blur
|
|
const num = parseFloat(e.target.value);
|
|
if (!isNaN(num)) {
|
|
setKValue((Math.trunc(num * 1000) / 1000).toFixed(3));
|
|
}
|
|
}}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono"
|
|
placeholder="0.020"
|
|
required
|
|
/>
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG
|
|
</p>
|
|
</div>
|
|
|
|
{/* Filament - read-only when editing */}
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">Filament</label>
|
|
<select
|
|
value={filamentId}
|
|
onChange={(e) => {
|
|
const newFilamentId = e.target.value;
|
|
setFilamentId(newFilamentId);
|
|
// Auto-generate profile name when filament is selected (for new profiles)
|
|
// Only auto-generate if name is empty - don't overwrite user input
|
|
if (!profile && newFilamentId && !name) {
|
|
const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
|
|
if (selectedFilament) {
|
|
const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
|
|
setName(`${flowLabel} ${selectedFilament.name}`);
|
|
}
|
|
}
|
|
}}
|
|
disabled={!!profile}
|
|
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
required={!profile}
|
|
>
|
|
<option value="">Select filament...</option>
|
|
{/* Show current filament when editing - look up from knownFilaments */}
|
|
{profile?.filament_id && (
|
|
<option key={profile.filament_id} value={profile.filament_id}>
|
|
{knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
|
|
</option>
|
|
)}
|
|
{/* Show known filaments from existing K-profiles (for new profiles) */}
|
|
{!profile && knownFilaments.map((f) => (
|
|
<option key={f.id} value={f.id}>
|
|
{f.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{!profile && knownFilaments.length === 0 && (
|
|
<p className="text-xs text-bambu-gray mt-1">
|
|
No filaments found. Create a K-profile in Bambu Studio first.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Flow Type and Nozzle Size - read-only when editing */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">Flow Type</label>
|
|
<select
|
|
value={nozzleType}
|
|
onChange={(e) => {
|
|
const newNozzleType = e.target.value;
|
|
setNozzleType(newNozzleType);
|
|
// Update profile name when flow type changes (for new profiles)
|
|
// Only auto-generate if name is empty - don't overwrite user input
|
|
if (!profile && filamentId && !name) {
|
|
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
|
|
if (selectedFilament) {
|
|
const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
|
|
setName(`${flowLabel} ${selectedFilament.name}`);
|
|
}
|
|
}
|
|
}}
|
|
disabled={!!profile}
|
|
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
>
|
|
<option value="HH00">High Flow</option>
|
|
<option value="HS00">Standard</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">Nozzle Size</label>
|
|
<select
|
|
value={modalDiameter}
|
|
onChange={(e) => setModalDiameter(e.target.value)}
|
|
disabled={!!profile}
|
|
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
>
|
|
<option value="0.2">0.2mm</option>
|
|
<option value="0.4">0.4mm</option>
|
|
<option value="0.6">0.6mm</option>
|
|
<option value="0.8">0.8mm</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Extruder - only show for dual-nozzle printers */}
|
|
{isDualNozzle && (
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">
|
|
{profile ? 'Extruder' : 'Extruders'}
|
|
</label>
|
|
{profile ? (
|
|
// Read-only display for editing
|
|
<div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
|
|
{profile.extruder_id === 1 ? 'Left' : 'Right'}
|
|
</div>
|
|
) : (
|
|
// Checkboxes for new profile - can select both
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedExtruders.includes(1)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedExtruders([...selectedExtruders, 1]);
|
|
} else {
|
|
setSelectedExtruders(selectedExtruders.filter(id => id !== 1));
|
|
}
|
|
}}
|
|
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
|
|
/>
|
|
<span className="text-white">Left</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedExtruders.includes(0)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedExtruders([...selectedExtruders, 0]);
|
|
} else {
|
|
setSelectedExtruders(selectedExtruders.filter(id => id !== 0));
|
|
}
|
|
}}
|
|
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
|
|
/>
|
|
<span className="text-white">Right</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-4">
|
|
{profile && (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
disabled={deleteMutation.isPending || isSyncing}
|
|
className="text-red-500 hover:bg-red-500/10"
|
|
>
|
|
{deleteMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={onClose}
|
|
disabled={isSyncing}
|
|
className="flex-1"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={saveMutation.isPending || isSyncing}
|
|
className="flex-1"
|
|
>
|
|
{saveMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Gauge className="w-4 h-4" />
|
|
)}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{showDeleteConfirm && (
|
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[60]">
|
|
<Card className="w-full max-w-sm">
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
|
|
<Trash2 className="w-5 h-5 text-red-500" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-white">Delete Profile</h3>
|
|
<p className="text-sm text-bambu-gray">This cannot be undone</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-bambu-gray mb-6">
|
|
Are you sure you want to delete <span className="text-white font-medium">"{profile?.name}"</span> from the printer?
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
className="flex-1"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setShowDeleteConfirm(false);
|
|
handleDelete();
|
|
}}
|
|
disabled={deleteMutation.isPending}
|
|
className="flex-1 bg-red-500 hover:bg-red-600 text-white"
|
|
>
|
|
{deleteMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4" />
|
|
)}
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ExtruderFilter = 'all' | 'left' | 'right';
|
|
type FlowTypeFilter = 'all' | 'hf' | 's';
|
|
|
|
export function KProfilesView() {
|
|
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
|
|
const [nozzleDiameter, setNozzleDiameter] = useState('0.4');
|
|
const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
|
|
const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
|
|
|
|
// Get available printers
|
|
const { data: printers, isLoading: printersLoading } = useQuery({
|
|
queryKey: ['printers'],
|
|
queryFn: api.getPrinters,
|
|
});
|
|
|
|
// Get K-profiles for selected printer
|
|
const {
|
|
data: kprofiles,
|
|
isLoading: kprofilesLoading,
|
|
error: kprofilesError,
|
|
refetch: refetchProfiles,
|
|
} = useQuery({
|
|
queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
|
|
queryFn: () => api.getKProfiles(selectedPrinter!, nozzleDiameter),
|
|
enabled: !!selectedPrinter,
|
|
retry: false,
|
|
staleTime: 0, // Always consider data stale to ensure fresh fetch
|
|
refetchOnMount: 'always', // Always refetch when component mounts
|
|
});
|
|
|
|
// Check if error is due to printer not being connected
|
|
const isOfflineError = kprofilesError?.message?.includes('not connected');
|
|
|
|
// Auto-select first connected printer
|
|
useEffect(() => {
|
|
if (!selectedPrinter && printers && printers.length > 0) {
|
|
const activePrinter = printers.find((p) => p.is_active);
|
|
if (activePrinter) {
|
|
setSelectedPrinter(activePrinter.id);
|
|
}
|
|
}
|
|
}, [selectedPrinter, printers]);
|
|
|
|
// Refetch profiles when printer selection changes
|
|
useEffect(() => {
|
|
if (selectedPrinter) {
|
|
// Delay refetch to ensure query is enabled after state update
|
|
const timer = setTimeout(() => {
|
|
refetchProfiles();
|
|
}, 150);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [selectedPrinter, nozzleDiameter]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Get connected printers for display
|
|
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
|
|
|
|
// Filter profiles based on search query, extruder filter, and flow type
|
|
const filteredProfiles = React.useMemo(() => {
|
|
if (!kprofiles?.profiles) return [];
|
|
|
|
return kprofiles.profiles.filter((p) => {
|
|
// Search filter - match name or filament_id (case-insensitive)
|
|
const query = searchQuery.toLowerCase();
|
|
const matchesSearch =
|
|
!query ||
|
|
p.name.toLowerCase().includes(query) ||
|
|
p.filament_id.toLowerCase().includes(query);
|
|
|
|
// Extruder filter
|
|
const matchesExtruder =
|
|
extruderFilter === 'all' ||
|
|
(extruderFilter === 'left' && p.extruder_id === 1) ||
|
|
(extruderFilter === 'right' && p.extruder_id === 0);
|
|
|
|
// Flow type filter (HH = High Flow, HS = Standard)
|
|
const matchesFlowType =
|
|
flowTypeFilter === 'all' ||
|
|
(flowTypeFilter === 'hf' && p.nozzle_id.startsWith('HH')) ||
|
|
(flowTypeFilter === 's' && p.nozzle_id.startsWith('HS'));
|
|
|
|
return matchesSearch && matchesExtruder && matchesFlowType;
|
|
});
|
|
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter]);
|
|
|
|
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
|
|
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
|
|
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
|
|
|
|
if (printersLoading) {
|
|
return (
|
|
<div className="flex justify-center py-12">
|
|
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!printers || printers.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-white mb-2">No Printers Configured</h3>
|
|
<p className="text-bambu-gray">
|
|
Add a printer in Settings to manage K-profiles
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (connectedPrinters.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-white mb-2">No Active Printers</h3>
|
|
<p className="text-bambu-gray">
|
|
Enable a printer connection to view its K-profiles
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Printer & Nozzle Selector */}
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
|
<div className="flex-1 min-w-48">
|
|
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
|
|
<select
|
|
value={selectedPrinter || ''}
|
|
onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
|
>
|
|
{connectedPrinters.map((printer) => (
|
|
<option key={printer.id} value={printer.id}>
|
|
{printer.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="w-32">
|
|
<label className="block text-sm text-bambu-gray mb-1">Nozzle</label>
|
|
<select
|
|
value={nozzleDiameter}
|
|
onChange={(e) => setNozzleDiameter(e.target.value)}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
|
>
|
|
<option value="0.2">0.2mm</option>
|
|
<option value="0.4">0.4mm</option>
|
|
<option value="0.6">0.6mm</option>
|
|
<option value="0.8">0.8mm</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-end gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => refetchProfiles()}
|
|
disabled={kprofilesLoading}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${kprofilesLoading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
<Button onClick={() => setShowAddModal(true)}>
|
|
<Plus className="w-4 h-4" />
|
|
Add Profile
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search & Filter Row */}
|
|
<div className="flex flex-wrap gap-4 mb-6">
|
|
<div className="flex-1 min-w-48 relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search by name or filament..."
|
|
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
|
|
/>
|
|
</div>
|
|
{isDualNozzle && (
|
|
<div className="w-36">
|
|
<select
|
|
value={extruderFilter}
|
|
onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
|
>
|
|
<option value="all">All Extruders</option>
|
|
<option value="left">Left Only</option>
|
|
<option value="right">Right Only</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div className="w-32">
|
|
<select
|
|
value={flowTypeFilter}
|
|
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
|
>
|
|
<option value="all">All Flow</option>
|
|
<option value="hf">HF Only</option>
|
|
<option value="s">S Only</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* K-Profiles Grid */}
|
|
{kprofilesLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
|
</div>
|
|
) : isOfflineError ? (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-white mb-2">Printer Offline</h3>
|
|
<p className="text-bambu-gray mb-4">
|
|
The selected printer is not connected. Power it on to view K-profiles.
|
|
</p>
|
|
<Button variant="secondary" onClick={() => refetchProfiles()}>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Retry
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : filteredProfiles.length > 0 ? (
|
|
isDualNozzle ? (
|
|
// Dual-nozzle: show Left/Right columns
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Left Extruder (extruder_id 1 on Bambu) */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Left Extruder</h3>
|
|
<div className="space-y-1">
|
|
{filteredProfiles
|
|
.filter((p) => p.extruder_id === 1)
|
|
.map((profile) => (
|
|
<KProfileCard
|
|
key={profile.slot_id}
|
|
profile={profile}
|
|
onEdit={() => setEditingProfile(profile)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* Right Extruder (extruder_id 0 on Bambu) */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Right Extruder</h3>
|
|
<div className="space-y-1">
|
|
{filteredProfiles
|
|
.filter((p) => p.extruder_id === 0)
|
|
.map((profile) => (
|
|
<KProfileCard
|
|
key={profile.slot_id}
|
|
profile={profile}
|
|
onEdit={() => setEditingProfile(profile)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Single-nozzle: show all profiles in one list
|
|
<div className="space-y-1">
|
|
{filteredProfiles.map((profile) => (
|
|
<KProfileCard
|
|
key={profile.slot_id}
|
|
profile={profile}
|
|
onEdit={() => setEditingProfile(profile)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
) : searchQuery || extruderFilter !== 'all' || flowTypeFilter !== 'all' ? (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-white mb-2">No Matching Profiles</h3>
|
|
<p className="text-bambu-gray">
|
|
No profiles match your search criteria
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-12 text-center">
|
|
<Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-white mb-2">No K-Profiles</h3>
|
|
<p className="text-bambu-gray mb-4">
|
|
No pressure advance profiles found for {nozzleDiameter}mm nozzle
|
|
</p>
|
|
<Button onClick={() => setShowAddModal(true)}>
|
|
<Plus className="w-4 h-4" />
|
|
Create First Profile
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Edit Modal */}
|
|
{editingProfile && selectedPrinter && (
|
|
<KProfileModal
|
|
profile={editingProfile}
|
|
printerId={selectedPrinter}
|
|
nozzleDiameter={nozzleDiameter}
|
|
existingProfiles={kprofiles?.profiles}
|
|
isDualNozzle={isDualNozzle}
|
|
onClose={() => setEditingProfile(null)}
|
|
onSave={() => setEditingProfile(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Add Modal */}
|
|
{showAddModal && selectedPrinter && (
|
|
<KProfileModal
|
|
printerId={selectedPrinter}
|
|
nozzleDiameter={nozzleDiameter}
|
|
existingProfiles={kprofiles?.profiles}
|
|
isDualNozzle={isDualNozzle}
|
|
onClose={() => setShowAddModal(false)}
|
|
onSave={() => setShowAddModal(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|