Browse Source

Merge branch '0.2.1b' into feature/brazilianPortugueseTranslation

Wesley Reuel Marques Silva 3 months ago
parent
commit
f657e4c433

+ 17 - 1
.github/workflows/ci.yml

@@ -151,7 +151,23 @@ jobs:
 
       - name: Run npm audit
         working-directory: frontend
-        run: npm audit --audit-level=high
+        run: |
+          # Only fail on fixable high/critical vulnerabilities.
+          # Unfixable issues (e.g. npm's bundled tar) are tracked via security.yml.
+          npm audit --json > /tmp/audit.json 2>/dev/null || true
+          python3 -c "
+          import json, sys
+          data = json.load(open('/tmp/audit.json'))
+          vulns = data.get('vulnerabilities', {})
+          fixable = {n: v for n, v in vulns.items()
+                     if v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
+          if fixable:
+              for name, v in fixable.items():
+                  print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
+              sys.exit(1)
+          total = sum(1 for v in vulns.values() if v.get('severity') in ('high', 'critical'))
+          print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total known')
+          "
 
   frontend-typecheck:
     name: Frontend Type Check

+ 0 - 139
BETA_TEST_PLAN.md

@@ -1,139 +0,0 @@
-# Beta Test Plan — Spool Inventory & Related Features
-
-## Prerequisites
-
-- At least one printer connected with AMS
-- At least one Bambu Lab spool (RFID) loaded in AMS
-- At least one spool without RFID tag
-- At least one empty AMS slot (or slot with non-BL filament)
-- A 3MF file ready to print (small/fast test print recommended)
-
----
-
-## 1. Filament Tracking Mode Switching
-
-**Location:** Settings > Filament
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 1.1 | Default mode | Open Settings > Filament | "Built-in Inventory" card is selected (green border), info panel shows RFID/usage/catalog bullet points |
-| 1.2 | Switch to Spoolman | Click "Spoolman" card | Spoolman config appears (URL input, Sync Mode, connection status). Built-in info panel disappears |
-| 1.3 | Switch back | Click "Built-in Inventory" card | Spoolman config disappears, built-in info panel reappears |
-| 1.4 | Persistence | Switch mode, reload page | Selected mode persists after reload |
-| 1.5 | Spoolman disabled state | Select Spoolman without URL, check Connect button | Connect button should be disabled when URL is empty |
-
----
-
-## 2. Spool Management
-
-**Location:** Inventory page (sidebar)
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 2.1 | Add spool | Click "+ Add Spool", fill material/brand/color/weight, save | Spool appears in table with correct info |
-| 2.2 | Edit spool | Click a spool row, change fields, save | Changes reflected in table |
-| 2.3 | Remaining weight | Edit spool > Additional section > adjust remaining weight | Remaining weight = label_weight - weight_used, slider/input updates correctly |
-| 2.4 | Spool catalog | Edit spool > Additional > Empty Spool Weight dropdown | Pre-defined spool weights shown, selecting one updates the field |
-| 2.5 | Color picker | Edit spool > Color section | Recent colors, brand palettes, and hex input all work |
-| 2.6 | PA profile tab | Edit spool > PA Profile tab | Matching K-profiles shown grouped by printer/nozzle (requires calibration data) |
-| 2.7 | Archive spool | Archive a spool from context menu or edit | Spool moves to "Archived" tab |
-| 2.8 | Delete spool | Delete a spool | Spool removed from all views |
-| 2.9 | Summary cards | Check top of inventory page | Total Inventory, Total Consumed, By Material, In Printer, Low Stock cards show correct values |
-| 2.10 | Filters | Try Active/Archived/All tabs, material/brand dropdowns, search | Filtering works correctly |
-| 2.11 | View modes | Toggle between Table and Card view | Both views show correct spool data |
-
----
-
-## 3. AMS Slot Assignment
-
-**Location:** Printers page, AMS hover cards
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 3.1 | Assign spool | Hover over an empty/non-BL AMS slot > click "Assign Spool" | Modal opens showing only manual (non-BL) spools |
-| 3.2 | BL spools filtered | Open assign modal | Bambu Lab spools (with RFID tags) are NOT in the list |
-| 3.3 | Assigned spools filtered | Assign spool A to slot 1, then open assign modal for slot 2 | Spool A is NOT in the list (already assigned) |
-| 3.4 | Current slot spool visible | Open assign for slot that already has a spool | The currently assigned spool IS still shown (for reassignment) |
-| 3.5 | Confirm assignment | Select spool, click "Assign Spool" | Slot shows the assigned spool info on hover |
-| 3.6 | Unassign spool | Hover over assigned slot > click "Unassign" | Spool removed from slot, slot shows default info |
-| 3.7 | BL slot — no buttons | Hover over a slot with a Bambu Lab spool (RFID) | No "Assign Spool" or "Unassign" buttons shown |
-| 3.8 | Empty slot display | Check an empty AMS slot | Shows type from AMS data or "Empty" (localized) |
-
----
-
-## 4. Auto-Unlink on BL Spool Insertion
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 4.1 | BL spool replaces manual | Assign a manual spool to a slot, then physically insert a BL spool into that slot | Assignment automatically removed, slot now shows BL spool info |
-| 4.2 | Log message | Check backend logs after 4.1 | Log shows "Auto-unlink: spool X AMSY-TZ — Bambu Lab spool detected" |
-
----
-
-## 5. Spool Tag Linking
-
-**Location:** Printers page, AMS hover cards for BL spools
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 5.1 | Link modal | Hover over BL spool slot > click "Link to Spool" (if available) | Modal opens showing only untagged spools |
-| 5.2 | Search filter | Type in search box | Spools filtered by material/brand/color |
-| 5.3 | Link spool | Click a spool in the list | Success toast, modal closes, spool now linked to tag |
-| 5.4 | Tagged spools hidden | After linking spool A, open link modal again | Spool A is no longer in the list |
-
----
-
-## 6. Usage Tracking — BL Spools (AMS Remain%)
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 6.1 | Session capture | Start a print with a BL spool | Backend log shows "Captured start remain% for printer X (N trays)" |
-| 6.2 | Completed print | Let print finish | Spool's weight_used increases by (delta% * label_weight). Check inventory page remaining weight |
-| 6.3 | Failed/aborted print | Start and cancel a print mid-way | Usage still tracked based on remain% delta at time of stop |
-| 6.4 | No double-tracking | Complete a print with BL spool | Only AMS delta tracking applies (no 3MF fallback for this spool) |
-
----
-
-## 7. Usage Tracking — Non-BL Spools (3MF Estimates)
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 7.1 | Assign and print | Assign a manual spool to AMS slot, start a print using that slot | Backend log shows "3MF fallback available" at print start |
-| 7.2 | Completed print | Let print finish | Spool's weight_used increases by the 3MF estimated used_g. Check inventory page |
-| 7.3 | Failed print scaling | Start a print, cancel at ~50% | Usage = 3MF estimate * (progress/100). E.g., 100g estimate at 50% = ~50g tracked |
-| 7.4 | Multi-slot print | Print using multiple filament slots (some BL, some manual) | BL spools tracked via remain% delta, manual spools via 3MF. No double-counting |
-| 7.5 | Slot mapping | Use a spool in AMS slot 5 (second AMS unit) | Correctly maps to AMS 1, Tray 0. Check usage history shows correct ams_id/tray_id |
-
----
-
-## 8. Edge Cases
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 8.1 | No AMS data | Print from external spool (no AMS) | No crash, usage tracking gracefully skipped |
-| 8.2 | No archive | Print without 3MF archive available | AMS delta path still works for BL spools, 3MF path skipped gracefully |
-| 8.3 | Spool refilled | If remain% goes UP between start and end (spool swapped/refilled) | Negative delta skipped, no negative usage recorded |
-| 8.4 | Rapid print start/stop | Start and immediately cancel a print | No errors, minimal or zero usage tracked |
-| 8.5 | Concurrent printers | Print on two printers simultaneously | Each printer tracked independently, no cross-contamination |
-
----
-
-## 9. UI/UX Checks
-
-| # | Test | Steps | Expected |
-|---|------|-------|----------|
-| 9.1 | Mobile layout | Open on mobile or narrow browser | Inventory page, assign modal, and settings all responsive |
-| 9.2 | Dark/light mode | Toggle theme | All inventory UI elements properly themed |
-| 9.3 | Language switching | Switch to German and Japanese | All inventory strings translated (no hardcoded English) |
-| 9.4 | Keyboard nav | Use keyboard shortcuts on printers page | AMS slot interactions accessible |
-
----
-
-## Reporting Issues
-
-When reporting a bug, please include:
-- Test case number (e.g., "Beta 9.1 failed")
-- Browser + version
-- Screenshot or screen recording
-- Backend logs (if relevant — check Docker logs)
-- Steps to reproduce if different from above

+ 6 - 0
backend/app/core/database.py

@@ -1185,6 +1185,12 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Create spool_usage_history table for filament consumption tracking
     try:
         await conn.execute(

+ 3 - 0
backend/app/models/spool.py

@@ -19,6 +19,9 @@ class Spool(Base):
     brand: Mapped[str | None] = mapped_column(String(100))  # "Polymaker"
     label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)
     core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)
+    core_weight_catalog_id: Mapped[int | None] = mapped_column(
+        Integer
+    )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer

+ 2 - 0
backend/app/schemas/spool.py

@@ -11,6 +11,7 @@ class SpoolBase(BaseModel):
     brand: str | None = None
     label_weight: int = 1000
     core_weight: int = 250
+    core_weight_catalog_id: int | None = None
     weight_used: float = 0
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
@@ -35,6 +36,7 @@ class SpoolUpdate(BaseModel):
     brand: str | None = None
     label_weight: int | None = None
     core_weight: int | None = None
+    core_weight_catalog_id: int | None = None
     weight_used: float | None = None
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None

+ 135 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -62,6 +62,7 @@ const existingSpool: InventorySpool = {
   rgba: 'FF0000FF',
   label_weight: 1000,
   core_weight: 250,
+  core_weight_catalog_id: null,
   weight_used: 300,
   slicer_filament: 'GFL99',
   slicer_filament_name: 'Generic PLA',
@@ -183,4 +184,138 @@ describe('SpoolFormModal weightTouched', () => {
     // weight_used MUST be included for new spools (default value 0)
     expect(payload).toHaveProperty('weight_used', 0);
   });
+
+  it('preserves core_weight_catalog_id when editing other fields', async () => {
+    const spoolWithCatalogId: InventorySpool = {
+      ...existingSpool,
+      core_weight_catalog_id: 5,
+    };
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithCatalogId}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Change the note field (unrelated to catalog ID)
+    const noteInputs = screen.getAllByPlaceholderText(/note/i);
+    expect(noteInputs.length).toBeGreaterThan(0);
+    fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
+
+    // Click Save
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect(spoolId).toBe(1);
+    // core_weight_catalog_id MUST be preserved when editing other fields
+    expect(payload).toHaveProperty('core_weight_catalog_id', 5);
+    // Other changes should also be present
+    expect(payload).toHaveProperty('note', 'Updated note');
+  });
+
+  it('includes core_weight_catalog_id when selecting from catalog', async () => {
+    const mockCatalog = [
+      { id: 1, name: 'Generic 250g', weight: 250 },
+      { id: 2, name: 'Bambu Lab 250g', weight: 250 },
+      { id: 3, name: 'Standard 300g', weight: 300 },
+    ];
+
+    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // Wait for catalog to load
+    await waitFor(() => {
+      expect(api.getSpoolCatalog).toHaveBeenCalled();
+    });
+
+    // Click on the empty spool weight field to open dropdown
+    const weightInputs = screen.getAllByPlaceholderText(/search/i);
+    const weightPicker = weightInputs.find(input =>
+      input.getAttribute('placeholder')?.toLowerCase().includes('spool')
+    );
+    expect(weightPicker).toBeTruthy();
+    fireEvent.focus(weightPicker!);
+
+    // Click on "Bambu Lab 250g" option
+    const bambuOption = await screen.findByText('Bambu Lab 250g');
+    fireEvent.click(bambuOption);
+
+    // Click the add spool button
+    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
+    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
+    expect(submitButton).toBeTruthy();
+    fireEvent.click(submitButton!);
+
+    await waitFor(() => {
+      expect(api.createSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
+    // Both weight AND catalog ID should be sent
+    expect(payload).toHaveProperty('core_weight', 250);
+    expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
+  });
+
+  it('displays correct catalog name when duplicates exist', async () => {
+    const spoolWithCatalogId: InventorySpool = {
+      ...existingSpool,
+      core_weight: 250,
+      core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
+    };
+
+    const mockCatalog = [
+      { id: 1, name: 'Generic 250g', weight: 250 },
+      { id: 2, name: 'Bambu Lab 250g', weight: 250 },
+      { id: 3, name: 'Standard 300g', weight: 300 },
+    ];
+
+    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithCatalogId}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Wait for catalog to load
+    await waitFor(() => {
+      expect(api.getSpoolCatalog).toHaveBeenCalled();
+    });
+
+    // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
+    await waitFor(() => {
+      const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
+      const bambuFound = weightInputs.some(input =>
+        input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
+      );
+      expect(bambuFound).toBeTruthy();
+    });
+  });
 });

+ 1 - 0
frontend/src/api/client.ts

@@ -1771,6 +1771,7 @@ export interface InventorySpool {
   brand: string | null;
   label_weight: number;
   core_weight: number;
+  core_weight_catalog_id: number | null;
   weight_used: number;
   slicer_filament: string | null;
   slicer_filament_name: string | null;

+ 2 - 0
frontend/src/components/SpoolFormModal.tsx

@@ -171,6 +171,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
           rgba: spool.rgba || '808080FF',
           label_weight: spool.label_weight || 1000,
           core_weight: spool.core_weight || 250,
+          core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
           weight_used: spool.weight_used || 0,
           slicer_filament: spool.slicer_filament || '',
           note: spool.note || '',
@@ -336,6 +337,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
       rgba: formData.rgba || null,
       label_weight: formData.label_weight,
       core_weight: formData.core_weight,
+      core_weight_catalog_id: formData.core_weight_catalog_id,
       slicer_filament: formData.slicer_filament || null,
       slicer_filament_name: presetName,
       nozzle_temp_min: null,

+ 55 - 9
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -8,15 +8,18 @@ function SpoolWeightPicker({
   catalog,
   value,
   onChange,
+  catalogId,
+  onCatalogIdChange,
 }: {
   catalog: { id: number; name: string; weight: number }[];
   value: number;
   onChange: (weight: number) => void;
+  catalogId: number | null;
+  onCatalogIdChange: (id: number | null) => void;
 }) {
   const { t } = useTranslation();
   const [isOpen, setIsOpen] = useState(false);
   const [search, setSearch] = useState('');
-  const [selectedId, setSelectedId] = useState<number | null>(null);
   const dropdownRef = useRef<HTMLDivElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
 
@@ -30,6 +33,35 @@ function SpoolWeightPicker({
     return () => document.removeEventListener('mousedown', handleClick);
   }, []);
 
+  // When value changes, auto-select if there's only one matching entry or keep selection if it still matches
+  useEffect(() => {
+    // If no catalog loaded yet, skip matching logic
+    if (catalog.length === 0) {
+      return;
+    }
+
+    const matches = catalog.filter(e => e.weight === value);
+
+    // If currently selected entry still matches the weight, keep it selected
+    if (catalogId) {
+      const selected = catalog.find(e => e.id === catalogId);
+      if (selected && selected.weight === value) {
+        return; // Keep current selection
+      }
+    }
+
+    // If exactly one match, auto-select it
+    if (matches.length === 1) {
+      onCatalogIdChange(matches[0].id);
+    } else if (matches.length === 0) {
+      // No matches, clear selection to prevent stale catalog ID
+      if (catalogId !== null) {
+        onCatalogIdChange(null);
+      }
+    }
+    // If multiple matches, don't auto-select - let user choose
+  }, [value, catalog, catalogId, onCatalogIdChange]);
+
   const filtered = useMemo(() => {
     if (!search) return catalog;
     const s = search.toLowerCase();
@@ -39,17 +71,29 @@ function SpoolWeightPicker({
     );
   }, [catalog, search]);
 
-  // Display value: show catalog name if selected, or the weight
+  // Find all entries matching the current weight
+  const matchingEntries = useMemo(() => {
+    return catalog.filter(e => e.weight === value);
+  }, [catalog, value]);
+
+  // Display value: show catalog name if selected by ID, otherwise show first match
   const displayValue = useMemo(() => {
     if (isOpen) return search;
-    if (selectedId) {
-      const entry = catalog.find(e => e.id === selectedId);
+
+    // If a catalog ID is explicitly selected, use that
+    if (catalogId) {
+      const entry = catalog.find(e => e.id === catalogId);
       if (entry) return entry.name;
     }
-    const match = catalog.find(e => e.weight === value);
-    if (match) return match.name;
+
+    // Otherwise, show the first matching entry as a suggestion
+    if (matchingEntries.length > 0) {
+      return matchingEntries[0].name;
+    }
+
+    // Leave empty if there are no matches
     return '';
-  }, [isOpen, search, selectedId, catalog, value]);
+  }, [isOpen, search, catalogId, catalog, matchingEntries]);
 
   return (
     <div>
@@ -86,12 +130,12 @@ function SpoolWeightPicker({
                     key={entry.id}
                     type="button"
                     className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
-                      (selectedId ? entry.id === selectedId : entry.weight === value)
+                      (catalogId ? entry.id === catalogId : entry.weight === value)
                         ? 'bg-bambu-green/10 text-bambu-green'
                         : 'text-white'
                     }`}
                     onClick={() => {
-                      setSelectedId(entry.id);
+                      onCatalogIdChange(entry.id);
                       onChange(entry.weight);
                       setIsOpen(false);
                       setSearch('');
@@ -150,6 +194,8 @@ export function AdditionalSection({
         catalog={spoolCatalog}
         value={formData.core_weight}
         onChange={(weight) => updateField('core_weight', weight)}
+        catalogId={formData.core_weight_catalog_id}
+        onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
       />
 
       {/* Current Weight (remaining filament) */}

+ 2 - 0
frontend/src/components/spool-form/types.ts

@@ -9,6 +9,7 @@ export interface SpoolFormData {
   rgba: string;
   label_weight: number;
   core_weight: number;
+  core_weight_catalog_id: number | null;
   weight_used: number;
   slicer_filament: string;
   note: string;
@@ -22,6 +23,7 @@ export const defaultFormData: SpoolFormData = {
   rgba: '808080FF',
   label_weight: 1000,
   core_weight: 250,
+  core_weight_catalog_id: null,
   weight_used: 0,
   slicer_filament: '',
   note: '',

+ 197 - 176
frontend/src/pages/QueuePage.tsx

@@ -361,26 +361,40 @@ function SortableQueueItem({
   const isPending = item.status === 'pending';
   const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
 
+  const isMobileSelectable = isPending && onToggleSelect;
+
   return (
     <div
       ref={setNodeRef}
       style={style}
       className={`
-        group relative bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary
-        transition-all duration-200 hover:border-bambu-dark-tertiary/80
+        group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200
         ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
         ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
+        ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
+        ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}
+        ${isMobileSelectable ? 'sm:cursor-default' : ''}
       `}
+      onClick={isMobileSelectable ? () => {
+        if (window.innerWidth < 640) onToggleSelect();
+      } : undefined}
     >
-      <div className="flex items-center gap-4 p-4">
-        {/* Selection checkbox for pending items */}
+      {/* Mobile selected left accent bar */}
+      {isMobileSelectable && isSelected && (
+        <div className="sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green" />
+      )}
+
+      <div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
+        {/* Mobile selection indicator — left accent bar only, no tick */}
+
+        {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
         {isPending && onToggleSelect && (
           <button
             onClick={(e) => {
               e.stopPropagation();
               onToggleSelect();
             }}
-            className={`flex items-center justify-center w-6 h-6 rounded border transition-colors ${
+            className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
               isSelected
                 ? 'bg-bambu-green border-bambu-green text-white'
                 : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
@@ -390,25 +404,25 @@ function SortableQueueItem({
           </button>
         )}
 
-        {/* Drag handle or position number */}
+        {/* Drag handle or position number - hidden on mobile */}
         {isPending ? (
           <div
             {...attributes}
             {...listeners}
-            className="flex items-center justify-center w-10 h-10 md:w-8 md:h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation"
+            className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0"
           >
-            <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
+            <GripVertical className="w-4 h-4 text-bambu-gray" />
           </div>
         ) : position !== undefined ? (
-          <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
+          <div className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium shrink-0">
             #{position}
           </div>
         ) : (
-          <div className="w-8" />
+          <div className="hidden sm:block w-8 shrink-0" />
         )}
 
         {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
-        <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
+        <div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
           {item.archive_thumbnail ? (
             <img
               src={
@@ -431,7 +445,7 @@ function SortableQueueItem({
             />
           ) : (
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-              <Layers className="w-6 h-6" />
+              <Layers className="w-5 h-5 sm:w-6 sm:h-6" />
             </div>
           )}
         </div>
@@ -439,7 +453,7 @@ function SortableQueueItem({
         {/* Info */}
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2 mb-1">
-            <p className="text-white font-medium truncate">
+            <p className="text-sm sm:text-base text-white font-medium truncate">
               {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
               {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}
             </p>
@@ -462,57 +476,59 @@ function SortableQueueItem({
             ) : null}
           </div>
 
-          <div className="flex items-center gap-3 text-sm text-bambu-gray">
-            <span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
-              <Printer className="w-3.5 h-3.5" />
+          <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
+            <span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
+              <Printer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
+              <span className="truncate max-w-[120px] sm:max-w-none">
               {item.target_model
                 ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
                 : item.printer_id === null
                   ? t('queue.filter.unassigned')
                   : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
+              </span>
             </span>
             {item.print_time_seconds && (
-              <span className="flex items-center gap-1.5">
-                <Timer className="w-3.5 h-3.5" />
+              <span className="flex items-center gap-1 sm:gap-1.5">
+                <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
                 {formatDuration(item.print_time_seconds)}
               </span>
             )}
             {item.filament_used_grams && (
-              <span className="flex items-center gap-1.5">
-                <Weight className="w-3.5 h-3.5" />
+              <span className="flex items-center gap-1 sm:gap-1.5">
+                <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
                 {formatWeight(item.filament_used_grams)}
               </span>
             )}
             {item.created_by_username && (
-              <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
+              <span className="hidden sm:flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
                 <User className="w-3.5 h-3.5" />
                 {item.created_by_username}
               </span>
             )}
             {isPending && !item.manual_start && (
-              <span className="flex items-center gap-1.5">
-                <Clock className="w-3.5 h-3.5" />
+              <span className="flex items-center gap-1 sm:gap-1.5">
+                <Clock className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
                 {formatRelativeTime(item.scheduled_time, timeFormat, t)}
               </span>
             )}
           </div>
 
           {/* Options badges */}
-          <div className="flex items-center gap-2 mt-2">
+          <div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2">
             {item.manual_start && (
-              <span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
-                <Hand className="w-3 h-3" />
+              <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
+                <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
                 {t('queue.badges.staged')}
               </span>
             )}
             {item.require_previous_success && (
-              <span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
+              <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
                 {t('queue.badges.requiresPrevious')}
               </span>
             )}
             {item.auto_off_after && (
-              <span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
-                <Power className="w-3 h-3" />
+              <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
+                <Power className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
                 {t('queue.badges.autoPowerOff')}
               </span>
             )}
@@ -520,17 +536,17 @@ function SortableQueueItem({
 
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}
           {isPrinting && status && (
-            <div className="mt-3">
-              <div className="flex items-center justify-between text-sm">
-                <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
+            <div className="mt-2 sm:mt-3">
+              <div className="flex items-center justify-between text-xs sm:text-sm">
+                <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3">
                   <div
-                    className="bg-bambu-green h-2 rounded-full transition-all"
+                    className="bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all"
                     style={{ width: `${status.progress || 0}%` }}
                   />
                 </div>
                 <span className="text-white">{Math.round(status.progress || 0)}%</span>
               </div>
-              <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
+              <div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-bambu-gray">
                 {status.remaining_time != null && status.remaining_time > 0 && (
                   <>
                     <span className="flex items-center gap-1">
@@ -554,7 +570,7 @@ function SortableQueueItem({
 
           {/* Waiting reason for model-based assignments */}
           {item.waiting_reason && item.status === 'pending' && (
-            <p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
+            <p className="text-[10px] sm:text-xs text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1">
               <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
               <span>{item.waiting_reason}</span>
             </p>
@@ -562,88 +578,92 @@ function SortableQueueItem({
 
           {/* Error message */}
           {item.error_message && (
-            <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
+            <p className="text-[10px] sm:text-xs text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">
               <AlertCircle className="w-3 h-3" />
               {item.error_message}
             </p>
           )}
         </div>
 
-        {/* Status badge */}
-        <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
-
-        {/* Actions */}
-        <div className="flex items-center gap-1">
-          {isPrinting && (
-            <Button
-              variant="ghost"
-              size="sm"
-              onClick={onStop}
-              disabled={!hasPermission('printers:control')}
-              title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
-              className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
-            >
-              <StopCircle className="w-4 h-4" />
-            </Button>
-          )}
-          {isPending && (
-            <>
-              {item.manual_start && (
-                <Button
-                  variant="ghost"
-                  size="sm"
-                  onClick={onStart}
-                  disabled={!hasPermission('printers:control')}
-                  title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
-                  className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
-                >
-                  <Play className="w-4 h-4" />
-                </Button>
-              )}
-              <Button
-                variant="ghost"
-                size="sm"
-                onClick={onEdit}
-                disabled={!canModify('queue', 'update', item.created_by_id)}
-                title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
-              >
-                <Pencil className="w-4 h-4" />
-              </Button>
-              <Button
-                variant="ghost"
-                size="sm"
-                onClick={onCancel}
-                disabled={!canModify('queue', 'delete', item.created_by_id)}
-                title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
-                className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
-              >
-                <X className="w-4 h-4" />
-              </Button>
-            </>
-          )}
-          {isHistory && (
-            <>
-              <Button
-                variant="ghost"
-                size="sm"
-                onClick={onRequeue}
-                disabled={!hasPermission('queue:create')}
-                title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
-                className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
-              >
-                <RefreshCw className="w-4 h-4" />
-              </Button>
+        {/* Status badge + Actions */}
+        {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
+        <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
+          <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
+
+          <div className="flex items-center gap-0.5 sm:gap-1">
+            {isPrinting && (
               <Button
                 variant="ghost"
                 size="sm"
-                onClick={onRemove}
-                disabled={!canModify('queue', 'delete', item.created_by_id)}
-                title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
+                onClick={onStop}
+                disabled={!hasPermission('printers:control')}
+                title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
+                className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
               >
-                <Trash2 className="w-4 h-4" />
+                <StopCircle className="w-4 h-4" />
               </Button>
-            </>
-          )}
+            )}
+            {isPending && (
+              <>
+                {item.manual_start && (
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={onStart}
+                    disabled={!hasPermission('printers:control')}
+                    title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
+                    className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
+                  >
+                    <Play className="w-4 h-4" />
+                  </Button>
+                )}
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onEdit}
+                  disabled={!canModify('queue', 'update', item.created_by_id)}
+                  title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
+                  className="p-1.5 sm:p-2"
+                >
+                  <Pencil className="w-4 h-4" />
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onCancel}
+                  disabled={!canModify('queue', 'delete', item.created_by_id)}
+                  title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
+                  className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
+                >
+                  <X className="w-4 h-4" />
+                </Button>
+              </>
+            )}
+            {isHistory && (
+              <>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onRequeue}
+                  disabled={!hasPermission('queue:create')}
+                  title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
+                  className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2"
+                >
+                  <RefreshCw className="w-4 h-4" />
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onRemove}
+                  disabled={!canModify('queue', 'delete', item.created_by_id)}
+                  title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
+                  className="p-1.5 sm:p-2"
+                >
+                  <Trash2 className="w-4 h-4" />
+                </Button>
+              </>
+            )}
+          </div>
         </div>
       </div>
     </div>
@@ -987,72 +1007,72 @@ export function QueuePage() {
       </div>
 
       {/* Summary Cards */}
-      <div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
+      <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 sm:gap-3 lg:gap-4 mb-8">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
-                <Play className="w-5 h-5 text-blue-400" />
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0">
+                <Play className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{activeItems.length}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.printing')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{activeItems.length}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.printing')}</p>
               </div>
             </div>
           </CardContent>
         </Card>
 
         <Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
-                <Clock className="w-5 h-5 text-yellow-400" />
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center shrink-0">
+                <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{pendingItems.length}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.queued')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{pendingItems.length}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.queued')}</p>
               </div>
             </div>
           </CardContent>
         </Card>
 
         <Card className="bg-gradient-to-br from-bambu-green/10 to-transparent border-bambu-green/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center">
-                <Timer className="w-5 h-5 text-bambu-green" />
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center shrink-0">
+                <Timer className="w-4 h-4 sm:w-5 sm:h-5 text-bambu-green" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.totalTime')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{formatDuration(totalQueueTime)}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.totalTime')}</p>
               </div>
             </div>
           </CardContent>
         </Card>
 
         <Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
-                <Weight className="w-5 h-5 text-purple-500" />
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
+                <Weight className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{formatWeight(totalWeight)}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.totalWeight')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{formatWeight(totalWeight)}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.totalWeight')}</p>
               </div>
             </div>
           </CardContent>
         </Card>
 
-        <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-gray-500/20 flex items-center justify-center">
-                <CheckCircle className="w-5 h-5 text-gray-400" />
+        <Card className="col-span-2 sm:col-span-1 bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-gray-500/20 flex items-center justify-center shrink-0">
+                <CheckCircle className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{historyItems.length}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.history')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{historyItems.length}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.history')}</p>
               </div>
             </div>
           </CardContent>
@@ -1060,9 +1080,9 @@ export function QueuePage() {
       </div>
 
       {/* Filters */}
-      <div className="flex items-center gap-4 mb-6">
+      <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
         <select
-          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
           value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
           onChange={(e) => {
             const val = e.target.value;
@@ -1079,7 +1099,7 @@ export function QueuePage() {
         </select>
 
         <select
-          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
           value={filterStatus}
           onChange={(e) => setFilterStatus(e.target.value)}
         >
@@ -1094,7 +1114,7 @@ export function QueuePage() {
 
         {uniqueLocations.length > 0 && (
           <select
-            className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
             value={filterLocation}
             onChange={(e) => setFilterLocation(e.target.value)}
           >
@@ -1105,10 +1125,11 @@ export function QueuePage() {
           </select>
         )}
 
-        <div className="flex-1" />
+        <div className="hidden sm:block flex-1" />
 
         {historyItems.length > 0 && (
           <Button
+            className="w-full sm:w-auto"
             variant="secondary"
             size="sm"
             onClick={() => setShowClearHistoryConfirm(true)}
@@ -1132,15 +1153,15 @@ export function QueuePage() {
           </p>
         </Card>
       ) : (
-        <div className="space-y-8">
+        <div className="space-y-6 sm:space-y-8">
           {/* Active Prints */}
           {activeItems.length > 0 && (
             <div>
-              <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
+              <h2 className="text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2">
                 <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
                 {t('queue.sections.currentlyPrinting')}
               </h2>
-              <div className="space-y-3">
+              <div className="space-y-2 sm:space-y-3">
                 {activeItems.map((item) => (
                   <SortableQueueItem
                     key={item.id}
@@ -1165,20 +1186,20 @@ export function QueuePage() {
           {/* Pending Queue */}
           {pendingItems.length > 0 && (
             <div>
-              <div className="flex items-center justify-between mb-4">
-                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-                  <Clock className="w-5 h-5 text-yellow-400" />
+              <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
+                <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
+                  <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400" />
                   {t('queue.sections.queued')}
-                  <span className="text-sm font-normal text-bambu-gray">
+                  <span className="text-xs sm:text-sm font-normal text-bambu-gray">
                     ({t('queue.itemCount', { count: pendingItems.length })})
                   </span>
-                  <span className="text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
+                  <span className="hidden sm:inline text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
                     {t('queue.dragToReorder')}
                   </span>
                 </h2>
                 <div className="flex items-center gap-2">
                   <select
-                    className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     value={pendingSortBy}
                     onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
                   >
@@ -1200,12 +1221,12 @@ export function QueuePage() {
               </div>
 
               {/* Bulk action toolbar */}
-              <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-dark rounded-lg">
+              <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-3 sm:mb-4 p-2 sm:p-3 bg-bambu-dark rounded-lg">
                 <Button
                   variant="ghost"
                   size="sm"
                   onClick={handleSelectAll}
-                  className="flex items-center gap-2"
+                  className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
                 >
                   {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
                     <CheckSquare className="w-4 h-4 text-bambu-green" />
@@ -1216,31 +1237,31 @@ export function QueuePage() {
                 </Button>
                 {selectedItems.length > 0 && (
                   <>
-                    <span className="text-sm text-bambu-gray">
+                    <span className="text-xs sm:text-sm text-bambu-gray">
                       {t('queue.bulkEdit.selected', { count: selectedItems.length })}
                     </span>
-                    <div className="h-4 w-px bg-bambu-dark-tertiary" />
+                    <div className="hidden sm:block h-4 w-px bg-bambu-dark-tertiary" />
                     <Button
                       variant="ghost"
                       size="sm"
                       onClick={() => setShowBulkEditModal(true)}
-                      className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
+                      className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light"
                       disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
-                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : undefined}
+                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : t('queue.bulkEdit.editSelected')}
                     >
-                      <Pencil className="w-4 h-4" />
-                      {t('queue.bulkEdit.editSelected')}
+                      <Pencil className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
+                      <span className="hidden sm:inline">{t('queue.bulkEdit.editSelected')}</span>
                     </Button>
                     <Button
                       variant="ghost"
                       size="sm"
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
-                      className="flex items-center gap-2 text-red-400 hover:text-red-300"
+                      className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-400 hover:text-red-300"
                       disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
-                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : undefined}
+                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : t('queue.bulkEdit.cancelSelected')}
                     >
-                      <X className="w-4 h-4" />
-                      {t('queue.bulkEdit.cancelSelected')}
+                      <X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
+                      <span className="hidden sm:inline">{t('queue.bulkEdit.cancelSelected')}</span>
                     </Button>
                   </>
                 )}
@@ -1255,7 +1276,7 @@ export function QueuePage() {
                   items={pendingItems.map(i => i.id)}
                   strategy={verticalListSortingStrategy}
                 >
-                  <div className="space-y-3">
+                  <div className="space-y-2 sm:space-y-3">
                     {pendingItems.map((item, index) => (
                       <SortableQueueItem
                         key={item.id}
@@ -1284,17 +1305,17 @@ export function QueuePage() {
           {/* History */}
           {historyItems.length > 0 && (
             <div>
-              <div className="flex items-center justify-between mb-4">
-                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-                  <CheckCircle className="w-5 h-5 text-bambu-gray" />
+              <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
+                <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
+                  <CheckCircle className="w-4 h-4 sm:w-5 sm:h-5 text-bambu-gray" />
                   {t('queue.sections.history')}
-                  <span className="text-sm font-normal text-bambu-gray">
+                  <span className="text-xs sm:text-sm font-normal text-bambu-gray">
                     ({t('queue.itemCount', { count: historyItems.length })})
                   </span>
                 </h2>
                 <div className="flex items-center gap-2">
                   <select
-                    className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     value={historySortBy}
                     onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
                   >
@@ -1313,7 +1334,7 @@ export function QueuePage() {
                   </Button>
                 </div>
               </div>
-              <div className="space-y-3">
+              <div className="space-y-2 sm:space-y-3">
                 {historyItems.slice(0, 20).map((item, index) => (
                   <SortableQueueItem
                     key={item.id}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DmiEmqVo.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-ph3tf3Mr.js"></script>
+    <script type="module" crossorigin src="/assets/index-DmiEmqVo.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-tulFiIvt.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff