mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Archive improvements: list view actions, object count, cross-view highlighting
Added: - Archive list view: edit/delete buttons and context menu with full feature parity - Archive object count display on cards (extracted from 3MF metadata) - Cross-view archive highlighting: click in calendar/project to highlight in card view - Context menu button (⋮) on cards and list rows for easy access - Spoolman: clear location when spools are removed from AMS Fixed: - QR code endpoint 500 error (added qrcode[pil] dependency)
This commit is contained in:
@@ -204,6 +204,7 @@ export interface Archive {
|
||||
source_3mf_path: string | null;
|
||||
duplicates: ArchiveDuplicate[] | null;
|
||||
duplicate_count: number;
|
||||
object_count: number | null;
|
||||
print_name: string | null;
|
||||
print_time_seconds: number | null;
|
||||
actual_time_seconds: number | null; // Computed from started_at/completed_at
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api/client';
|
||||
interface CalendarViewProps {
|
||||
archives: Archive[];
|
||||
onArchiveClick?: (archive: Archive) => void;
|
||||
highlightedArchiveId?: number | null;
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
@@ -23,11 +24,12 @@ const MONTH_NAMES = [
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
|
||||
export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }: CalendarViewProps) {
|
||||
const today = new Date();
|
||||
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
|
||||
const [currentYear, setCurrentYear] = useState(today.getFullYear());
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
|
||||
|
||||
// Group archives by date
|
||||
const archivesByDate = useMemo(() => {
|
||||
@@ -79,6 +81,14 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
|
||||
|
||||
const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];
|
||||
|
||||
// Clear selected archive when date changes
|
||||
const handleDateSelect = (dateKey: string | null) => {
|
||||
if (dateKey !== selectedDate) {
|
||||
setSelectedArchiveId(null);
|
||||
}
|
||||
setSelectedDate(dateKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Calendar */}
|
||||
@@ -137,7 +147,7 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => setSelectedDate(isSelected ? null : dateKey)}
|
||||
onClick={() => handleDateSelect(isSelected ? null : dateKey)}
|
||||
className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${
|
||||
isSelected
|
||||
? 'bg-bambu-green text-white'
|
||||
@@ -216,11 +226,19 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
|
||||
</h3>
|
||||
{selectedArchives.length > 0 ? (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{selectedArchives.map(archive => (
|
||||
{selectedArchives.map(archive => {
|
||||
const isHighlighted = archive.id === selectedArchiveId || archive.id === highlightedArchiveId;
|
||||
return (
|
||||
<button
|
||||
key={archive.id}
|
||||
onClick={() => onArchiveClick?.(archive)}
|
||||
className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedArchiveId(archive.id);
|
||||
onArchiveClick?.(archive);
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 p-2 rounded-lg transition-colors text-left ${
|
||||
!isHighlighted ? 'hover:bg-bambu-dark-tertiary' : ''
|
||||
}`}
|
||||
style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
|
||||
>
|
||||
{archive.thumbnail_path ? (
|
||||
<img
|
||||
@@ -255,7 +273,8 @@ export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-bambu-gray">No prints on this day</p>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import type { ReactNode, MouseEvent } from 'react';
|
||||
import type { ReactNode, MouseEvent, HTMLAttributes } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
onContextMenu?: (e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
|
||||
export function Card({ children, className = '', onClick, onContextMenu, ...rest }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
+709
-118
@@ -94,6 +94,7 @@ function ArchiveCard({
|
||||
onSelect,
|
||||
selectionMode,
|
||||
projects,
|
||||
isHighlighted,
|
||||
}: {
|
||||
archive: Archive;
|
||||
printerName: string;
|
||||
@@ -101,7 +102,13 @@ function ArchiveCard({
|
||||
onSelect: (id: number) => void;
|
||||
selectionMode: boolean;
|
||||
projects: ProjectListItem[] | undefined;
|
||||
isHighlighted?: boolean;
|
||||
}) {
|
||||
// Debug: log when card is highlighted
|
||||
if (isHighlighted) {
|
||||
console.log('ArchiveCard isHighlighted=true for archive:', archive.id);
|
||||
}
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -417,7 +424,9 @@ function ArchiveCard({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`relative flex flex-col ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
|
||||
data-archive-id={archive.id}
|
||||
className={`relative flex flex-col group ${isSelected ? 'ring-2 ring-bambu-green' : ''} ${selectionMode ? 'cursor-pointer' : ''}`}
|
||||
style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '2px' } : undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={selectionMode ? () => onSelect(archive.id) : undefined}
|
||||
>
|
||||
@@ -448,20 +457,20 @@ function ArchiveCard({
|
||||
<Image className="w-12 h-12 text-bambu-dark-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile menu button */}
|
||||
{isMobile && (
|
||||
<button
|
||||
className="absolute top-2 right-10 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setContextMenu({ x: rect.left, y: rect.bottom + 4 });
|
||||
}}
|
||||
title="Menu"
|
||||
>
|
||||
<MoreVertical className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
{/* Context menu button - visible on mobile, shows on hover for desktop */}
|
||||
<button
|
||||
className={`absolute top-2 left-2 p-1.5 rounded bg-black/50 hover:bg-black/70 transition-all ${
|
||||
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
} ${selectionMode ? 'left-10' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setContextMenu({ x: rect.left, y: rect.bottom + 4 });
|
||||
}}
|
||||
title="Right-click for more options"
|
||||
>
|
||||
<MoreVertical className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
{/* Favorite star */}
|
||||
<button
|
||||
className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
|
||||
@@ -614,6 +623,12 @@ function ArchiveCard({
|
||||
{archive.layer_height && <span>{archive.layer_height}mm</span>}
|
||||
</div>
|
||||
)}
|
||||
{archive.object_count != null && archive.object_count > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-bambu-gray" title={`${archive.object_count} object${archive.object_count > 1 ? 's' : ''}`}>
|
||||
<Box className="w-3 h-3" />
|
||||
{archive.object_count} object{archive.object_count > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
{archive.filament_type && (
|
||||
<div className="flex items-center gap-1.5 col-span-2">
|
||||
<span className="text-bambu-gray text-xs">{archive.filament_type}</span>
|
||||
@@ -966,6 +981,650 @@ function ArchiveCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveListRow({
|
||||
archive,
|
||||
printerName,
|
||||
isSelected,
|
||||
onSelect,
|
||||
selectionMode,
|
||||
projects,
|
||||
isHighlighted,
|
||||
}: {
|
||||
archive: Archive;
|
||||
printerName: string;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
selectionMode: boolean;
|
||||
projects: ProjectListItem[] | undefined;
|
||||
isHighlighted?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showReprint, setShowReprint] = useState(false);
|
||||
const [showSchedule, setShowSchedule] = useState(false);
|
||||
const [showViewer, setShowViewer] = useState(false);
|
||||
const [showTimelapse, setShowTimelapse] = useState(false);
|
||||
const [showTimelapseSelect, setShowTimelapseSelect] = useState(false);
|
||||
const [availableTimelapses, setAvailableTimelapses] = useState<Array<{ name: string; path: string; size: number; mtime: string | null }>>([]);
|
||||
const [showQRCode, setShowQRCode] = useState(false);
|
||||
const [showPhotos, setShowPhotos] = useState(false);
|
||||
const [showProjectPage, setShowProjectPage] = useState(false);
|
||||
const [showDeleteSource3mfConfirm, setShowDeleteSource3mfConfirm] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const source3mfInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const source3mfUploadMutation = useMutation({
|
||||
mutationFn: (file: File) => api.uploadSource3mf(archive.id, file),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast(`Source 3MF attached: ${data.filename}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to upload source 3MF', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const source3mfDeleteMutation = useMutation({
|
||||
mutationFn: () => api.deleteSource3mf(archive.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast('Source 3MF removed');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to remove source 3MF', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const timelapseScanMutation = useMutation({
|
||||
mutationFn: () => api.scanArchiveTimelapse(archive.id),
|
||||
onSuccess: (data) => {
|
||||
if (data.status === 'attached') {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast(`Timelapse attached: ${data.filename}`);
|
||||
} else if (data.status === 'exists') {
|
||||
showToast('Timelapse already attached');
|
||||
} else if (data.status === 'not_found' && data.available_files && data.available_files.length > 0) {
|
||||
setAvailableTimelapses(data.available_files);
|
||||
setShowTimelapseSelect(true);
|
||||
} else {
|
||||
showToast(data.message || 'No matching timelapse found', 'warning');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to scan for timelapse', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const timelapseSelectMutation = useMutation({
|
||||
mutationFn: (filename: string) => api.selectArchiveTimelapse(archive.id, filename),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast(`Timelapse attached: ${data.filename}`);
|
||||
setShowTimelapseSelect(false);
|
||||
setAvailableTimelapses([]);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || 'Failed to attach timelapse', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.deleteArchive(archive.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast('Archive deleted');
|
||||
},
|
||||
onError: () => {
|
||||
showToast('Failed to delete archive', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: () => api.toggleFavorite(archive.id),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast(data.is_favorite ? 'Added to favorites' : 'Removed from favorites');
|
||||
},
|
||||
});
|
||||
|
||||
const assignProjectMutation = useMutation({
|
||||
mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
showToast('Project updated');
|
||||
},
|
||||
onError: () => {
|
||||
showToast('Failed to update project', 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
|
||||
|
||||
const contextMenuItems: ContextMenuItem[] = [
|
||||
...(isGcodeFile ? [
|
||||
{
|
||||
label: 'Print',
|
||||
icon: <Printer className="w-4 h-4" />,
|
||||
onClick: () => setShowReprint(true),
|
||||
},
|
||||
{
|
||||
label: 'Schedule',
|
||||
icon: <Calendar className="w-4 h-4" />,
|
||||
onClick: () => setShowSchedule(true),
|
||||
},
|
||||
{
|
||||
label: 'Open in Bambu Studio',
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl);
|
||||
},
|
||||
},
|
||||
] : [
|
||||
{
|
||||
label: 'Slice',
|
||||
icon: <ExternalLink className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl);
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
label: 'View on MakerWorld',
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
onClick: () => archive.makerworld_url && window.open(archive.makerworld_url, '_blank'),
|
||||
disabled: !archive.makerworld_url,
|
||||
},
|
||||
{ label: '', divider: true, onClick: () => {} },
|
||||
{
|
||||
label: '3D Preview',
|
||||
icon: <Box className="w-4 h-4" />,
|
||||
onClick: () => setShowViewer(true),
|
||||
},
|
||||
{
|
||||
label: 'View Timelapse',
|
||||
icon: <Film className="w-4 h-4" />,
|
||||
onClick: () => setShowTimelapse(true),
|
||||
disabled: !archive.timelapse_path,
|
||||
},
|
||||
{
|
||||
label: 'Scan for Timelapse',
|
||||
icon: <ScanSearch className="w-4 h-4" />,
|
||||
onClick: () => timelapseScanMutation.mutate(),
|
||||
disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending,
|
||||
},
|
||||
{ label: '', divider: true, onClick: () => {} },
|
||||
{
|
||||
label: archive.source_3mf_path ? 'Download Source 3MF' : 'Upload Source 3MF',
|
||||
icon: <FileCode className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
if (archive.source_3mf_path) {
|
||||
const link = document.createElement('a');
|
||||
link.href = api.getSource3mfDownloadUrl(archive.id);
|
||||
link.download = `${archive.print_name || archive.filename}_source.3mf`;
|
||||
link.click();
|
||||
} else {
|
||||
source3mfInputRef.current?.click();
|
||||
}
|
||||
},
|
||||
},
|
||||
...(archive.source_3mf_path ? [{
|
||||
label: 'Replace Source 3MF',
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
onClick: () => source3mfInputRef.current?.click(),
|
||||
},
|
||||
{
|
||||
label: 'Remove Source 3MF',
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
onClick: () => setShowDeleteSource3mfConfirm(true),
|
||||
danger: true,
|
||||
}] : []),
|
||||
{ label: '', divider: true, onClick: () => {} },
|
||||
{
|
||||
label: 'Download',
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = api.getArchiveDownload(archive.id);
|
||||
link.download = `${archive.print_name || archive.filename}.3mf`;
|
||||
link.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Copy Download Link',
|
||||
icon: <Copy className="w-4 h-4" />,
|
||||
onClick: () => {
|
||||
const url = `${window.location.origin}${api.getArchiveDownload(archive.id)}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
showToast('Link copied to clipboard');
|
||||
}).catch(() => {
|
||||
showToast('Failed to copy link', 'error');
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'QR Code',
|
||||
icon: <QrCode className="w-4 h-4" />,
|
||||
onClick: () => setShowQRCode(true),
|
||||
},
|
||||
{
|
||||
label: `View Photos${archive.photos?.length ? ` (${archive.photos.length})` : ''}`,
|
||||
icon: <Camera className="w-4 h-4" />,
|
||||
onClick: () => setShowPhotos(true),
|
||||
disabled: !archive.photos?.length,
|
||||
},
|
||||
{
|
||||
label: 'Project Page',
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
onClick: () => setShowProjectPage(true),
|
||||
},
|
||||
{ label: '', divider: true, onClick: () => {} },
|
||||
{
|
||||
label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
|
||||
icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
|
||||
onClick: () => favoriteMutation.mutate(),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="w-4 h-4" />,
|
||||
onClick: () => setShowEdit(true),
|
||||
},
|
||||
...(archive.project_id && archive.project_name ? [{
|
||||
label: `Go to Project: ${archive.project_name}`,
|
||||
icon: <FolderKanban className="w-4 h-4 text-bambu-green" />,
|
||||
onClick: () => window.location.href = '/projects',
|
||||
}] : []),
|
||||
{
|
||||
label: 'Add to Project',
|
||||
icon: <FolderKanban className="w-4 h-4" />,
|
||||
onClick: () => {},
|
||||
submenu: (() => {
|
||||
const items: ContextMenuItem[] = [];
|
||||
if (archive.project_id) {
|
||||
items.push({
|
||||
label: 'Remove from Project',
|
||||
icon: <X className="w-4 h-4" />,
|
||||
onClick: () => assignProjectMutation.mutate(null),
|
||||
});
|
||||
}
|
||||
if (!projects) {
|
||||
items.push({
|
||||
label: 'Loading...',
|
||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
onClick: () => {},
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
const activeProjects = projects.filter(p => p.status === 'active');
|
||||
if (activeProjects.length === 0) {
|
||||
items.push({
|
||||
label: 'No projects available',
|
||||
icon: <FolderKanban className="w-4 h-4 opacity-50" />,
|
||||
onClick: () => {},
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
activeProjects.forEach(p => {
|
||||
items.push({
|
||||
label: p.name,
|
||||
icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
|
||||
onClick: () => assignProjectMutation.mutate(p.id),
|
||||
disabled: archive.project_id === p.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
})(),
|
||||
},
|
||||
{
|
||||
label: isSelected ? 'Deselect' : 'Select',
|
||||
icon: isSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />,
|
||||
onClick: () => onSelect(archive.id),
|
||||
},
|
||||
{ label: '', divider: true, onClick: () => {} },
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
onClick: () => setShowDeleteConfirm(true),
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-archive-id={archive.id}
|
||||
className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${
|
||||
isSelected ? 'bg-bambu-green/10' : ''
|
||||
}`}
|
||||
style={isHighlighted ? { outline: '4px solid #facc15', outlineOffset: '-4px' } : undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="col-span-1 flex items-center gap-2">
|
||||
{selectionMode && (
|
||||
<button onClick={() => onSelect(archive.id)}>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="w-4 h-4 text-bambu-green" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-bambu-gray" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{archive.thumbnail_path ? (
|
||||
<img
|
||||
src={api.getArchiveThumbnail(archive.id)}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-bambu-dark rounded flex items-center justify-center">
|
||||
<Image className="w-5 h-5 text-bambu-dark-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
|
||||
{archive.timelapse_path && (
|
||||
<span title="Has timelapse">
|
||||
<Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{archive.filament_type && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
|
||||
{archive.filament_color && (
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{archive.filament_color.split(',').map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-2.5 h-2.5 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-bambu-gray truncate">
|
||||
{printerName}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-bambu-gray">
|
||||
{new Date(archive.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="col-span-1 text-sm text-bambu-gray">
|
||||
{formatFileSize(archive.file_size)}
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl);
|
||||
}}
|
||||
title="Open in Bambu Studio"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
{archive.makerworld_url && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(archive.makerworld_url!, '_blank')}
|
||||
title="MakerWorld"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = api.getArchiveDownload(archive.id);
|
||||
link.download = `${archive.print_name || archive.filename}.3mf`;
|
||||
link.click();
|
||||
}}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowEdit(true)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setContextMenu({ x: rect.left, y: rect.bottom + 4 });
|
||||
}}
|
||||
title="More options"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEdit && (
|
||||
<EditArchiveModal
|
||||
archive={archive}
|
||||
onClose={() => setShowEdit(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3D Viewer Modal */}
|
||||
{showViewer && (
|
||||
<ModelViewerModal
|
||||
archiveId={archive.id}
|
||||
title={archive.print_name || archive.filename}
|
||||
onClose={() => setShowViewer(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reprint Modal */}
|
||||
{showReprint && (
|
||||
<ReprintModal
|
||||
archiveId={archive.id}
|
||||
archiveName={archive.print_name || archive.filename}
|
||||
onClose={() => setShowReprint(false)}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmModal
|
||||
title="Delete Archive"
|
||||
message={`Are you sure you want to delete "${archive.print_name || archive.filename}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate();
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Source 3MF Confirmation */}
|
||||
{showDeleteSource3mfConfirm && (
|
||||
<ConfirmModal
|
||||
title="Remove Source 3MF"
|
||||
message={`Are you sure you want to remove the source 3MF file from "${archive.print_name || archive.filename}"?`}
|
||||
confirmText="Remove"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
source3mfDeleteMutation.mutate();
|
||||
setShowDeleteSource3mfConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowDeleteSource3mfConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
items={contextMenuItems}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timelapse Viewer Modal */}
|
||||
{showTimelapse && archive.timelapse_path && (
|
||||
<TimelapseViewer
|
||||
src={api.getArchiveTimelapse(archive.id)}
|
||||
title={`${archive.print_name || archive.filename} - Timelapse`}
|
||||
downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
|
||||
archiveId={archive.id}
|
||||
onClose={() => setShowTimelapse(false)}
|
||||
onEdit={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
setShowTimelapse(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timelapse Selection Modal */}
|
||||
{showTimelapseSelect && availableTimelapses.length > 0 && (
|
||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card-dark rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Select Timelapse</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
No auto-match found. Select the timelapse for this print:
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTimelapseSelect(false);
|
||||
setAvailableTimelapses([]);
|
||||
}}
|
||||
className="text-gray-400 hover:text-white p-1"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-2">
|
||||
{availableTimelapses.map((file) => (
|
||||
<button
|
||||
key={file.name}
|
||||
onClick={() => timelapseSelectMutation.mutate(file.name)}
|
||||
disabled={timelapseSelectMutation.isPending}
|
||||
className="w-full text-left p-3 rounded-lg hover:bg-gray-700 transition-colors mb-1"
|
||||
>
|
||||
<div className="font-medium text-white">{file.name}</div>
|
||||
<div className="text-sm text-gray-400 flex gap-3">
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
{file.mtime && (
|
||||
<span>{new Date(file.mtime).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRCode && (
|
||||
<QRCodeModal
|
||||
archiveId={archive.id}
|
||||
archiveName={archive.print_name || archive.filename}
|
||||
onClose={() => setShowQRCode(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Photo Gallery Modal */}
|
||||
{showPhotos && archive.photos && (
|
||||
<PhotoGalleryModal
|
||||
archiveId={archive.id}
|
||||
archiveName={archive.print_name || archive.filename}
|
||||
photos={archive.photos}
|
||||
onClose={() => setShowPhotos(false)}
|
||||
onDelete={async (filename) => {
|
||||
try {
|
||||
await api.deleteArchivePhoto(archive.id, filename);
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
showToast('Photo deleted');
|
||||
} catch {
|
||||
showToast('Failed to delete photo', 'error');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Project Page Modal */}
|
||||
{showProjectPage && (
|
||||
<ProjectPageModal
|
||||
archiveId={archive.id}
|
||||
archiveName={archive.print_name || archive.filename}
|
||||
onClose={() => setShowProjectPage(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Schedule Modal */}
|
||||
{showSchedule && (
|
||||
<AddToQueueModal
|
||||
archiveId={archive.id}
|
||||
archiveName={archive.print_name || archive.filename}
|
||||
onClose={() => setShowSchedule(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hidden file input for source 3MF upload */}
|
||||
<input
|
||||
ref={source3mfInputRef}
|
||||
type="file"
|
||||
accept=".3mf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
source3mfUploadMutation.mutate(file);
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
|
||||
type ViewMode = 'grid' | 'list' | 'calendar';
|
||||
type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
|
||||
@@ -1030,6 +1689,27 @@ export function ArchivesPage() {
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [showCompareModal, setShowCompareModal] = useState(false);
|
||||
const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
|
||||
|
||||
// Clear highlight after 5 seconds and scroll to highlighted element
|
||||
useEffect(() => {
|
||||
if (highlightedArchiveId) {
|
||||
// Scroll to highlighted element after a short delay (to let the view render)
|
||||
const scrollTimer = setTimeout(() => {
|
||||
const element = document.querySelector(`[data-archive-id="${highlightedArchiveId}"]`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Clear highlight after 5 seconds
|
||||
const clearTimer = setTimeout(() => setHighlightedArchiveId(null), 5000);
|
||||
return () => {
|
||||
clearTimeout(scrollTimer);
|
||||
clearTimeout(clearTimer);
|
||||
};
|
||||
}
|
||||
}, [highlightedArchiveId]);
|
||||
|
||||
const { data: archives, isLoading } = useQuery({
|
||||
queryKey: ['archives', filterPrinter],
|
||||
@@ -1746,10 +2426,12 @@ export function ArchivesPage() {
|
||||
<CalendarView
|
||||
archives={filteredArchives || []}
|
||||
onArchiveClick={(archive) => {
|
||||
// Switch to grid view and search for the archive
|
||||
setSearch(archive.print_name || archive.filename);
|
||||
// Switch to grid view and highlight the archive
|
||||
setSearch(''); // Clear search to show all archives
|
||||
setViewMode('grid');
|
||||
setHighlightedArchiveId(archive.id);
|
||||
}}
|
||||
highlightedArchiveId={highlightedArchiveId}
|
||||
/>
|
||||
</Card>
|
||||
) : viewMode === 'grid' ? (
|
||||
@@ -1763,6 +2445,7 @@ export function ArchivesPage() {
|
||||
onSelect={toggleSelect}
|
||||
selectionMode={selectionMode}
|
||||
projects={projects}
|
||||
isHighlighted={archive.id === highlightedArchiveId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1780,108 +2463,16 @@ export function ArchivesPage() {
|
||||
</div>
|
||||
{/* List Items */}
|
||||
{filteredArchives?.map((archive) => (
|
||||
<div
|
||||
<ArchiveListRow
|
||||
key={archive.id}
|
||||
className={`grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-bambu-dark-tertiary/30 ${
|
||||
selectedIds.has(archive.id) ? 'bg-bambu-green/10' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="col-span-1 flex items-center gap-2">
|
||||
{selectionMode && (
|
||||
<button onClick={() => toggleSelect(archive.id)}>
|
||||
{selectedIds.has(archive.id) ? (
|
||||
<CheckSquare className="w-4 h-4 text-bambu-green" />
|
||||
) : (
|
||||
<Square className="w-4 h-4 text-bambu-gray" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{archive.thumbnail_path ? (
|
||||
<img
|
||||
src={api.getArchiveThumbnail(archive.id)}
|
||||
alt=""
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-bambu-dark rounded flex items-center justify-center">
|
||||
<Image className="w-5 h-5 text-bambu-dark-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
|
||||
{archive.timelapse_path && (
|
||||
<span title="Has timelapse">
|
||||
<Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{archive.filament_type && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
|
||||
{archive.filament_color && (
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{archive.filament_color.split(',').map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-2.5 h-2.5 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-bambu-gray truncate">
|
||||
{archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-bambu-gray">
|
||||
{new Date(archive.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="col-span-1 text-sm text-bambu-gray">
|
||||
{formatFileSize(archive.file_size)}
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const filename = archive.print_name || archive.filename || 'model';
|
||||
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
|
||||
openInSlicer(downloadUrl);
|
||||
}}
|
||||
title="Slice"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
{archive.makerworld_url && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(archive.makerworld_url!, '_blank')}
|
||||
title="MakerWorld"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = api.getArchiveDownload(archive.id);
|
||||
link.download = `${archive.print_name || archive.filename}.3mf`;
|
||||
link.click();
|
||||
}}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
archive={archive}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
|
||||
isSelected={selectedIds.has(archive.id)}
|
||||
onSelect={toggleSelect}
|
||||
selectionMode={selectionMode}
|
||||
projects={projects}
|
||||
isHighlighted={archive.id === highlightedArchiveId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user