Add Spool card fixes and additions, It locale update

This commit is contained in:
Matteo Parenti
2026-02-13 16:26:18 +01:00
parent f88c7704bc
commit 30b6190163
9 changed files with 188 additions and 19 deletions
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Scale } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useToast } from '../../contexts/ToastContext';
import type { AdditionalSectionProps } from './types';
function SpoolWeightPicker({
@@ -129,6 +130,18 @@ export function AdditionalSection({
spoolCatalog,
}: AdditionalSectionProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const [measuredInput, setMeasuredInput] = useState('');
const [isMeasuredFocused, setIsMeasuredFocused] = useState(false);
const remainingWeight = Math.max(0, formData.label_weight - formData.weight_used);
const measuredDefault = formData.core_weight + remainingWeight;
useEffect(() => {
if (!isMeasuredFocused) {
setMeasuredInput(String(measuredDefault));
}
}, [isMeasuredFocused, measuredDefault]);
return (
<div className="space-y-4">
@@ -146,7 +159,7 @@ export function AdditionalSection({
<div className="relative flex-1">
<input
type="number"
value={Math.max(0, formData.label_weight - formData.weight_used)}
value={remainingWeight}
min={0}
max={formData.label_weight}
onChange={(e) => {
@@ -161,6 +174,45 @@ export function AdditionalSection({
</div>
</div>
{/* Measured Weight (empty spool + remaining filament) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.measuredWeight')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="number"
value={measuredInput}
min={0}
onFocus={() => setIsMeasuredFocused(true)}
onChange={(e) => {
setMeasuredInput(e.target.value);
}}
onBlur={() => {
setIsMeasuredFocused(false);
const raw = measuredInput.trim();
const measured = Number(raw);
const minAllowed = formData.core_weight;
const maxAllowed = formData.core_weight + formData.label_weight;
if (!raw || !Number.isFinite(measured) || measured < minAllowed || measured > maxAllowed) {
showToast(t('inventory.measuredWeightError', { min: minAllowed, max: maxAllowed }), 'error');
setMeasuredInput(String(measuredDefault));
return;
}
const rounded = Math.round(measured);
const remaining = Math.max(0, Math.min(formData.label_weight, rounded - formData.core_weight));
updateField('weight_used', Math.max(0, formData.label_weight - remaining));
setMeasuredInput(String(rounded));
}}
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
</div>
<span className="text-xs text-bambu-gray shrink-0">/ {formData.core_weight + formData.label_weight}g</span>
</div>
</div>
{/* Note */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
@@ -19,9 +19,12 @@ export function FilamentSection({
const { t } = useTranslation();
const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);
const [brandSearch, setBrandSearch] = useState('');
const [subtypeSearch, setSubtypeSearch] = useState('');
const presetRef = useRef<HTMLDivElement>(null);
const brandRef = useRef<HTMLDivElement>(null);
const subtypeRef = useRef<HTMLDivElement>(null);
// Close dropdowns on outside click
useEffect(() => {
@@ -32,6 +35,9 @@ export function FilamentSection({
if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
setBrandDropdownOpen(false);
}
if (subtypeRef.current && !subtypeRef.current.contains(e.target as Node)) {
setSubtypeDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
@@ -54,6 +60,12 @@ export function FilamentSection({
return availableBrands.filter(b => b.toLowerCase().includes(search));
}, [availableBrands, brandSearch]);
const filteredVariants = useMemo(() => {
if (!subtypeSearch) return KNOWN_VARIANTS;
const search = subtypeSearch.toLowerCase();
return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search));
}, [subtypeSearch]);
// Handle preset selection
const handlePresetSelect = (option: FilamentOption) => {
updateField('slicer_filament', option.code);
@@ -210,20 +222,59 @@ export function FilamentSection({
{/* Variant / Subtype */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
<div className="relative">
<div className="relative" ref={subtypeRef}>
<input
type="text"
value={formData.subtype}
onChange={(e) => updateField('subtype', e.target.value)}
list="variant-suggestions"
value={subtypeDropdownOpen ? subtypeSearch : formData.subtype}
onChange={(e) => {
setSubtypeSearch(e.target.value);
setSubtypeDropdownOpen(true);
}}
onFocus={() => {
setSubtypeDropdownOpen(true);
setSubtypeSearch('');
}}
placeholder="Basic, Matte, Silk..."
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
/>
<datalist id="variant-suggestions">
{KNOWN_VARIANTS.map(v => (
<option key={v} value={v} />
))}
</datalist>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
{subtypeDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
{filteredVariants.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filteredVariants.map(variant => (
<button
key={variant}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
}`}
onClick={() => {
updateField('subtype', variant);
setSubtypeDropdownOpen(false);
setSubtypeSearch('');
}}
>
{variant}
</button>
))
)}
{subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && (
<button
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
onClick={() => {
updateField('subtype', subtypeSearch);
setSubtypeDropdownOpen(false);
setSubtypeSearch('');
}}
>
{t('inventory.useCustomBrand', { brand: subtypeSearch })}
</button>
)}
</div>
)}
</div>
</div>
+2
View File
@@ -2462,6 +2462,8 @@ export default {
searchSpoolWeight: 'Spulengewicht suchen...',
weightUsed: 'Verbraucht',
currentWeight: 'Restgewicht',
measuredWeight: 'Gemessenes Gewicht',
measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.',
slicerFilament: 'Slicer-Filament',
slicerFilamentName: 'Slicer-Preset-Name',
slicerPreset: 'Slicer-Preset',
+2
View File
@@ -2462,6 +2462,8 @@ export default {
searchSpoolWeight: 'Search spool weight...',
weightUsed: 'Used',
currentWeight: 'Remaining Weight',
measuredWeight: 'Measured Weight',
measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.',
slicerFilament: 'Slicer Filament',
slicerFilamentName: 'Slicer Preset Name',
slicerPreset: 'Slicer Preset',
+3 -1
View File
@@ -2458,6 +2458,8 @@ export default {
searchSpoolWeight: 'Chercher poids bobine...',
weightUsed: 'Consommé',
currentWeight: 'Poids restant',
measuredWeight: 'Poids mesuré',
measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.',
slicerFilament: 'Filament Slicer',
slicerFilamentName: 'Nom du Preset Slicer',
slicerPreset: 'Preset Slicer',
@@ -3347,4 +3349,4 @@ export default {
// Spoolman Settings
spoolmanSettings: {},
};
};
+58
View File
@@ -2261,6 +2261,64 @@ export default {
reportPartialUsageDesc: 'Quando una stampa fallisce o viene annullata, segnala il filamento stimato usato fino a quel punto in base all\'avanzamento layer.',
},
// Inventory
inventory: {
title: 'Inventario Bobine',
addSpool: 'Aggiungi Bobina',
editSpool: 'Modifica Bobina',
material: 'Materiale',
selectMaterial: 'Seleziona materiale...',
subtype: 'Sottotipo',
brand: 'Marchio',
searchBrand: 'Cerca marchio...',
useCustomBrand: 'Usa "{{brand}}"',
colorName: 'Nome Colore',
colorNamePlaceholder: 'Jade White, Fire Red...',
color: 'Colore',
hexColor: 'Colore Hex',
pickColor: 'Scegli colore personalizzato',
labelWeight: 'Peso da Etichetta',
coreWeight: 'Peso Bobina Vuota',
searchSpoolWeight: 'Cerca peso bobina...',
weightUsed: 'Utilizzato',
currentWeight: 'Peso Rimanente',
measuredWeight: 'Peso Misurato',
measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.',
slicerFilament: 'Filamento Slicer',
slicerFilamentName: 'Nome Preset Slicer',
slicerPreset: 'Preset Slicer',
searchPresets: 'Cerca preset filamento...',
selectedPreset: 'Selezionato',
noPresetsFound: 'Nessun preset trovato',
tempOverrides: 'Override Temperatura',
note: 'Nota',
notePlaceholder: 'Eventuali note aggiuntive su questa bobina...',
archive: 'Archivia',
restore: 'Ripristina',
noSpools: 'Ancora nessuna bobina. Aggiungi la tua prima bobina per iniziare.',
noManualSpools: 'Nessuna bobina aggiunta manualmente disponibile. Aggiungi prima una bobina al tuo inventario.',
kProfiles: 'K-Profiles',
addKProfile: 'Aggiungi K-Profile',
assignSpool: 'Assegna Bobina',
unassignSpool: 'Deassegna',
assignSuccess: 'Bobina assegnata e slot AMS configurato',
assignFailed: 'Assegnazione bobina fallita',
selectSpool: 'Seleziona una bobina da assegnare a questo slot',
assigned: 'Assegnato',
assigning: 'Assegnazione...',
searchSpools: 'Cerca bobine...',
allMaterials: 'Tutti i Materiali',
filterByBrand: 'Filtra per marchio...',
showArchived: 'Mostra archiviate',
spoolCreated: 'Bobina creata',
spoolUpdated: 'Bobina aggiornata',
spoolDeleted: 'Bobina eliminata',
spoolArchived: 'Bobina archiviata',
spoolRestored: 'Bobina ripristinata',
deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
advancedSettings: 'Impostazioni Avanzate',
},
// Timelapse
timelapse: {
title: 'Timelapse',
+2
View File
@@ -2393,6 +2393,8 @@ export default {
searchSpoolWeight: 'スプール重量を検索...',
weightUsed: '使用量',
currentWeight: '残量',
measuredWeight: '計測重量',
measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
slicerFilament: 'スライサーフィラメント',
slicerFilamentName: 'スライサープリセット名',
slicerPreset: 'スライサープリセット',
+4 -4
View File
@@ -91,7 +91,7 @@ export function LoginPage() {
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
{advancedAuthStatus?.advanced_auth_enabled
{advancedAuthStatus?.advanced_auth_enabled
? t('login.usernameOrEmail') || 'Username or Email'
: t('login.username')}
</label>
@@ -102,7 +102,7 @@ export function LoginPage() {
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={advancedAuthStatus?.advanced_auth_enabled
placeholder={advancedAuthStatus?.advanced_auth_enabled
? t('login.usernameOrEmailPlaceholder') || 'Enter your username or email'
: t('login.usernamePlaceholder')}
autoComplete="username"
@@ -217,8 +217,8 @@ export function LoginPage() {
className="flex-1"
disabled={forgotPasswordMutation.isPending}
>
{forgotPasswordMutation.isPending
? (t('login.sending') || 'Sending...')
{forgotPasswordMutation.isPending
? (t('login.sending') || 'Sending...')
: (t('login.sendResetEmail') || 'Send Reset Email')}
</Button>
</div>
+4 -4
View File
@@ -147,7 +147,7 @@ export function UsersPage() {
const handleCreate = () => {
// Use the status from the query hook
const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
if (!formData.username) {
const errorMsg = t('users.toast.fillRequired');
showToast(errorMsg, 'error');
@@ -156,7 +156,7 @@ export function UsersPage() {
}
return;
}
// Email is required when advanced auth is enabled
if (advancedAuthEnabled && !formData.email) {
const errorMsg = 'Email is required when advanced authentication is enabled';
@@ -164,7 +164,7 @@ export function UsersPage() {
console.error('[Advanced Auth] Create user failed: Email is required when advanced authentication is enabled');
return;
}
// Password validation only when advanced auth is disabled
if (!advancedAuthEnabled) {
if (!formData.password) {
@@ -180,7 +180,7 @@ export function UsersPage() {
return;
}
}
createMutation.mutate({
username: formData.username,
password: advancedAuthEnabled ? undefined : formData.password,