Selaa lähdekoodia

Let number fields be cleared and retyped (issue #3182)

maziggy 3 päivää sitten
vanhempi
sitoutus
17aa5e5439

+ 1 - 0
CHANGELOG.md

@@ -46,6 +46,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Number fields can be cleared and retyped (#3182, reported by @Carter3DP)** — Every number field corrected its value on each keystroke, so erasing the "1" in the print dialog's Quantity snapped straight back to 1, and getting to 6 meant typing 16 and deleting the 1. It was worse where the minimum is above 1: typing the "6" of 60 into the AMS drying temperature turned it into 45, so the field could only be set with the arrows. Fields now keep what you type while you edit, use it once it is a number in range, and settle on leaving the field: out-of-range numbers are pulled into range, and an empty field goes back to its default. This covers the 38 number fields across the print dialog, per-plate quantities, scheduling, drying, Settings, smart plugs, backups, spools, projects and SpoolBuddy. Two fields could not be set to 0 even though 0 is allowed, the smart plug's delay after drying and AMS humidity; they can now. The drying presets now also keep to their own limits, 30–65 °C on an AMS 2 Pro and 30–85 °C on an AMS HT.
 - **Installing or updating no longer runs out of memory on 2 GB machines (#3181, reported by @PhilippeP62)** — Every installer and updater builds the frontend with `npm run build`, which also ran the TypeScript type check. That check alone needs about 1 GB of Node memory, the whole default allowance on a 2 GB machine such as the standard Proxmox LXC or a 2 GB Raspberry Pi, so the build crashed with "JavaScript heap out of memory". On the Proxmox helper script that left the install without its database and nobody could sign in; Bambuddy's own update script rolled back but could never finish an update. The build now only bundles the frontend, and the type check runs on its own in development and CI (`npm run typecheck`). That also fixes CI's type-check job and the pre-commit hook, which ran `tsc --noEmit` against a config that lists no files and so had been passing without checking anything.
 - **Remember Me is back on the login page when only SSO sign-in is allowed (#2784, reported and contributed by @vuthanhtrung2010 in #3117)** — Turning off local username/password login hid the whole sign-in form, and the Remember Me checkbox went with it. An SSO sign-in could then only ever last until the browser closed, so SSO users signed in again every time they came back. The checkbox now sits above the SSO buttons when local login is off, and the "or continue with" divider, which had nothing left to divide, is hidden. Signing in with Remember Me unticked now also clears a choice left over from an abandoned attempt; before, ticking it, backing out of the sign-in provider and then signing in unticked still kept you signed in. With automatic SSO sign-in configured, Bambuddy now also remembers the page you were trying to open and returns you there after signing in, instead of to the dashboard. Automatic sign-in redirects before the page can show the checkbox, so those sign-ins still last only until the browser closes.
 - **A queued file whose only plate isn't plate 1 now prints instead of hanging the printer (#2947, reported and contributed by @sgiffhorn in #2951)** — A queue item with no plate chosen was always sent as plate 1. A file exported with a single plate keeps that plate's original number, so a file holding only `plate_2.gcode` made the printer look for a plate that isn't there. The printer accepted the command, raised an HMS error and sat idle until it was power-cycled, while the queue blamed the SD card. An item with no plate now prints the lowest plate the file actually contains, and falls back to plate 1 only when the file can't be read. A stored plate of 0 counts as no plate. The same plate is now used everywhere a queued print is prepared: the G-code tweaks, filament usage, the H2C nozzle-rack checks, the external-spool check and the print command. Before, each one assumed plate 1 on its own.

+ 7 - 1
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -567,12 +567,16 @@ describe('EditArchiveModal', () => {
       );
       const field = screen.getByLabelText(/items printed/i) as HTMLInputElement;
       await user.clear(field);
+      // Empty while editing (#3182), and 0 rather than 1 once the field is left.
+      expect(field.value).toBe('');
+      await user.tab();
 
       expect(field.value).toBe('0');
     });
 
     it('still refuses a negative count', async () => {
       const user = userEvent.setup();
+      const seen = patchSpy();
 
       render(
         <EditArchiveModal
@@ -584,8 +588,10 @@ describe('EditArchiveModal', () => {
       const field = screen.getByLabelText(/items printed/i) as HTMLInputElement;
       await user.clear(field);
       await user.type(field, '-3');
+      await user.click(screen.getByRole('button', { name: /save/i }));
 
-      expect(Number(field.value)).toBeGreaterThanOrEqual(0);
+      expect(field.value).toBe('0');
+      await waitFor(() => expect(seen.body?.quantity).toBe(0));
     });
   });
 

+ 151 - 0
frontend/src/__tests__/components/NumberInput.test.tsx

@@ -0,0 +1,151 @@
+/**
+ * NumberInput keeps what was typed as a draft, so a number field can be
+ * cleared and retyped (#3182). Clamping inside onChange used to snap an
+ * emptied field straight back to its minimum.
+ */
+import { useState } from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { NumberInput } from '../../components/NumberInput';
+
+function Harness({
+  initial,
+  onCommit,
+  ...props
+}: {
+  initial: number;
+  onCommit?: (v: number) => void;
+  min?: number;
+  max?: number;
+  fallback?: number;
+  integer?: boolean;
+}) {
+  const [value, setValue] = useState(initial);
+  return (
+    <>
+      <NumberInput
+        aria-label="qty"
+        value={value}
+        onChange={(v) => {
+          setValue(v);
+          onCommit?.(v);
+        }}
+        {...props}
+      />
+      <output data-testid="committed">{value}</output>
+    </>
+  );
+}
+
+const input = () => screen.getByLabelText('qty') as HTMLInputElement;
+const committed = () => screen.getByTestId('committed').textContent;
+
+describe('NumberInput', () => {
+  it('can be emptied and retyped without snapping back to the minimum', async () => {
+    const user = userEvent.setup();
+    render(<Harness initial={1} min={1} max={999} fallback={1} />);
+
+    await user.click(input());
+    await user.keyboard('{Backspace}');
+    expect(input().value).toBe('');
+    expect(committed()).toBe('1');
+
+    await user.keyboard('6');
+    expect(input().value).toBe('6');
+    expect(committed()).toBe('6');
+  });
+
+  it('passes nothing up while the draft is below the minimum, then the finished number', async () => {
+    const user = userEvent.setup();
+    const onCommit = vi.fn();
+    render(<Harness initial={45} min={45} max={90} onCommit={onCommit} />);
+
+    await user.clear(input());
+    await user.keyboard('6');
+    expect(input().value).toBe('6');
+    expect(onCommit).not.toHaveBeenCalled();
+
+    await user.keyboard('0');
+    expect(input().value).toBe('60');
+    expect(onCommit).toHaveBeenCalledTimes(1);
+    expect(onCommit).toHaveBeenLastCalledWith(60);
+  });
+
+  it('clamps an out-of-range draft into range on blur', async () => {
+    const user = userEvent.setup();
+    render(<Harness initial={50} min={45} max={90} />);
+
+    await user.clear(input());
+    await user.keyboard('120');
+    await user.tab();
+    expect(input().value).toBe('90');
+    expect(committed()).toBe('90');
+
+    await user.clear(input());
+    await user.keyboard('3');
+    await user.tab();
+    expect(input().value).toBe('45');
+    expect(committed()).toBe('45');
+  });
+
+  it('commits the fallback when left empty', async () => {
+    const user = userEvent.setup();
+    render(<Harness initial={30} min={1} max={365} fallback={7} />);
+
+    await user.clear(input());
+    await user.tab();
+    expect(input().value).toBe('7');
+    expect(committed()).toBe('7');
+  });
+
+  it('reverts to the current value when left empty without a fallback', async () => {
+    const user = userEvent.setup();
+    const onCommit = vi.fn();
+    render(<Harness initial={40} min={30} max={65} onCommit={onCommit} />);
+
+    await user.clear(input());
+    await user.tab();
+    expect(input().value).toBe('40');
+    expect(onCommit).not.toHaveBeenCalled();
+  });
+
+  it('accepts 0 where the range allows it', async () => {
+    const user = userEvent.setup();
+    render(<Harness initial={10} min={0} max={120} fallback={10} />);
+
+    await user.clear(input());
+    await user.keyboard('0');
+    await user.tab();
+    expect(input().value).toBe('0');
+    expect(committed()).toBe('0');
+  });
+
+  it('keeps fractions when integer is false', async () => {
+    const user = userEvent.setup();
+    render(<Harness initial={28} min={0} max={60} integer={false} />);
+
+    await user.clear(input());
+    await user.keyboard('27.5');
+    await user.tab();
+    expect(input().value).toBe('27.5');
+    expect(committed()).toBe('27.5');
+  });
+
+  it('follows a value changed from outside while not being edited', () => {
+    const { rerender } = render(<NumberInput aria-label="qty" value={3} onChange={() => {}} />);
+    expect(input().value).toBe('3');
+    rerender(<NumberInput aria-label="qty" value={8} onChange={() => {}} />);
+    expect(input().value).toBe('8');
+  });
+
+  it('still calls a caller-supplied onBlur', async () => {
+    const user = userEvent.setup();
+    const onBlur = vi.fn();
+    render(<NumberInput aria-label="qty" value={3} onChange={() => {}} onBlur={onBlur} />);
+
+    await user.click(input());
+    await user.tab();
+    expect(onBlur).toHaveBeenCalledTimes(1);
+  });
+});

+ 43 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -1205,6 +1205,49 @@ describe('PrintModal', () => {
       await user.keyboard('5');
       expect(input.value).toBe('5');
     });
+
+    it('quantity can be erased and retyped instead of snapping back to 1 (#3182)', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="create"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      const input = screen.getByLabelText('Quantity') as HTMLInputElement;
+      await user.click(input);
+      await user.keyboard('{Backspace}');
+      expect(input.value).toBe('');
+
+      await user.keyboard('6');
+      expect(input.value).toBe('6');
+      expect(screen.getByText('Creates 6 queue items')).toBeInTheDocument();
+    });
+
+    it('an emptied quantity settles back to 1 when the field is left', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="create"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      const input = screen.getByLabelText('Quantity') as HTMLInputElement;
+      await user.click(input);
+      await user.keyboard('{Backspace}');
+      await user.tab();
+      expect(input.value).toBe('1');
+    });
   });
 
   describe('reprint G-code injection dispatch (#422 / auto-eject)', () => {

+ 19 - 0
frontend/src/__tests__/pages/PrintersPageDryingStartModes.test.tsx

@@ -129,6 +129,25 @@ describe('PrintersPage - drying start modes', () => {
     );
   });
 
+  it('lets the temperature be retyped instead of snapping to the 45 °C minimum (#3182)', async () => {
+    const user = userEvent.setup();
+    render(<PrintersPage />);
+    await openDryingPopover(user);
+
+    const temp = screen
+      .getAllByRole('spinbutton')
+      .find((el) => el.getAttribute('min') === '45') as HTMLInputElement;
+    await user.clear(temp);
+    await user.keyboard('6');
+    expect(temp.value).toBe('6');
+    await user.keyboard('0');
+    expect(temp.value).toBe('60');
+
+    await user.tab();
+    expect(temp.value).toBe('60');
+    expect((screen.getAllByRole('slider')[0] as HTMLInputElement).value).toBe('60');
+  });
+
   it('reveals the delay chips when After delay is selected, with 2h preselected', async () => {
     const user = userEvent.setup();
     render(<PrintersPage />);

+ 4 - 3
frontend/src/components/EditArchiveModal.tsx

@@ -9,6 +9,7 @@ import { PrintLogTable } from './PrintLogTable';
 import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
 import { assignableProjects } from '../utils/projectTree';
 import { verdictSourceKey } from '../utils/verdictSource';
+import { NumberInput } from './NumberInput';
 
 // Keys for failure reasons - translated at render time.
 // Exported so the Print Log per-row classification editor (#1687 part 4)
@@ -375,15 +376,15 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               <Hash className="w-4 h-4 inline mr-1" />
               {t('editArchive.itemsPrinted')}
             </label>
-            <input
+            <NumberInput
               id="archive-items-printed"
-              type="number"
               min={0}
               value={quantity}
               // 0 is a real answer, not an empty field: a plate that jammed and
               // came off ruined produced nothing, and the project's completed
               // count has to be able to say so (#3051).
-              onChange={(e) => setQuantity(Math.max(0, parseInt(e.target.value) || 0))}
+              onChange={setQuantity}
+              fallback={0}
               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"
               placeholder="1"
             />

+ 6 - 3
frontend/src/components/EmailSettings.tsx

@@ -9,6 +9,7 @@ import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useEffect } from 'react';
 import { useAuth } from '../contexts/AuthContext';
+import { NumberInput } from './NumberInput';
 
 const SECURITY_PORT_MAP: Record<string, number> = {
   starttls: 587,
@@ -316,10 +317,12 @@ export function EmailSettings() {
                   <label className="block text-sm font-medium text-white mb-1">
                     {t('settings.email.smtpPort') || 'SMTP Port'}
                   </label>
-                  <input
-                    type="number"
+                  <NumberInput
                     value={smtpSettings.smtp_port}
-                    onChange={(e) => handlePortChange(parseInt(e.target.value) || 587)}
+                    onChange={handlePortChange}
+                    min={1}
+                    max={65535}
+                    fallback={587}
                     placeholder="587"
                     className={inputClasses}
                   />

+ 4 - 3
frontend/src/components/FailureDetectionSettings.tsx

@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { useToast } from '../contexts/ToastContext';
+import { NumberInput } from './NumberInput';
 
 type TestResult = { ok: boolean; message: string } | null;
 
@@ -251,10 +252,10 @@ export function FailureDetectionSettings() {
               <label className="block text-sm text-bambu-gray mb-1">
                 {t('failureDetection.pollInterval')}
               </label>
-              <input
-                type="number"
+              <NumberInput
                 value={pollInterval}
-                onChange={(e) => setPollInterval(Math.max(5, Math.min(120, Number(e.target.value) || 10)))}
+                onChange={setPollInterval}
+                fallback={10}
                 min={5}
                 max={120}
                 disabled={!enabled}

+ 4 - 4
frontend/src/components/GitHubBackupSettings.tsx

@@ -44,6 +44,7 @@ import { ConfirmModal } from './ConfirmModal';
 import { GitHubRestoreModal } from './GitHubRestoreModal';
 import { useToast } from '../contexts/ToastContext';
 import { formatRelativeTime, parseUTCDate } from '../utils/date';
+import { NumberInput } from './NumberInput';
 
 function formatDateTime(dateStr: string | null): string {
   if (!dateStr) return '-';
@@ -1288,14 +1289,12 @@ export function GitHubBackupSettings() {
                   )}
                   <div>
                     <label className="block text-sm text-bambu-gray mb-1">{t('backup.retention')}</label>
-                    <input
-                      type="number"
+                    <NumberInput
                       min={1}
                       max={100}
                       value={localBackupStatus?.retention ?? 5}
                       className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                      onChange={async (e) => {
-                        const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));
+                      onChange={async (val) => {
                         try {
                           await api.updateSettings({ local_backup_retention: val });
                           showToast(t('backup.settingsSaved'));
@@ -1304,6 +1303,7 @@ export function GitHubBackupSettings() {
                         }
                         refetchLocalStatus();
                       }}
+                      fallback={5}
                     />
                     <p className="text-xs text-bambu-gray-light mt-1">{t('backup.retentionDescription')}</p>
                   </div>

+ 66 - 0
frontend/src/components/NumberInput.tsx

@@ -0,0 +1,66 @@
+import { useState, type InputHTMLAttributes } from 'react';
+
+type NativeInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange' | 'min' | 'max'>;
+
+interface NumberInputProps extends NativeInputProps {
+  value: number | null | undefined;
+  onChange: (value: number) => void;
+  min?: number;
+  max?: number;
+  /** Parse with parseInt (default); pass false for fields with a fractional step. */
+  integer?: boolean;
+  /** Committed when the field is left empty. Without it an empty field reverts to `value`. */
+  fallback?: number;
+}
+
+/**
+ * A number field that can be cleared and retyped (#3182).
+ *
+ * Clamping inside onChange rewrites the field on every keystroke: clearing a
+ * "1" snaps straight back to 1, and typing the "6" of 60 into a field with a
+ * minimum of 45 turns it into 45. This keeps what was typed as a draft while
+ * the field is being edited, passes a value up only once the draft is a number
+ * inside [min, max], and settles the field on blur — clamped into range, or
+ * `fallback` when left empty.
+ */
+export function NumberInput({
+  value,
+  onChange,
+  min,
+  max,
+  integer = true,
+  fallback,
+  onBlur,
+  ...rest
+}: NumberInputProps) {
+  const [draft, setDraft] = useState<string | null>(null);
+
+  const parse = (raw: string) => (integer ? parseInt(raw, 10) : parseFloat(raw));
+  const inRange = (n: number) => (min === undefined || n >= min) && (max === undefined || n <= max);
+  const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));
+
+  return (
+    <input
+      {...rest}
+      type="number"
+      min={min}
+      max={max}
+      value={draft ?? value ?? ''}
+      onChange={(e) => {
+        const raw = e.target.value;
+        setDraft(raw);
+        const n = parse(raw);
+        if (!Number.isNaN(n) && inRange(n) && n !== value) onChange(n);
+      }}
+      onBlur={(e) => {
+        if (draft !== null) {
+          const n = parse(draft);
+          const settled = Number.isNaN(n) ? fallback : n;
+          if (settled !== undefined && clamp(settled) !== value) onChange(clamp(settled));
+          setDraft(null);
+        }
+        onBlur?.(e);
+      }}
+    />
+  );
+}

+ 4 - 5
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -4,6 +4,7 @@ import type { PlateSelectorProps } from './types';
 import { formatDuration } from '../../utils/date';
 import { withMediaToken } from '../../api/client';
 import { getBedTypeInfo } from '../../utils/bedType';
+import { NumberInput } from '../NumberInput';
 
 /**
  * Plate selection grid for multi-plate 3MF files.
@@ -130,17 +131,15 @@ export function PlateSelector({
             {showQuantities && isSelected && (
               <div className="flex items-center gap-1 flex-shrink-0">
                 <span className="text-xs text-bambu-gray" aria-hidden="true">×</span>
-                <input
-                  type="number"
+                <NumberInput
                   min={1}
                   max={999}
                   value={quantities[plate.index] ?? 1}
                   aria-label={t('queue.plateQuantityLabel', {
                     plate: plate.name || t('queue.plateNumber', { index: plate.index }),
                   })}
-                  onChange={(e) =>
-                    onQuantityChange(plate.index, Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))
-                  }
+                  onChange={(v) => onQuantityChange(plate.index, v)}
+                  fallback={1}
                   className="w-14 px-1.5 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
                 />
               </div>

+ 7 - 6
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -13,6 +13,7 @@ import {
   type DateFormat,
   type TimeFormat,
 } from '../../utils/date';
+import { NumberInput } from '../NumberInput';
 
 /**
  * Schedule options component for queue items.
@@ -312,23 +313,23 @@ export function ScheduleOptionsPanel({
               <div className="flex gap-3">
                 <div className="flex-1">
                   <label className="block text-xs text-bambu-gray mb-1">{t('printModal.staggerGroupSize', 'Group size')}</label>
-                  <input
-                    type="number"
+                  <NumberInput
                     min={1}
                     max={printerCount}
                     value={options.staggerGroupSize}
-                    onChange={(e) => onChange({ ...options, staggerGroupSize: Math.max(1, parseInt(e.target.value) || 1) })}
+                    onChange={(v) => onChange({ ...options, staggerGroupSize: v })}
+                    fallback={1}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
                   />
                 </div>
                 <div className="flex-1">
                   <label className="block text-xs text-bambu-gray mb-1">{t('printModal.staggerInterval', 'Interval (min)')}</label>
-                  <input
-                    type="number"
+                  <NumberInput
                     min={1}
                     max={60}
                     value={options.staggerIntervalMinutes}
-                    onChange={(e) => onChange({ ...options, staggerIntervalMinutes: Math.max(1, parseInt(e.target.value) || 1) })}
+                    onChange={(v) => onChange({ ...options, staggerIntervalMinutes: v })}
+                    fallback={1}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
                   />
                 </div>

+ 4 - 3
frontend/src/components/PrintModal/index.tsx

@@ -40,6 +40,7 @@ import type {
   ScheduleType,
 } from './types';
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
+import { NumberInput } from '../NumberInput';
 
 /** Same filament: type ignoring case, colour as RRGGBB ignoring `#`, case and alpha. */
 function isSameFilament(a: { type: string; color: string }, b: { type: string; color: string }): boolean {
@@ -1951,13 +1952,13 @@ export function PrintModal({
                 <label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
                   {t('queue.quantity', 'Quantity')}
                 </label>
-                <input
+                <NumberInput
                   id="printQuantity"
-                  type="number"
                   min={1}
                   max={999}
                   value={quantity}
-                  onChange={(e) => setQuantity(Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))}
+                  onChange={setQuantity}
+                  fallback={1}
                   className="w-20 px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white focus:outline-none focus:ring-1 focus:ring-bambu-green"
                 />
                 {quantity > 1 && (

+ 16 - 15
frontend/src/components/SmartPlugCard.tsx

@@ -8,6 +8,7 @@ import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
+import { NumberInput } from './NumberInput';
 
 interface SmartPlugCardProps {
   plug: SmartPlug;
@@ -447,24 +448,24 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   {plug.off_delay_mode === 'time' ? (
                     <div>
                       <label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayMinutes')}</label>
-                      <input
-                        type="number"
-                        min="1"
-                        max="60"
+                      <NumberInput
+                        min={1}
+                        max={60}
                         value={plug.off_delay_minutes}
-                        onChange={(e) => updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })}
+                        onChange={(v) => updateMutation.mutate({ off_delay_minutes: v })}
+                        fallback={5}
                         className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
                       />
                     </div>
                   ) : (
                     <div>
                       <label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.tempThreshold')}</label>
-                      <input
-                        type="number"
-                        min="30"
-                        max="100"
+                      <NumberInput
+                        min={30}
+                        max={100}
                         value={plug.off_temp_threshold}
-                        onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}
+                        onChange={(v) => updateMutation.mutate({ off_temp_threshold: v })}
+                        fallback={70}
                         className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
                       />
                       <p className="text-xs text-bambu-gray mt-1">{t('smartPlugs.tempThresholdDescription')}</p>
@@ -498,12 +499,12 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
               {plug.auto_off_after_drying && (
                 <div className="pl-4 border-l-2 border-bambu-dark-tertiary">
                   <label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayAfterDryingMinutes')}</label>
-                  <input
-                    type="number"
-                    min="0"
-                    max="120"
+                  <NumberInput
+                    min={0}
+                    max={120}
                     value={plug.off_delay_after_drying_minutes}
-                    onChange={(e) => updateMutation.mutate({ off_delay_after_drying_minutes: parseInt(e.target.value) || 10 })}
+                    onChange={(v) => updateMutation.mutate({ off_delay_after_drying_minutes: v })}
+                    fallback={10}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
                   />
                 </div>

+ 4 - 3
frontend/src/components/StreamOverlayBuilder.tsx

@@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next';
 import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
 import { api, type Printer } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
+import { NumberInput } from './NumberInput';
 
 type OverlaySize = 'small' | 'medium' | 'large';
 
@@ -165,13 +166,13 @@ export function StreamOverlayBuilder() {
           <label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
             {t('streamOverlay.builder.fps', 'Frame rate')}
           </label>
-          <input
+          <NumberInput
             id="overlay-builder-fps"
-            type="number"
             min={1}
             max={30}
             value={fps}
-            onChange={(e) => setFps(Math.min(Math.max(Number(e.target.value) || 1, 1), 30))}
+            onChange={setFps}
+            fallback={1}
             className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
           />
           <p className="text-xs text-bambu-gray mt-1">

+ 4 - 6
frontend/src/components/spool-form/FilamentSection.tsx

@@ -5,6 +5,7 @@ import type { FilamentSectionProps, FilamentOption } from './types';
 import { KNOWN_VARIANTS } from './constants';
 import { parsePresetName } from './utils';
 import { PresetSourceBadge } from './PresetPicker';
+import { NumberInput } from '../NumberInput';
 
 // The identity fields a slicer preset can auto-fill.
 type PresetFilledField = 'material' | 'brand' | 'subtype';
@@ -521,16 +522,13 @@ export function FilamentSection({
       {quickAdd && (
         <div>
           <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
-          <input
-            type="number"
+          <NumberInput
             className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
             value={quantity}
             min={1}
             max={100}
-            onChange={(e) => {
-              const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
-              onQuantityChange(val);
-            }}
+            onChange={onQuantityChange}
+            fallback={1}
           />
         </div>
       )}

+ 7 - 6
frontend/src/pages/PrintersPage.tsx

@@ -200,6 +200,7 @@ import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
 import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
+import { NumberInput } from '../components/NumberInput';
 
 // The status filter's options, and the only values it may hold. One list so a
 // saved filter cannot be validated against a set the dropdown has since moved
@@ -7507,12 +7508,12 @@ function PrinterCard({
                   <div className="flex items-center justify-between mb-1">
                     <label className="text-[length:var(--pc-t10,10px)] text-white/70 font-medium">{t('printers.drying.temperature')}</label>
                     <div className="flex items-center gap-1">
-                      <input
-                        type="number"
+                      <NumberInput
                         min={45}
                         max={maxTemp}
                         value={dryingTemp}
-                        onChange={e => setDryingTemp(Math.min(maxTemp, Math.max(45, Number(e.target.value) || 45)))}
+                        onChange={setDryingTemp}
+                        fallback={45}
                         className="w-12 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[length:var(--pc-t11,11px)] text-center focus:outline-none focus:border-bambu-green [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
                       />
                       <span className="text-[length:var(--pc-t10,10px)] text-bambu-gray">°C</span>
@@ -7536,12 +7537,12 @@ function PrinterCard({
                   <div className="flex items-center justify-between mb-1">
                     <label className="text-[length:var(--pc-t10,10px)] text-white/70 font-medium">{t('printers.drying.duration')}</label>
                     <div className="flex items-center gap-1">
-                      <input
-                        type="number"
+                      <NumberInput
                         min={1}
                         max={24}
                         value={dryingDuration}
-                        onChange={e => setDryingDuration(Math.min(24, Math.max(1, Number(e.target.value) || 1)))}
+                        onChange={setDryingDuration}
+                        fallback={1}
                         className="w-10 px-1 py-0.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-[length:var(--pc-t11,11px)] text-center focus:outline-none focus:border-bambu-green [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
                       />
                       <span className="text-[length:var(--pc-t10,10px)] text-bambu-gray">{t('printers.drying.hours')}</span>

+ 9 - 8
frontend/src/pages/ProjectDetailPage.tsx

@@ -48,6 +48,7 @@ import { PrintModal } from '../components/PrintModal';
 import { ProjectModal } from './ProjectsPage';
 import { getCurrencySymbol } from '../utils/currency';
 import { isSlicedLibraryFile } from '../utils/libraryFiles';
+import { NumberInput } from '../components/NumberInput';
 
 function formatFilament(grams: number): string {
   if (grams >= 1000) {
@@ -1205,12 +1206,12 @@ export function ProjectDetailPage() {
                   autoFocus
                 />
                 <div className="flex gap-2">
-                  <input
-                    type="number"
+                  <NumberInput
                     value={newBomQty}
-                    onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
+                    onChange={setNewBomQty}
+                    fallback={1}
                     className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
-                    min="1"
+                    min={1}
                     placeholder={t('projectDetail.bom.qty')}
                   />
                   <input
@@ -1280,12 +1281,12 @@ export function ProjectDetailPage() {
                           autoFocus
                         />
                         <div className="flex gap-2">
-                          <input
-                            type="number"
+                          <NumberInput
                             value={editBomQty}
-                            onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
+                            onChange={setEditBomQty}
+                            fallback={1}
                             className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
-                            min="1"
+                            min={1}
                             placeholder={t('projectDetail.bom.qty')}
                           />
                           <input

+ 78 - 76
frontend/src/pages/SettingsPage.tsx

@@ -64,6 +64,7 @@ import { Gauge, Link2, Palette } from 'lucide-react';
 import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
 import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
+import { NumberInput } from '../components/NumberInput';
 
 const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
@@ -2415,14 +2416,13 @@ export function SettingsPage() {
                   <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">
                     {getCurrencySymbol(localSettings.currency)}
                   </span>
-                  <input
-                    type="number"
+                  <NumberInput
                     step="0.01"
-                    min="0"
+                    min={0}
                     value={localSettings.default_filament_cost}
-                    onChange={(e) =>
-                      updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
-                    }
+                    onChange={(v) => updateSetting('default_filament_cost', v)}
+                    integer={false}
+                    fallback={0}
                     style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}
                     className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
@@ -2436,14 +2436,13 @@ export function SettingsPage() {
                   <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">
                     {getCurrencySymbol(localSettings.currency)}
                   </span>
-                  <input
-                    type="number"
+                  <NumberInput
                     step="0.001"
-                    min="0"
+                    min={0}
                     value={localSettings.energy_cost_per_kwh}
-                    onChange={(e) =>
-                      updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
-                    }
+                    onChange={(v) => updateSetting('energy_cost_per_kwh', v)}
+                    integer={false}
+                    fallback={0}
                     style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}
                     className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
@@ -2649,13 +2648,14 @@ export function SettingsPage() {
                   {t('settings.lowDiskSpaceWarning')}
                 </label>
                 <div className="flex items-center gap-2">
-                  <input
-                    type="number"
-                    min="0.5"
-                    max="100"
+                  <NumberInput
+                    min={0.5}
+                    max={100}
                     step="0.5"
                     value={localSettings.library_disk_warning_gb ?? 5}
-                    onChange={(e) => updateSetting('library_disk_warning_gb', parseFloat(e.target.value) || 5)}
+                    onChange={(v) => updateSetting('library_disk_warning_gb', v)}
+                    integer={false}
+                    fallback={5}
                     className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
                   <span className="text-bambu-gray">GB</span>
@@ -3452,12 +3452,12 @@ export function SettingsPage() {
                       <label className="block text-sm text-bambu-gray mb-1">
                         {t('settings.port')}
                       </label>
-                      <input
-                        type="number"
-                        min="1"
-                        max="65535"
+                      <NumberInput
+                        min={1}
+                        max={65535}
                         value={localSettings.mqtt_port ?? 1883}
-                        onChange={(e) => updateSetting('mqtt_port', Math.min(65535, Math.max(1, parseInt(e.target.value) || 1883)))}
+                        onChange={(v) => updateSetting('mqtt_port', v)}
+                        fallback={1883}
                         className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                       />
                     </div>
@@ -5182,12 +5182,12 @@ export function SettingsPage() {
                   <label className="block text-xs text-bambu-gray mb-1">
                     {t('settings.staggerGroupSize', 'Group size')}
                   </label>
-                  <input
-                    type="number"
+                  <NumberInput
                     min={1}
                     max={50}
                     value={localSettings.stagger_group_size ?? 2}
-                    onChange={(e) => updateSetting('stagger_group_size', Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))}
+                    onChange={(v) => updateSetting('stagger_group_size', v)}
+                    fallback={1}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
                   />
                   <p className="text-xs text-bambu-gray mt-1">
@@ -5198,12 +5198,12 @@ export function SettingsPage() {
                   <label className="block text-xs text-bambu-gray mb-1">
                     {t('settings.staggerInterval', 'Interval (minutes)')}
                   </label>
-                  <input
-                    type="number"
+                  <NumberInput
                     min={1}
                     max={60}
                     value={localSettings.stagger_interval_minutes ?? 5}
-                    onChange={(e) => updateSetting('stagger_interval_minutes', Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))}
+                    onChange={(v) => updateSetting('stagger_interval_minutes', v)}
+                    fallback={1}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
                   />
                   <p className="text-xs text-bambu-gray mt-1">
@@ -5230,12 +5230,12 @@ export function SettingsPage() {
                 <label className="block text-xs text-bambu-gray mb-1">
                   {t('settings.concurrentUploadsLabel', 'Printers uploaded to at once')}
                 </label>
-                <input
-                  type="number"
+                <NumberInput
                   min={1}
                   max={16}
                   value={localSettings.queue_max_concurrent_uploads ?? 4}
-                  onChange={(e) => updateSetting('queue_max_concurrent_uploads', Math.max(1, Math.min(16, parseInt(e.target.value) || 1)))}
+                  onChange={(v) => updateSetting('queue_max_concurrent_uploads', v)}
+                  fallback={1}
                   className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
                 />
                 <p className="text-xs text-bambu-gray mt-1">
@@ -5281,12 +5281,12 @@ export function SettingsPage() {
                   <label className="block text-xs text-bambu-gray mb-1">
                     {t('settings.preheatMaxWait', 'Max wait (seconds)')}
                   </label>
-                  <input
-                    type="number"
+                  <NumberInput
                     min={60}
                     max={3600}
                     value={localSettings.preheat_max_wait_seconds ?? 900}
-                    onChange={(e) => updateSetting('preheat_max_wait_seconds', Math.max(60, Math.min(3600, parseInt(e.target.value) || 900)))}
+                    onChange={(v) => updateSetting('preheat_max_wait_seconds', v)}
+                    fallback={900}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
                     disabled={!(localSettings.preheat_enabled ?? false)}
                   />
@@ -5298,12 +5298,12 @@ export function SettingsPage() {
                   <label className="block text-xs text-bambu-gray mb-1">
                     {t('settings.preheatSoak', 'Soak (seconds)')}
                   </label>
-                  <input
-                    type="number"
+                  <NumberInput
                     min={0}
                     max={1800}
                     value={localSettings.preheat_soak_seconds ?? 300}
-                    onChange={(e) => updateSetting('preheat_soak_seconds', Math.max(0, Math.min(1800, parseInt(e.target.value) || 0)))}
+                    onChange={(v) => updateSetting('preheat_soak_seconds', v)}
+                    fallback={0}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
                     disabled={!(localSettings.preheat_enabled ?? false)}
                   />
@@ -5338,12 +5338,12 @@ export function SettingsPage() {
                   <label className="block text-xs text-bambu-gray mb-1">
                     {t('settings.keepWarmBedTemp', 'Keep-warm bed temperature (°C)')}
                   </label>
-                  <input
-                    type="number"
+                  <NumberInput
                     min={40}
                     max={110}
                     value={localSettings.queue_keep_warm_bed_temp ?? 90}
-                    onChange={(e) => updateSetting('queue_keep_warm_bed_temp', Math.max(40, Math.min(110, parseInt(e.target.value) || 90)))}
+                    onChange={(v) => updateSetting('queue_keep_warm_bed_temp', v)}
+                    fallback={90}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
                     disabled={!(localSettings.preheat_enabled ?? false)}
                   />
@@ -5355,12 +5355,12 @@ export function SettingsPage() {
                   <label className="block text-xs text-bambu-gray mb-1">
                     {t('settings.keepWarmMaxMinutes', 'Stop keeping warm after (minutes)')}
                   </label>
-                  <input
-                    type="number"
+                  <NumberInput
                     min={5}
                     max={480}
                     value={localSettings.queue_keep_warm_max_minutes ?? 120}
-                    onChange={(e) => updateSetting('queue_keep_warm_max_minutes', Math.max(5, Math.min(480, parseInt(e.target.value) || 120)))}
+                    onChange={(v) => updateSetting('queue_keep_warm_max_minutes', v)}
+                    fallback={120}
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
                     disabled={!(localSettings.queue_keep_bed_warm ?? false) || !(localSettings.preheat_enabled ?? false) || !(localSettings.require_plate_clear ?? false)}
                   />
@@ -5821,8 +5821,8 @@ export function SettingsPage() {
                             <td className="py-1.5 pr-2 text-white font-medium">{fil}</td>
                             <td className="py-1 px-1">
                               <div className="flex items-center justify-end gap-1">
-                                <input type="number" min={30} max={65} value={preset.n3f}
-                                  onChange={e => updatePreset(fil, 'n3f', Math.max(1, parseInt(e.target.value) || 0))}
+                                <NumberInput min={30} max={65} value={preset.n3f}
+                                  onChange={v => updatePreset(fil, 'n3f', v)}
                                   className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
                                 />
                                 <span className="text-bambu-gray">°C</span>
@@ -5830,8 +5830,8 @@ export function SettingsPage() {
                             </td>
                             <td className="py-1 px-1">
                               <div className="flex items-center gap-1">
-                                <input type="number" min={1} max={24} value={preset.n3f_hours}
-                                  onChange={e => updatePreset(fil, 'n3f_hours', Math.max(1, parseInt(e.target.value) || 0))}
+                                <NumberInput min={1} max={24} value={preset.n3f_hours}
+                                  onChange={v => updatePreset(fil, 'n3f_hours', v)}
                                   className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
                                 />
                                 <span className="text-bambu-gray">h</span>
@@ -5839,8 +5839,8 @@ export function SettingsPage() {
                             </td>
                             <td className="py-1 px-1">
                               <div className="flex items-center justify-end gap-1">
-                                <input type="number" min={30} max={85} value={preset.n3s}
-                                  onChange={e => updatePreset(fil, 'n3s', Math.max(1, parseInt(e.target.value) || 0))}
+                                <NumberInput min={30} max={85} value={preset.n3s}
+                                  onChange={v => updatePreset(fil, 'n3s', v)}
                                   className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
                                 />
                                 <span className="text-bambu-gray">°C</span>
@@ -5848,8 +5848,8 @@ export function SettingsPage() {
                             </td>
                             <td className="py-1 px-1">
                               <div className="flex items-center gap-1">
-                                <input type="number" min={1} max={24} value={preset.n3s_hours}
-                                  onChange={e => updatePreset(fil, 'n3s_hours', Math.max(1, parseInt(e.target.value) || 0))}
+                                <NumberInput min={1} max={24} value={preset.n3s_hours}
+                                  onChange={v => updatePreset(fil, 'n3s_hours', v)}
                                   className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
                                 />
                                 <span className="text-bambu-gray">h</span>
@@ -6178,12 +6178,12 @@ export function SettingsPage() {
                         {t('settings.goodGreen')} ≤
                       </label>
                       <div className="flex items-center gap-2">
-                        <input
-                          type="number"
-                          min="0"
-                          max="100"
+                        <NumberInput
+                          min={0}
+                          max={100}
                           value={localSettings.ams_humidity_good ?? 40}
-                          onChange={(e) => updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)}
+                          onChange={(v) => updateSetting('ams_humidity_good', v)}
+                          fallback={40}
                           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"
                         />
                         <span className="text-bambu-gray">%</span>
@@ -6194,12 +6194,12 @@ export function SettingsPage() {
                         {t('settings.fairOrange')} ≤
                       </label>
                       <div className="flex items-center gap-2">
-                        <input
-                          type="number"
-                          min="0"
-                          max="100"
+                        <NumberInput
+                          min={0}
+                          max={100}
                           value={localSettings.ams_humidity_fair ?? 60}
-                          onChange={(e) => updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)}
+                          onChange={(v) => updateSetting('ams_humidity_fair', v)}
+                          fallback={60}
                           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"
                         />
                         <span className="text-bambu-gray">%</span>
@@ -6237,13 +6237,14 @@ export function SettingsPage() {
                         {t('settings.goodBlue')} ≤
                       </label>
                       <div className="flex items-center gap-2">
-                        <input
-                          type="number"
+                        <NumberInput
                           step="0.5"
-                          min="0"
-                          max="60"
+                          min={0}
+                          max={60}
                           value={localSettings.ams_temp_good ?? 28}
-                          onChange={(e) => updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)}
+                          onChange={(v) => updateSetting('ams_temp_good', v)}
+                          integer={false}
+                          fallback={28}
                           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"
                         />
                         <span className="text-bambu-gray">°C</span>
@@ -6254,13 +6255,14 @@ export function SettingsPage() {
                         {t('settings.fairOrange')} ≤
                       </label>
                       <div className="flex items-center gap-2">
-                        <input
-                          type="number"
+                        <NumberInput
                           step="0.5"
-                          min="0"
-                          max="60"
+                          min={0}
+                          max={60}
                           value={localSettings.ams_temp_fair ?? 35}
-                          onChange={(e) => updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)}
+                          onChange={(v) => updateSetting('ams_temp_fair', v)}
+                          integer={false}
+                          fallback={35}
                           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"
                         />
                         <span className="text-bambu-gray">°C</span>
@@ -6324,12 +6326,12 @@ export function SettingsPage() {
                       {t('settings.keepSensorHistory')}
                     </label>
                     <div className="flex items-center gap-2">
-                      <input
-                        type="number"
-                        min="1"
-                        max="365"
+                      <NumberInput
+                        min={1}
+                        max={365}
                         value={localSettings.ams_history_retention_days ?? 30}
-                        onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}
+                        onChange={(v) => updateSetting('ams_history_retention_days', v)}
+                        fallback={30}
                         className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                       />
                       <span className="text-bambu-gray">{t('common.days')}</span>

+ 4 - 3
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -53,6 +53,7 @@ import {
   withCurrentValue,
 } from '../../components/spool-form/utils';
 import { MATERIALS } from '../../components/spool-form/constants';
+import { NumberInput } from '../../components/NumberInput';
 
 type Tab = 'existing' | 'new' | 'replace';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
@@ -906,10 +907,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
 
             <div>
               <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
-              <input
-                type="number"
+              <NumberInput
                 value={formData.label_weight}
-                onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
+                onChange={(v) => updateField('label_weight', v)}
+                fallback={0}
                 min={0}
                 max={10000}
                 className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 1
static/assets/PdfPreviewModal-yZr5_glF.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SpreadsheetPreviewModal-1B_pviLD.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 1
static/assets/index-C2T51Bsa.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/pdf-D5n9hpnM.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BPS87yt5.js"></script>
+    <script type="module" crossorigin src="/assets/index-C2T51Bsa.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-CmAe-C0m.css">
   </head>

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä