mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +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:
@@ -2,6 +2,34 @@
|
||||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.1.6b9] - 2026-01-09
|
||||
|
||||
### Added
|
||||
- **Archive list view improvements** - Full feature parity with card view:
|
||||
- Edit and delete buttons inline with each row
|
||||
- Three-dot menu button for context menu access
|
||||
- All context menu actions (re-print, compare, add to project, etc.)
|
||||
- **Archive object count** - Shows number of printable objects on archive cards:
|
||||
- Displays in stats grid (e.g., "3 objs")
|
||||
- Extracted from 3MF metadata automatically
|
||||
- **Cross-view archive highlight** - Click an archive in calendar or project view to highlight it:
|
||||
- Switches to card/list view and scrolls to the archive
|
||||
- Yellow border highlight for 5 seconds
|
||||
- Works in card, list, and calendar views
|
||||
- **Context menu visual indicator** - Three-dot button on cards and list items:
|
||||
- Shows on hover (desktop) or always visible (mobile)
|
||||
- Provides quick access to context menu actions
|
||||
- Positioned on left side for easy access
|
||||
- **Spoolman location clearing** - When spools are removed from AMS, their location field is now cleared in Spoolman:
|
||||
- Previously, location persisted even after spool removal
|
||||
- Now correctly clears "Printer Name - AMS X Slot Y" when spool is no longer present
|
||||
|
||||
### Fixed
|
||||
- **QR code endpoint** - Fixed 500 error on archive QR code generation:
|
||||
- Added `qrcode[pil]` to requirements.txt
|
||||
- Improved error handling for missing dependencies
|
||||
- Fixed PIL Image resizing method
|
||||
|
||||
## [0.1.6b8] - 2026-01-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1464,16 +1464,20 @@ async def get_qrcode(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate a QR code that links to this archive."""
|
||||
import qrcode
|
||||
try:
|
||||
import qrcode
|
||||
from PIL import Image as PILImage
|
||||
except ImportError:
|
||||
raise HTTPException(500, "QR code generation not available - qrcode package not installed")
|
||||
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
archive = result.scalar_one_or_none()
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
# Build URL to archive detail page
|
||||
# Build URL to archive download
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
archive_url = f"{base_url}/archives?id={archive_id}"
|
||||
archive_url = f"{base_url}/api/v1/archives/{archive_id}/download"
|
||||
|
||||
# Generate QR code
|
||||
qr = qrcode.QRCode(
|
||||
@@ -1487,13 +1491,16 @@ async def get_qrcode(
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Convert to PIL Image for resizing
|
||||
pil_img = img.get_image()
|
||||
|
||||
# Resize if needed
|
||||
if size != 200:
|
||||
img = img.resize((size, size))
|
||||
pil_img = pil_img.resize((size, size), PILImage.Resampling.LANCZOS)
|
||||
|
||||
# Convert to bytes
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
pil_img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
|
||||
return Response(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
|
||||
class ArchiveBase(BaseModel):
|
||||
@@ -45,6 +45,9 @@ class ArchiveResponse(BaseModel):
|
||||
duplicates: list[ArchiveDuplicate] | None = None
|
||||
duplicate_count: int = 0 # Quick count for list views
|
||||
|
||||
# Object count (computed from extra_data.printable_objects)
|
||||
object_count: int | None = None
|
||||
|
||||
print_name: str | None
|
||||
print_time_seconds: int | None # Estimated time from slicer
|
||||
actual_time_seconds: int | None = None # Computed from started_at/completed_at
|
||||
@@ -81,6 +84,15 @@ class ArchiveResponse(BaseModel):
|
||||
|
||||
created_at: datetime
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compute_object_count(self) -> "ArchiveResponse":
|
||||
"""Compute object_count from extra_data.printable_objects if not set."""
|
||||
if self.object_count is None and self.extra_data:
|
||||
printable_objects = self.extra_data.get("printable_objects")
|
||||
if printable_objects and isinstance(printable_objects, dict):
|
||||
self.object_count = len(printable_objects)
|
||||
return self
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,6 +31,9 @@ pywebpush>=2.0.0
|
||||
python-multipart>=0.0.6
|
||||
aiofiles>=23.0.0
|
||||
|
||||
# QR Code generation
|
||||
qrcode[pil]>=7.4.0
|
||||
|
||||
# System monitoring
|
||||
psutil>=6.0.0
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BstVVW2U.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DIdbNfsf.css">
|
||||
<script type="module" crossorigin src="/assets/index-OrUoRy29.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BEtulymk.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user