Browse Source

Fix archives capped at 50 items, add pagination (#843)

  The archives page only fetched the 50 most recent prints due to a
  hardcoded API limit default. Added client-side pagination with
  configurable page sizes (25/50/100/200/All) and bumped the fetch
  limit to return all archives. Page size is persisted to localStorage.
maziggy 1 month ago
parent
commit
86693b4f75

+ 1 - 0
CHANGELOG.md

@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
 - **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
 
 
 ### Fixed
 ### Fixed
+- **Archives Capped at 50 Items** ([#843](https://github.com/maziggy/bambuddy/issues/843)) — The archives page only showed the 50 most recent prints due to a hardcoded API limit. Users with more than 50 archives could not see or access older entries. Fixed by fetching all archives and adding client-side pagination with configurable page sizes (25, 50, 100, 200, or All). Page size preference is persisted. Reported by @dcbaldwin.
 - **Filament Usage Not Recorded When Auto-Archive Disabled** — When a printer had "Auto-archive completed prints" turned off, filament consumption was silently lost. The `on_print_complete` callback returned early before reaching the usage tracking code, so neither the internal inventory (AMS remain% deltas) nor Spoolman received usage data. Moved filament tracking to run before the archive check so usage is always recorded regardless of the auto-archive setting.
 - **Filament Usage Not Recorded When Auto-Archive Disabled** — When a printer had "Auto-archive completed prints" turned off, filament consumption was silently lost. The `on_print_complete` callback returned early before reaching the usage tracking code, so neither the internal inventory (AMS remain% deltas) nor Spoolman received usage data. Moved filament tracking to run before the archive check so usage is always recorded regardless of the auto-archive setting.
 - **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. Reported by @NoahTingey.
 - **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. Reported by @NoahTingey.
 - **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.
 - **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.

+ 5 - 3
backend/app/services/printer_manager.py

@@ -520,9 +520,11 @@ def get_derived_status_name(state: PrinterState, model: str | None = None) -> st
         state: The printer state to analyze
         state: The printer state to analyze
         model: Optional printer model for model-specific workarounds
         model: Optional printer model for model-specific workarounds
     """
     """
-    # A1/A1 Mini firmware bug: some versions report stg_cur=0 when idle
-    # Only correct this specific case (IDLE + stg_cur=0) for affected models
-    if state.state == "IDLE" and state.stg_cur == 0 and has_stg_cur_idle_bug(model):
+    # Firmware bug: some models (A1, P1P, P1S) report stg_cur=0 when not printing.
+    # stg_cur=0 maps to "Printing" in STAGE_NAMES, which incorrectly overrides the
+    # real state (IDLE, FINISH, FAILED, etc.). Only trust stg_cur when the printer
+    # is actually in an active print state (RUNNING or PAUSE).
+    if state.state not in ("RUNNING", "PAUSE") and state.stg_cur == 0 and has_stg_cur_idle_bug(model):
         return None
         return None
 
 
     # If we have a valid calibration stage, use it
     # If we have a valid calibration stage, use it

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

@@ -2602,7 +2602,7 @@ export const api = {
     request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
     request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
 
 
   // Archives
   // Archives
-  getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0, dateFrom?: string, dateTo?: string) => {
+  getArchives: (printerId?: number, projectId?: number, limit = 10000, offset = 0, dateFrom?: string, dateTo?: string) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (printerId) params.set('printer_id', String(printerId));
     if (printerId) params.set('printer_id', String(printerId));
     if (projectId) params.set('project_id', String(projectId));
     if (projectId) params.set('project_id', String(projectId));

+ 9 - 0
frontend/src/i18n/locales/de.ts

@@ -539,6 +539,15 @@ export default {
     noArchivesSearch: 'Keine Archive entsprechen Ihrer Suche',
     noArchivesSearch: 'Keine Archive entsprechen Ihrer Suche',
     originalPrintNotVisible: 'Ursprünglicher Druck nicht sichtbar - versuchen Sie, die Filter zu löschen',
     originalPrintNotVisible: 'Ursprünglicher Druck nicht sichtbar - versuchen Sie, die Filter zu löschen',
     noArchivesYet: 'Noch keine Archive',
     noArchivesYet: 'Noch keine Archive',
+    prints: 'Drucke',
+    pagination: {
+      showing: 'Zeige',
+      to: 'bis',
+      of: 'von',
+      show: 'Zeige',
+      page: 'Seite',
+      all: 'Alle',
+    },
     loadingArchives: 'Lade Archive...',
     loadingArchives: 'Lade Archive...',
     releaseToUpload: 'Loslassen zum Hochladen',
     releaseToUpload: 'Loslassen zum Hochladen',
     showAll: 'Alle anzeigen',
     showAll: 'Alle anzeigen',

+ 9 - 0
frontend/src/i18n/locales/en.ts

@@ -539,6 +539,15 @@ export default {
     noArchivesSearch: 'No archives match your search',
     noArchivesSearch: 'No archives match your search',
     originalPrintNotVisible: 'Original print not visible - try clearing filters',
     originalPrintNotVisible: 'Original print not visible - try clearing filters',
     noArchivesYet: 'No archives yet',
     noArchivesYet: 'No archives yet',
+    prints: 'prints',
+    pagination: {
+      showing: 'Showing',
+      to: 'to',
+      of: 'of',
+      show: 'Show',
+      page: 'Page',
+      all: 'All',
+    },
     loadingArchives: 'Loading archives...',
     loadingArchives: 'Loading archives...',
     releaseToUpload: 'Release to upload',
     releaseToUpload: 'Release to upload',
     showAll: 'Show all',
     showAll: 'Show all',

+ 9 - 0
frontend/src/i18n/locales/fr.ts

@@ -539,6 +539,15 @@ export default {
     noArchivesSearch: 'Aucune archive ne correspond',
     noArchivesSearch: 'Aucune archive ne correspond',
     originalPrintNotVisible: 'Impression d\'origine non visible - essayez d\'effacer les filtres',
     originalPrintNotVisible: 'Impression d\'origine non visible - essayez d\'effacer les filtres',
     noArchivesYet: 'Pas encore d\'archive',
     noArchivesYet: 'Pas encore d\'archive',
+    prints: 'impressions',
+    pagination: {
+      showing: 'Affichage',
+      to: 'à',
+      of: 'sur',
+      show: 'Afficher',
+      page: 'Page',
+      all: 'Tout',
+    },
     loadingArchives: 'Chargement...',
     loadingArchives: 'Chargement...',
     releaseToUpload: 'Relâcher pour téléverser',
     releaseToUpload: 'Relâcher pour téléverser',
     showAll: 'Tout afficher',
     showAll: 'Tout afficher',

+ 9 - 0
frontend/src/i18n/locales/it.ts

@@ -539,6 +539,15 @@ export default {
     noArchivesSearch: 'Nessun archivio corrisponde alla ricerca',
     noArchivesSearch: 'Nessun archivio corrisponde alla ricerca',
     originalPrintNotVisible: 'Stampa originale non visibile - prova a rimuovere i filtri',
     originalPrintNotVisible: 'Stampa originale non visibile - prova a rimuovere i filtri',
     noArchivesYet: 'Nessun archivio ancora',
     noArchivesYet: 'Nessun archivio ancora',
+    prints: 'stampe',
+    pagination: {
+      showing: 'Mostrando',
+      to: 'a',
+      of: 'di',
+      show: 'Mostra',
+      page: 'Pagina',
+      all: 'Tutti',
+    },
     loadingArchives: 'Caricamento archivi...',
     loadingArchives: 'Caricamento archivi...',
     releaseToUpload: 'Rilascia per caricare',
     releaseToUpload: 'Rilascia per caricare',
     showAll: 'Mostra tutti',
     showAll: 'Mostra tutti',

+ 9 - 0
frontend/src/i18n/locales/ja.ts

@@ -538,6 +538,15 @@ export default {
     noArchivesSearch: '検索条件に一致するアーカイブがありません',
     noArchivesSearch: '検索条件に一致するアーカイブがありません',
     originalPrintNotVisible: '元の印刷が表示されていません - フィルターをクリアしてみてください',
     originalPrintNotVisible: '元の印刷が表示されていません - フィルターをクリアしてみてください',
     noArchivesYet: 'アーカイブはまだありません',
     noArchivesYet: 'アーカイブはまだありません',
+    prints: '件',
+    pagination: {
+      showing: '表示中',
+      to: '〜',
+      of: '/',
+      show: '表示',
+      page: 'ページ',
+      all: 'すべて',
+    },
     loadingArchives: 'アーカイブを読み込み中...',
     loadingArchives: 'アーカイブを読み込み中...',
     releaseToUpload: 'ドロップしてアップロード',
     releaseToUpload: 'ドロップしてアップロード',
     showAll: 'すべて表示',
     showAll: 'すべて表示',

+ 9 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -539,6 +539,15 @@ export default {
     noArchivesSearch: 'Nenhum arquivo corresponde à sua pesquisa',
     noArchivesSearch: 'Nenhum arquivo corresponde à sua pesquisa',
     originalPrintNotVisible: 'Impressão original não visível - tente limpar os filtros',
     originalPrintNotVisible: 'Impressão original não visível - tente limpar os filtros',
     noArchivesYet: 'Ainda não há arquivos',
     noArchivesYet: 'Ainda não há arquivos',
+    prints: 'impressões',
+    pagination: {
+      showing: 'Mostrando',
+      to: 'a',
+      of: 'de',
+      show: 'Mostrar',
+      page: 'Página',
+      all: 'Todos',
+    },
     loadingArchives: 'Carregando arquivos...',
     loadingArchives: 'Carregando arquivos...',
     releaseToUpload: 'Solte para enviar',
     releaseToUpload: 'Solte para enviar',
     showAll: 'Mostrar todos',
     showAll: 'Mostrar todos',

+ 9 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -539,6 +539,15 @@ export default {
     noArchivesSearch: '没有匹配搜索的归档',
     noArchivesSearch: '没有匹配搜索的归档',
     originalPrintNotVisible: '原始打印不可见 - 请尝试清除筛选条件',
     originalPrintNotVisible: '原始打印不可见 - 请尝试清除筛选条件',
     noArchivesYet: '暂无归档',
     noArchivesYet: '暂无归档',
+    prints: '条打印',
+    pagination: {
+      showing: '显示',
+      to: '至',
+      of: '共',
+      show: '每页',
+      page: '页',
+      all: '全部',
+    },
     loadingArchives: '加载归档中...',
     loadingArchives: '加载归档中...',
     releaseToUpload: '释放以上传',
     releaseToUpload: '释放以上传',
     showAll: '显示全部',
     showAll: '显示全部',

+ 175 - 35
frontend/src/pages/ArchivesPage.tsx

@@ -46,6 +46,8 @@ import {
   FolderKanban,
   FolderKanban,
   ChevronLeft,
   ChevronLeft,
   ChevronRight,
   ChevronRight,
+  ChevronsLeft,
+  ChevronsRight,
   Settings,
   Settings,
   User,
   User,
   Play,
   Play,
@@ -2381,6 +2383,15 @@ export function ArchivesPage() {
   const [collection, setCollection] = useState<Collection>(() =>
   const [collection, setCollection] = useState<Collection>(() =>
     (localStorage.getItem('archiveCollection') as Collection) || 'all'
     (localStorage.getItem('archiveCollection') as Collection) || 'all'
   );
   );
+  // Pagination state
+  const [pageIndex, setPageIndex] = useState(0);
+  const [pageSize, setPageSize] = useState<number>(() => {
+    try {
+      const stored = localStorage.getItem('archivePageSize');
+      return stored ? Number(stored) : 50;
+    } catch { return 50; }
+  });
+
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
@@ -2562,6 +2573,15 @@ export function ArchivesPage() {
     localStorage.setItem('archiveFilterFileType', filterFileType);
     localStorage.setItem('archiveFilterFileType', filterFileType);
   }, [filterFileType]);
   }, [filterFileType]);
 
 
+  // Reset page when filters/search/sort/collection change
+  useEffect(() => {
+    setPageIndex(0);
+  }, [search, filterPrinter, filterMaterial, filterColors, colorFilterMode, filterFavorites, hideFailed, hideDuplicates, filterTag, filterFileType, sortBy, collection]);
+
+  useEffect(() => {
+    try { localStorage.setItem('archivePageSize', String(pageSize)); } catch { /* ignore */ }
+  }, [pageSize]);
+
   useEffect(() => {
   useEffect(() => {
     localStorage.setItem('archiveViewMode', viewMode);
     localStorage.setItem('archiveViewMode', viewMode);
   }, [viewMode]);
   }, [viewMode]);
@@ -2713,6 +2733,27 @@ export function ArchivesPage() {
       }
       }
     });
     });
 
 
+  // Pagination
+  const totalFiltered = filteredArchives?.length || 0;
+  const showAll = pageSize === -1;
+  const effectivePageSize = showAll ? totalFiltered || 1 : pageSize;
+  const totalPages = Math.max(1, Math.ceil(totalFiltered / effectivePageSize));
+  const paginatedArchives = showAll
+    ? filteredArchives
+    : filteredArchives?.slice(pageIndex * effectivePageSize, (pageIndex + 1) * effectivePageSize);
+
+  // Jump to the page containing the highlighted archive
+  useEffect(() => {
+    if (highlightedArchiveId && filteredArchives && !showAll) {
+      const idx = filteredArchives.findIndex(a => a.id === highlightedArchiveId);
+      if (idx >= 0) {
+        const targetPage = Math.floor(idx / effectivePageSize);
+        if (targetPage !== pageIndex) setPageIndex(targetPage);
+      }
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [highlightedArchiveId]);
+
   const selectionMode = isSelectionMode || selectedIds.size > 0;
   const selectionMode = isSelectionMode || selectedIds.size > 0;
 
 
   const toggleSelect = (id: number) => {
   const toggleSelect = (id: number) => {
@@ -3305,40 +3346,10 @@ export function ArchivesPage() {
           />
           />
         </Card>
         </Card>
       ) : viewMode === 'grid' ? (
       ) : viewMode === 'grid' ? (
-        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
-          {filteredArchives?.map((archive) => (
-            <ArchiveCard
-              key={archive.id}
-              archive={archive}
-              printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
-              isSelected={selectedIds.has(archive.id)}
-              onSelect={toggleSelect}
-              selectionMode={selectionMode}
-              projects={projects}
-              isHighlighted={archive.id === highlightedArchiveId}
-              timeFormat={timeFormat}
-              preferredSlicer={preferredSlicer}
-              currency={currency}
-              t={t}
-              onNavigateToArchive={handleNavigateToArchive}
-            />
-          ))}
-        </div>
-      ) : viewMode === 'list' ? (
-        <Card>
-          <div className="divide-y divide-bambu-dark-tertiary">
-            {/* List Header */}
-            <div className="grid grid-cols-12 gap-4 px-4 py-3 text-xs text-bambu-gray font-medium">
-              <div className="col-span-1"></div>
-              <div className="col-span-4">Name</div>
-              <div className="col-span-2">Printer</div>
-              <div className="col-span-2">Date</div>
-              <div className="col-span-1">Size</div>
-              <div className="col-span-2 text-right">Actions</div>
-            </div>
-            {/* List Items */}
-            {filteredArchives?.map((archive) => (
-              <ArchiveListRow
+        <>
+          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
+            {paginatedArchives?.map((archive) => (
+              <ArchiveCard
                 key={archive.id}
                 key={archive.id}
                 archive={archive}
                 archive={archive}
                 printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
                 printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
@@ -3347,13 +3358,65 @@ export function ArchivesPage() {
                 selectionMode={selectionMode}
                 selectionMode={selectionMode}
                 projects={projects}
                 projects={projects}
                 isHighlighted={archive.id === highlightedArchiveId}
                 isHighlighted={archive.id === highlightedArchiveId}
+                timeFormat={timeFormat}
                 preferredSlicer={preferredSlicer}
                 preferredSlicer={preferredSlicer}
+                currency={currency}
                 t={t}
                 t={t}
                 onNavigateToArchive={handleNavigateToArchive}
                 onNavigateToArchive={handleNavigateToArchive}
               />
               />
             ))}
             ))}
           </div>
           </div>
-        </Card>
+          <ArchivePaginationBar
+            pageIndex={pageIndex}
+            pageSize={pageSize}
+            totalRows={totalFiltered}
+            totalPages={totalPages}
+            onPageChange={setPageIndex}
+            onPageSizeChange={(size) => { setPageSize(size); setPageIndex(0); }}
+            t={t}
+          />
+        </>
+      ) : viewMode === 'list' ? (
+        <>
+          <Card>
+            <div className="divide-y divide-bambu-dark-tertiary">
+              {/* List Header */}
+              <div className="grid grid-cols-12 gap-4 px-4 py-3 text-xs text-bambu-gray font-medium">
+                <div className="col-span-1"></div>
+                <div className="col-span-4">Name</div>
+                <div className="col-span-2">Printer</div>
+                <div className="col-span-2">Date</div>
+                <div className="col-span-1">Size</div>
+                <div className="col-span-2 text-right">Actions</div>
+              </div>
+              {/* List Items */}
+              {paginatedArchives?.map((archive) => (
+                <ArchiveListRow
+                  key={archive.id}
+                  archive={archive}
+                  printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
+                  isSelected={selectedIds.has(archive.id)}
+                  onSelect={toggleSelect}
+                  selectionMode={selectionMode}
+                  projects={projects}
+                  isHighlighted={archive.id === highlightedArchiveId}
+                  preferredSlicer={preferredSlicer}
+                  t={t}
+                  onNavigateToArchive={handleNavigateToArchive}
+                />
+              ))}
+            </div>
+          </Card>
+          <ArchivePaginationBar
+            pageIndex={pageIndex}
+            pageSize={pageSize}
+            totalRows={totalFiltered}
+            totalPages={totalPages}
+            onPageChange={setPageIndex}
+            onPageSizeChange={(size) => { setPageSize(size); setPageIndex(0); }}
+            t={t}
+          />
+        </>
       ) : viewMode === 'log' ? (
       ) : viewMode === 'log' ? (
         <div className="space-y-4">
         <div className="space-y-4">
           {/* Log filters */}
           {/* Log filters */}
@@ -3638,3 +3701,80 @@ export function ArchivesPage() {
     </div>
     </div>
   );
   );
 }
 }
+
+/* Pagination bar for archives grid/list views */
+function ArchivePaginationBar({
+  pageIndex, pageSize, totalRows, totalPages, onPageChange, onPageSizeChange, t,
+}: {
+  pageIndex: number;
+  pageSize: number;
+  totalRows: number;
+  totalPages: number;
+  onPageChange: (page: number) => void;
+  onPageSizeChange: (size: number) => void;
+  t: (key: string) => string;
+}) {
+  const isShowAll = pageSize === -1;
+  if (totalPages <= 1 && !isShowAll) return null;
+  const effectiveSize = isShowAll ? totalRows || 1 : pageSize;
+  return (
+    <div className="flex items-center justify-between pt-2 text-sm">
+      <span className="text-bambu-gray">
+        {isShowAll
+          ? `${totalRows} ${t('archives.prints')}`
+          : <>{t('archives.pagination.showing')} {pageIndex * effectiveSize + 1} {t('archives.pagination.to')}{' '}
+              {Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '}
+              {t('archives.pagination.of')} {totalRows} {t('archives.prints')}</>
+        }
+      </span>
+      <div className="flex items-center gap-2">
+        <span className="text-bambu-gray">{t('archives.pagination.show')}</span>
+        <select
+          value={pageSize}
+          onChange={(e) => onPageSizeChange(Number(e.target.value))}
+          className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
+        >
+          {[25, 50, 100, 200].map((n) => (
+            <option key={n} value={n}>{n}</option>
+          ))}
+          <option value={-1}>{t('archives.pagination.all')}</option>
+        </select>
+        {!isShowAll && (
+          <>
+            <button
+              onClick={() => onPageChange(0)}
+              disabled={pageIndex === 0}
+              className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronsLeft className="w-4 h-4" />
+            </button>
+            <button
+              onClick={() => onPageChange(Math.max(0, pageIndex - 1))}
+              disabled={pageIndex === 0}
+              className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronLeft className="w-4 h-4" />
+            </button>
+            <span className="text-bambu-gray px-2 whitespace-nowrap">
+              {t('archives.pagination.page')} {pageIndex + 1} {t('archives.pagination.of')} {totalPages}
+            </span>
+            <button
+              onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}
+              disabled={pageIndex >= totalPages - 1}
+              className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronRight className="w-4 h-4" />
+            </button>
+            <button
+              onClick={() => onPageChange(totalPages - 1)}
+              disabled={pageIndex >= totalPages - 1}
+              className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronsRight className="w-4 h-4" />
+            </button>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}

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


+ 1 - 1
static/index.html

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

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