Merge branch 'feature/mobile_view_fix' of https://github.com/Keybored02/bambuddy into feature/mobile_view_fix

This commit is contained in:
Matteo Parenti
2026-02-15 14:24:58 +01:00
22 changed files with 1358 additions and 461 deletions
+48
View File
@@ -369,6 +369,28 @@ export interface Archive {
created_by_username: string | null;
}
export interface PrintLogEntry {
id: number;
print_name: string | null;
printer_name: string | null;
printer_id: number | null;
status: string;
started_at: string | null;
completed_at: string | null;
duration_seconds: number | null;
filament_type: string | null;
filament_color: string | null;
filament_used_grams: number | null;
thumbnail_path: string | null;
created_by_username: string | null;
created_at: string;
}
export interface PrintLogResponse {
items: PrintLogEntry[];
total: number;
}
export interface ArchiveStats {
total_prints: number;
successful_prints: number;
@@ -2954,6 +2976,32 @@ export const api = {
return response.json();
},
// Print Log
getPrintLog: (params?: {
search?: string;
printerId?: number;
username?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
limit?: number;
offset?: number;
}) => {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
if (params?.printerId) searchParams.set('printer_id', String(params.printerId));
if (params?.username) searchParams.set('created_by_username', params.username);
if (params?.status) searchParams.set('status', params.status);
if (params?.dateFrom) searchParams.set('date_from', params.dateFrom);
if (params?.dateTo) searchParams.set('date_to', params.dateTo);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));
return request<PrintLogResponse>(`/print-log/?${searchParams}`);
},
getPrintLogThumbnail: (id: number) => `${API_BASE}/print-log/${id}/thumbnail`,
clearPrintLog: () =>
request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
// Settings
getSettings: () => request<AppSettings>('/settings/'),
updateSettings: (data: AppSettingsUpdate) =>
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Package, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -29,7 +29,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'inventory', to: '/inventory', icon: Package, labelKey: 'nav.inventory' },
{ id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
+29
View File
@@ -484,6 +484,7 @@ export default {
gridView: 'Rasteransicht',
listView: 'Listenansicht',
calendarView: 'Kalenderansicht',
logView: 'Druckprotokoll',
manageTags: 'Tags verwalten',
showFailedPrints: 'Fehlgeschlagene Drucke anzeigen',
hideFailedPrints: 'Fehlgeschlagene Drucke ausblenden',
@@ -672,6 +673,34 @@ export default {
actions: 'Aktionen',
hasTimelapse: 'Hat Zeitraffer',
},
log: {
date: 'Datum',
printName: 'Druckname',
printer: 'Drucker',
user: 'Benutzer',
status: 'Status',
duration: 'Dauer',
filament: 'Filament',
allPrinters: 'Alle Drucker',
allUsers: 'Alle Benutzer',
allStatuses: 'Alle Status',
cancelled: 'Abgebrochen',
skipped: 'Übersprungen',
dateFrom: 'Von',
dateTo: 'Bis',
noEntries: 'Keine Druckprotokolleinträge gefunden',
showing: '{{count}} von {{total}} Einträgen',
rowsPerPage: 'Zeilen',
page: 'Seite',
prev: 'Zurück',
next: 'Weiter',
clearLog: 'Protokoll löschen',
clearLogTitle: 'Druckprotokoll löschen',
clearLogConfirm: 'Alle Druckprotokolleinträge werden dauerhaft gelöscht. Archive und Warteschlangeneinträge sind nicht betroffen. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher?',
clearLogButton: 'Alle löschen',
cleared: '{{count}} Protokolleinträge gelöscht',
clearFailed: 'Druckprotokoll konnte nicht gelöscht werden',
},
},
// Queue page
+29
View File
@@ -484,6 +484,7 @@ export default {
gridView: 'Grid view',
listView: 'List view',
calendarView: 'Calendar view',
logView: 'Print Log',
manageTags: 'Manage Tags',
showFailedPrints: 'Show failed prints',
hideFailedPrints: 'Hide failed prints',
@@ -672,6 +673,34 @@ export default {
actions: 'Actions',
hasTimelapse: 'Has timelapse',
},
log: {
date: 'Date',
printName: 'Print Name',
printer: 'Printer',
user: 'User',
status: 'Status',
duration: 'Duration',
filament: 'Filament',
allPrinters: 'All Printers',
allUsers: 'All Users',
allStatuses: 'All Statuses',
cancelled: 'Cancelled',
skipped: 'Skipped',
dateFrom: 'From',
dateTo: 'To',
noEntries: 'No print log entries found',
showing: 'Showing {{count}} of {{total}} entries',
rowsPerPage: 'Rows',
page: 'Page',
prev: 'Prev',
next: 'Next',
clearLog: 'Clear Log',
clearLogTitle: 'Clear Print Log',
clearLogConfirm: 'All print log entries will be permanently deleted. Archives and queue items are not affected. This action cannot be undone. Are you sure?',
clearLogButton: 'Clear All',
cleared: '{{count}} log entries cleared',
clearFailed: 'Failed to clear print log',
},
},
// Queue page
+29
View File
@@ -484,6 +484,7 @@ export default {
gridView: 'Grille',
listView: 'Liste',
calendarView: 'Calendrier',
logView: 'Journal d\'impression',
manageTags: 'Gérer les tags',
showFailedPrints: 'Afficher les échecs',
hideFailedPrints: 'Masquer les échecs',
@@ -672,6 +673,34 @@ export default {
actions: 'Actions',
hasTimelapse: 'A un timelapse',
},
log: {
date: 'Date',
printName: 'Nom de l\'impression',
printer: 'Imprimante',
user: 'Utilisateur',
status: 'Statut',
duration: 'Durée',
filament: 'Filament',
allPrinters: 'Toutes les imprimantes',
allUsers: 'Tous les utilisateurs',
allStatuses: 'Tous les statuts',
cancelled: 'Annulé',
skipped: 'Ignoré',
dateFrom: 'Du',
dateTo: 'Au',
noEntries: 'Aucune entrée de journal trouvée',
showing: '{{count}} sur {{total}} entrées',
rowsPerPage: 'Lignes',
page: 'Page',
prev: 'Préc.',
next: 'Suiv.',
clearLog: 'Effacer le journal',
clearLogTitle: 'Effacer le journal d\'impression',
clearLogConfirm: 'Toutes les entrées du journal d\'impression seront supprimées définitivement. Les archives et les éléments de file d\'attente ne sont pas affectés. Cette action est irréversible. Êtes-vous sûr ?',
clearLogButton: 'Tout effacer',
cleared: '{{count}} entrées de journal effacées',
clearFailed: 'Échec de l\'effacement du journal d\'impression',
},
},
// Queue page
+29
View File
@@ -475,6 +475,7 @@ export default {
gridView: 'Vista griglia',
listView: 'Vista elenco',
calendarView: 'Vista calendario',
logView: 'Registro stampe',
manageTags: 'Gestisci tag',
showFailedPrints: 'Mostra stampe fallite',
hideFailedPrints: 'Nascondi stampe fallite',
@@ -663,6 +664,34 @@ export default {
actions: 'Azioni',
hasTimelapse: 'Ha timelapse',
},
log: {
date: 'Data',
printName: 'Nome stampa',
printer: 'Stampante',
user: 'Utente',
status: 'Stato',
duration: 'Durata',
filament: 'Filamento',
allPrinters: 'Tutte le stampanti',
allUsers: 'Tutti gli utenti',
allStatuses: 'Tutti gli stati',
cancelled: 'Annullato',
skipped: 'Saltato',
dateFrom: 'Dal',
dateTo: 'Al',
noEntries: 'Nessuna voce di registro trovata',
showing: '{{count}} di {{total}} voci',
rowsPerPage: 'Righe',
page: 'Pagina',
prev: 'Prec.',
next: 'Succ.',
clearLog: 'Cancella registro',
clearLogTitle: 'Cancella registro stampe',
clearLogConfirm: 'Tutte le voci del registro di stampa verranno eliminate permanentemente. Gli archivi e gli elementi della coda non sono interessati. Questa azione non può essere annullata. Sei sicuro?',
clearLogButton: 'Cancella tutto',
cleared: '{{count}} voci di registro cancellate',
clearFailed: 'Impossibile cancellare il registro stampe',
},
},
// Queue page
+29
View File
@@ -484,6 +484,7 @@ export default {
gridView: 'グリッド表示',
listView: 'リスト表示',
calendarView: 'カレンダー表示',
logView: '印刷ログ',
showFailedPrints: '失敗した印刷を表示',
hideFailedPrints: '失敗した印刷を非表示',
printTime: '印刷時間',
@@ -677,6 +678,34 @@ export default {
date: '日付',
actions: '操作',
},
log: {
date: '日時',
printName: '印刷名',
printer: 'プリンター',
user: 'ユーザー',
status: 'ステータス',
duration: '所要時間',
filament: 'フィラメント',
allPrinters: '全プリンター',
allUsers: '全ユーザー',
allStatuses: '全ステータス',
cancelled: 'キャンセル',
skipped: 'スキップ',
dateFrom: '開始日',
dateTo: '終了日',
noEntries: '印刷ログが見つかりません',
showing: '{{total}}件中{{count}}件を表示',
rowsPerPage: '行数',
page: 'ページ',
prev: '前へ',
next: '次へ',
clearLog: 'ログをクリア',
clearLogTitle: '印刷ログをクリア',
clearLogConfirm: 'すべての印刷ログエントリが完全に削除されます。アーカイブとキューアイテムには影響しません。この操作は元に戻せません。よろしいですか?',
clearLogButton: 'すべてクリア',
cleared: '{{count}}件のログエントリを削除しました',
clearFailed: '印刷ログの削除に失敗しました',
},
noPrinterAvailable: '利用可能なプリンターがありません',
archiveOrReprint: 'アーカイブまたは再印刷',
multiPrinterPrint: 'マルチプリンター印刷',
+354 -27
View File
@@ -46,6 +46,7 @@ import {
ChevronRight,
Settings,
User,
ClipboardList,
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer, type SlicerType } from '../utils/slicer';
@@ -2064,7 +2065,7 @@ function ArchiveListRow({
}
type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
type ViewMode = 'grid' | 'list' | 'calendar';
type ViewMode = 'grid' | 'list' | 'calendar' | 'log';
type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
@@ -2133,6 +2134,29 @@ export function ArchivesPage() {
const [showTagManagement, setShowTagManagement] = useState(false);
const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
// Log view state
const [logFilterUser, setLogFilterUser] = useState<string | null>(() =>
localStorage.getItem('logFilterUser') || null
);
const [logFilterStatus, setLogFilterStatus] = useState<string | null>(() =>
localStorage.getItem('logFilterStatus')
);
const [logFilterDateFrom, setLogFilterDateFrom] = useState(() =>
localStorage.getItem('logFilterDateFrom') || ''
);
const [logFilterDateTo, setLogFilterDateTo] = useState(() =>
localStorage.getItem('logFilterDateTo') || ''
);
const [logOffset, setLogOffset] = useState(() => {
const saved = localStorage.getItem('logOffset');
return saved ? Number(saved) : 0;
});
const [showClearLogConfirm, setShowClearLogConfirm] = useState(false);
const [logPageSize, setLogPageSize] = useState(() => {
const saved = localStorage.getItem('logPageSize');
return saved ? Number(saved) : 25;
});
// Clear highlight after 5 seconds and scroll to highlighted element
useEffect(() => {
if (highlightedArchiveId) {
@@ -2173,6 +2197,27 @@ export function ArchivesPage() {
queryFn: api.getSettings,
});
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: api.getUsers,
enabled: viewMode === 'log',
});
const { data: printLogData, isLoading: isLogLoading } = useQuery({
queryKey: ['print-log', filterPrinter, logFilterUser, logFilterStatus, logFilterDateFrom, logFilterDateTo, search, logOffset, logPageSize],
queryFn: () => api.getPrintLog({
search: search || undefined,
printerId: filterPrinter || undefined,
username: logFilterUser || undefined,
status: logFilterStatus || undefined,
dateFrom: logFilterDateFrom || undefined,
dateTo: logFilterDateTo || undefined,
limit: logPageSize,
offset: logOffset,
}),
enabled: viewMode === 'log',
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio';
@@ -2191,6 +2236,18 @@ export function ArchivesPage() {
},
});
const clearLogMutation = useMutation({
mutationFn: () => api.clearPrintLog(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['print-log'] });
setLogOffset(0);
showToast(t('archives.log.cleared', { count: data.deleted }));
},
onError: () => {
showToast(t('archives.log.clearFailed'), 'error');
},
});
// Persist all filters to localStorage
useEffect(() => {
if (filterPrinter !== null) {
@@ -2248,6 +2305,47 @@ export function ArchivesPage() {
localStorage.setItem('archiveCollection', collection);
}, [collection]);
// Persist log view filters
useEffect(() => {
if (logFilterUser) {
localStorage.setItem('logFilterUser', logFilterUser);
} else {
localStorage.removeItem('logFilterUser');
}
}, [logFilterUser]);
useEffect(() => {
if (logFilterStatus) {
localStorage.setItem('logFilterStatus', logFilterStatus);
} else {
localStorage.removeItem('logFilterStatus');
}
}, [logFilterStatus]);
useEffect(() => {
if (logFilterDateFrom) {
localStorage.setItem('logFilterDateFrom', logFilterDateFrom);
} else {
localStorage.removeItem('logFilterDateFrom');
}
}, [logFilterDateFrom]);
useEffect(() => {
if (logFilterDateTo) {
localStorage.setItem('logFilterDateTo', logFilterDateTo);
} else {
localStorage.removeItem('logFilterDateTo');
}
}, [logFilterDateTo]);
useEffect(() => {
localStorage.setItem('logOffset', logOffset.toString());
}, [logOffset]);
useEffect(() => {
localStorage.setItem('logPageSize', logPageSize.toString());
}, [logPageSize]);
const printerMap = new Map(printers?.map((p) => [p.id, p.name]) || []);
// Extract unique materials and colors from archives
@@ -2672,8 +2770,40 @@ export function ArchivesPage() {
</div>
</div>
{/* Filters */}
<Card className="mb-6">
{/* View mode toggle — always visible */}
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0 w-fit mb-4">
<button
className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('grid')}
title={t('archives.gridView')}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('list')}
title={t('archives.listView')}
>
<List className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('calendar')}
title={t('archives.calendarView')}
>
<CalendarDays className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'log' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('log')}
title={t('archives.logView')}
>
<ClipboardList className="w-4 h-4" />
</button>
</div>
{/* Filters (hidden in log view which has its own filters) */}
{viewMode !== 'log' && <Card className="mb-6">
<CardContent className="py-4">
<div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-center md:flex-wrap">
{/* Search - full width on mobile */}
@@ -2799,29 +2929,6 @@ export function ArchivesPage() {
<option value="size-asc">{t('archives.sortSmallest')}</option>
</select>
</div>
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden flex-shrink-0">
<button
className={`p-2 ${viewMode === 'grid' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('grid')}
title={t('archives.gridView')}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('list')}
title={t('archives.listView')}
>
<List className="w-4 h-4" />
</button>
<button
className={`p-2 ${viewMode === 'calendar' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('calendar')}
title={t('archives.calendarView')}
>
<CalendarDays className="w-4 h-4" />
</button>
</div>
</div>
{hasTopFilters && (
<Button
@@ -2879,7 +2986,7 @@ export function ArchivesPage() {
</div>
)}
</CardContent>
</Card>
</Card>}
{/* Pending Uploads Panel (visible when in queue mode with pending files) */}
<PendingUploadsPanel />
@@ -2958,6 +3065,211 @@ export function ArchivesPage() {
))}
</div>
</Card>
) : viewMode === 'log' ? (
<div className="space-y-4">
{/* Log filters */}
<Card>
<CardContent className="py-3">
<div className="flex flex-col md:flex-row gap-3 md:items-center md:flex-wrap">
{/* Search */}
<div className="flex-1 relative md:min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder={t('archives.searchPlaceholder')}
className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setLogOffset(0); }}
/>
</div>
{/* Printer filter */}
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={filterPrinter || ''}
onChange={(e) => { setFilterPrinter(e.target.value ? Number(e.target.value) : null); setLogOffset(0); }}
>
<option value="">{t('archives.log.allPrinters')}</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{/* User filter */}
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={logFilterUser || ''}
onChange={(e) => { setLogFilterUser(e.target.value || null); setLogOffset(0); }}
>
<option value="">{t('archives.log.allUsers')}</option>
{users?.map((u) => (
<option key={u.id} value={u.username}>{u.username}</option>
))}
</select>
{/* Status filter */}
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={logFilterStatus || ''}
onChange={(e) => { setLogFilterStatus(e.target.value || null); setLogOffset(0); }}
>
<option value="">{t('archives.log.allStatuses')}</option>
<option value="completed">{t('archives.status.completed')}</option>
<option value="failed">{t('archives.status.failed')}</option>
<option value="stopped">{t('archives.status.stopped')}</option>
<option value="cancelled">{t('archives.log.cancelled')}</option>
<option value="skipped">{t('archives.log.skipped')}</option>
</select>
{/* Date range */}
<div className="flex items-center gap-2">
<label className="text-sm text-bambu-gray">{t('archives.log.dateFrom')}</label>
<input
type="date"
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={logFilterDateFrom}
onChange={(e) => { setLogFilterDateFrom(e.target.value); setLogOffset(0); }}
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-bambu-gray">{t('archives.log.dateTo')}</label>
<input
type="date"
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={logFilterDateTo}
onChange={(e) => { setLogFilterDateTo(e.target.value); setLogOffset(0); }}
/>
</div>
{/* Clear log button */}
<div className="ml-auto">
<Button
variant="danger"
size="sm"
onClick={() => setShowClearLogConfirm(true)}
disabled={!hasPermission('archives:delete_all') || clearLogMutation.isPending}
>
<Trash2 className="w-4 h-4" />
{t('archives.log.clearLog')}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Log table */}
<Card>
{isLogLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</div>
) : !printLogData?.items.length ? (
<div className="text-center py-12 text-bambu-gray">
{t('archives.log.noEntries')}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-bambu-dark-tertiary text-bambu-gray text-left">
<th className="px-4 py-3 font-medium">{t('archives.log.date')}</th>
<th className="px-4 py-3 font-medium">{t('archives.log.printName')}</th>
<th className="px-4 py-3 font-medium">{t('archives.log.printer')}</th>
<th className="px-4 py-3 font-medium">{t('archives.log.user')}</th>
<th className="px-4 py-3 font-medium">{t('archives.log.status')}</th>
<th className="px-4 py-3 font-medium">{t('archives.log.duration')}</th>
<th className="px-4 py-3 font-medium">{t('archives.log.filament')}</th>
</tr>
</thead>
<tbody className="divide-y divide-bambu-dark-tertiary">
{printLogData.items.map((entry) => (
<tr key={entry.id} className="hover:bg-bambu-dark-secondary/50">
<td className="px-4 py-3 text-white whitespace-nowrap">
{formatDateTime(entry.started_at || entry.created_at, timeFormat)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{entry.thumbnail_path && (
<img
src={api.getPrintLogThumbnail(entry.id)}
alt=""
className="w-8 h-8 rounded object-cover flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span className="text-white truncate max-w-[200px]">
{entry.print_name || '—'}
</span>
</div>
</td>
<td className="px-4 py-3 text-bambu-gray-light">{entry.printer_name || '—'}</td>
<td className="px-4 py-3 text-bambu-gray-light">{entry.created_by_username || '—'}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
entry.status === 'completed' ? 'bg-green-500/20 text-green-400' :
entry.status === 'failed' ? 'bg-red-500/20 text-red-400' :
entry.status === 'stopped' ? 'bg-yellow-500/20 text-yellow-400' :
entry.status === 'cancelled' ? 'bg-orange-500/20 text-orange-400' :
entry.status === 'skipped' ? 'bg-blue-500/20 text-blue-400' :
'bg-gray-500/20 text-gray-400'
}`}>
{entry.status}
</span>
</td>
<td className="px-4 py-3 text-bambu-gray-light whitespace-nowrap">
{entry.duration_seconds ? formatDuration(entry.duration_seconds) : '—'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{entry.filament_color && (
<span
className="w-3 h-3 rounded-full border border-white/20 flex-shrink-0"
style={{ backgroundColor: entry.filament_color.startsWith('#') ? entry.filament_color : undefined }}
/>
)}
<span className="text-bambu-gray-light text-xs">
{entry.filament_type || '—'}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-bambu-dark-tertiary flex-wrap gap-2">
<div className="flex items-center gap-3">
<span className="text-sm text-bambu-gray">
{t('archives.log.showing', { count: Math.min(logOffset + logPageSize, printLogData.total), total: printLogData.total })}
</span>
<div className="flex items-center gap-1.5">
<label className="text-xs text-bambu-gray">{t('archives.log.rowsPerPage')}</label>
<select
className="px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
value={logPageSize}
onChange={(e) => { setLogPageSize(Number(e.target.value)); setLogOffset(0); }}
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">
{t('archives.log.page')} {Math.floor(logOffset / logPageSize) + 1} / {Math.max(1, Math.ceil(printLogData.total / logPageSize))}
</span>
<Button variant="secondary" size="sm" onClick={() => setLogOffset(Math.max(0, logOffset - logPageSize))} disabled={logOffset === 0}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={() => setLogOffset(logOffset + logPageSize)} disabled={logOffset + logPageSize >= printLogData.total}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</Card>
</div>
) : null}
{/* Upload Modal */}
@@ -3019,6 +3331,21 @@ export function ArchivesPage() {
{showTagManagement && (
<TagManagementModal onClose={() => setShowTagManagement(false)} />
)}
{/* Clear Log Confirmation */}
{showClearLogConfirm && (
<ConfirmModal
title={t('archives.log.clearLogTitle')}
message={t('archives.log.clearLogConfirm')}
confirmText={t('archives.log.clearLogButton')}
variant="danger"
onConfirm={() => {
clearLogMutation.mutate();
setShowClearLogConfirm(false);
}}
onCancel={() => setShowClearLogConfirm(false)}
/>
)}
</div>
);
}