mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Add Spool card fixes and additions, It locale update
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2393,6 +2393,8 @@ export default {
|
||||
searchSpoolWeight: 'スプール重量を検索...',
|
||||
weightUsed: '使用量',
|
||||
currentWeight: '残量',
|
||||
measuredWeight: '計測重量',
|
||||
measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
|
||||
slicerFilament: 'スライサーフィラメント',
|
||||
slicerFilamentName: 'スライサープリセット名',
|
||||
slicerPreset: 'スライサープリセット',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user