|
|
@@ -35,6 +35,7 @@ import {
|
|
|
Printer,
|
|
|
Pencil,
|
|
|
Play,
|
|
|
+ Image,
|
|
|
} from 'lucide-react';
|
|
|
import { api } from '../api/client';
|
|
|
import type {
|
|
|
@@ -49,6 +50,7 @@ import { Button } from '../components/Button';
|
|
|
import { ConfirmModal } from '../components/ConfirmModal';
|
|
|
import { PrintModal } from '../components/PrintModal';
|
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
+import { useIsMobile } from '../hooks/useIsMobile';
|
|
|
|
|
|
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
|
|
|
type SortDirection = 'asc' | 'desc';
|
|
|
@@ -427,6 +429,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
const [preserveZipStructure, setPreserveZipStructure] = useState(true);
|
|
|
const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
|
|
|
+ const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
|
|
@@ -466,6 +469,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
|
|
};
|
|
|
|
|
|
const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
|
|
|
+ const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
|
|
|
|
|
|
const handleUpload = async () => {
|
|
|
if (files.length === 0) return;
|
|
|
@@ -482,7 +486,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
|
|
try {
|
|
|
if (files[i].isZip) {
|
|
|
// Extract ZIP file
|
|
|
- const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip);
|
|
|
+ const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
|
|
|
setFiles((prev) =>
|
|
|
prev.map((f, idx) =>
|
|
|
idx === i
|
|
|
@@ -497,7 +501,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
|
|
);
|
|
|
} else {
|
|
|
// Regular file upload
|
|
|
- await api.uploadLibraryFile(files[i].file, folderId);
|
|
|
+ await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
|
|
|
setFiles((prev) =>
|
|
|
prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
|
|
|
);
|
|
|
@@ -596,6 +600,32 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
+ {/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */}
|
|
|
+ {(hasStlFiles || hasZipFiles) && (
|
|
|
+ <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
|
|
|
+ <div className="flex items-start gap-3">
|
|
|
+ <Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
|
|
|
+ <div className="flex-1">
|
|
|
+ <p className="text-sm text-bambu-green font-medium">STL thumbnail generation</p>
|
|
|
+ <p className="text-xs text-bambu-green/70 mt-1">
|
|
|
+ {hasZipFiles && !hasStlFiles
|
|
|
+ ? 'ZIP files may contain STL files. Thumbnails can be generated during extraction.'
|
|
|
+ : 'Thumbnails can be generated for STL files. Large models may take longer to process.'}
|
|
|
+ </p>
|
|
|
+ <label className="flex items-center gap-2 mt-2 cursor-pointer">
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ checked={generateStlThumbnails}
|
|
|
+ onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
|
|
|
+ className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
|
|
+ />
|
|
|
+ <span className="text-sm text-white">Generate thumbnails for STL files</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
{/* File List */}
|
|
|
{files.length > 0 && (
|
|
|
<div className="max-h-48 overflow-y-auto space-y-2">
|
|
|
@@ -826,15 +856,18 @@ function isSlicedFilename(filename: string): boolean {
|
|
|
interface FileCardProps {
|
|
|
file: LibraryFileListItem;
|
|
|
isSelected: boolean;
|
|
|
+ isMobile: boolean;
|
|
|
onSelect: (id: number) => void;
|
|
|
onDelete: (id: number) => void;
|
|
|
onDownload: (id: number) => void;
|
|
|
onAddToQueue?: (id: number) => void;
|
|
|
onPrint?: (file: LibraryFileListItem) => void;
|
|
|
onRename?: (file: LibraryFileListItem) => void;
|
|
|
+ onGenerateThumbnail?: (file: LibraryFileListItem) => void;
|
|
|
+ thumbnailVersion?: number;
|
|
|
}
|
|
|
|
|
|
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) {
|
|
|
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) {
|
|
|
const [showActions, setShowActions] = useState(false);
|
|
|
|
|
|
return (
|
|
|
@@ -850,7 +883,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
|
|
|
<div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
|
|
|
{file.thumbnail_path ? (
|
|
|
<img
|
|
|
- src={api.getLibraryFileThumbnailUrl(file.id)}
|
|
|
+ src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? `?v=${thumbnailVersion}` : ''}`}
|
|
|
alt={file.filename}
|
|
|
className="w-full h-full object-cover"
|
|
|
/>
|
|
|
@@ -890,7 +923,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
|
|
|
</div>
|
|
|
|
|
|
{/* Actions - always visible on mobile, hover on desktop */}
|
|
|
- <div className="absolute bottom-2 right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
|
|
|
+ <div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
|
|
|
<button
|
|
|
onClick={() => setShowActions(!showActions)}
|
|
|
className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
|
|
|
@@ -935,6 +968,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
|
|
|
Rename
|
|
|
</button>
|
|
|
)}
|
|
|
+ {onGenerateThumbnail && file.file_type === 'stl' && (
|
|
|
+ <button
|
|
|
+ className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
|
|
|
+ onClick={() => { onGenerateThumbnail(file); setShowActions(false); }}
|
|
|
+ >
|
|
|
+ <Image className="w-3.5 h-3.5" />
|
|
|
+ Generate Thumbnail
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
<button
|
|
|
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
|
|
|
onClick={() => { onDelete(file.id); setShowActions(false); }}
|
|
|
@@ -951,7 +993,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
|
|
|
<div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
|
|
|
isSelected
|
|
|
? 'bg-bambu-green border-bambu-green'
|
|
|
- : 'border-white/30 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100'
|
|
|
+ : `border-white/30 bg-black/30 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`
|
|
|
}`}>
|
|
|
{isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
|
|
|
</div>
|
|
|
@@ -979,6 +1021,7 @@ export function FileManagerPage() {
|
|
|
const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
|
|
|
const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
|
|
|
const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
|
|
|
+ const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
|
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
|
|
|
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
|
|
|
});
|
|
|
@@ -1032,11 +1075,20 @@ export function FileManagerPage() {
|
|
|
};
|
|
|
}, [isResizing, sidebarWidth]);
|
|
|
|
|
|
- // Filter and sort state
|
|
|
+ // Filter and sort state (persist sort preferences to localStorage)
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
const [filterType, setFilterType] = useState<string>('all');
|
|
|
- const [sortField, setSortField] = useState<SortField>('date');
|
|
|
- const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
|
|
+ const [sortField, setSortField] = useState<SortField>(() => {
|
|
|
+ const saved = localStorage.getItem('library-sort-field');
|
|
|
+ return (saved as SortField) || 'name';
|
|
|
+ });
|
|
|
+ const [sortDirection, setSortDirection] = useState<SortDirection>(() => {
|
|
|
+ const saved = localStorage.getItem('library-sort-direction');
|
|
|
+ return (saved as SortDirection) || 'asc';
|
|
|
+ });
|
|
|
+
|
|
|
+ // Mobile detection for touch-friendly UI
|
|
|
+ const isMobile = useIsMobile();
|
|
|
|
|
|
// Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
|
|
|
useEffect(() => {
|
|
|
@@ -1272,6 +1324,52 @@ export function FileManagerPage() {
|
|
|
},
|
|
|
});
|
|
|
|
|
|
+ const batchThumbnailMutation = useMutation({
|
|
|
+ mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
|
|
|
+ onSuccess: (result) => {
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
|
|
+ // Update thumbnail versions for cache busting
|
|
|
+ if (result.succeeded > 0) {
|
|
|
+ const now = Date.now();
|
|
|
+ const newVersions: Record<number, number> = {};
|
|
|
+ result.results.forEach((r) => {
|
|
|
+ if (r.success) {
|
|
|
+ newVersions[r.file_id] = now;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ setThumbnailVersions((prev) => ({ ...prev, ...newVersions }));
|
|
|
+ }
|
|
|
+ if (result.succeeded > 0 && result.failed === 0) {
|
|
|
+ showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}`, 'success');
|
|
|
+ } else if (result.succeeded > 0 && result.failed > 0) {
|
|
|
+ showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}, ${result.failed} failed`, 'success');
|
|
|
+ } else if (result.processed === 0) {
|
|
|
+ showToast('No STL files missing thumbnails', 'info');
|
|
|
+ } else {
|
|
|
+ showToast(`Failed to generate thumbnails: ${result.results[0]?.error || 'Unknown error'}`, 'error');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onError: (error: Error) => showToast(error.message, 'error'),
|
|
|
+ });
|
|
|
+
|
|
|
+ const singleThumbnailMutation = useMutation({
|
|
|
+ mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }),
|
|
|
+ onSuccess: (result) => {
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
|
|
+ // Update thumbnail version for cache busting
|
|
|
+ if (result.succeeded > 0) {
|
|
|
+ const fileId = result.results[0]?.file_id;
|
|
|
+ if (fileId) {
|
|
|
+ setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() }));
|
|
|
+ }
|
|
|
+ showToast('Thumbnail generated', 'success');
|
|
|
+ } else {
|
|
|
+ showToast(`Failed to generate thumbnail: ${result.results[0]?.error || 'Unknown error'}`, 'error');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onError: (error: Error) => showToast(error.message, 'error'),
|
|
|
+ });
|
|
|
+
|
|
|
// Helper to check if a file is sliced (printable)
|
|
|
const isSlicedFile = useCallback((filename: string) => {
|
|
|
const lower = filename.toLowerCase();
|
|
|
@@ -1333,7 +1431,7 @@ export function FileManagerPage() {
|
|
|
const isLoading = foldersLoading || filesLoading;
|
|
|
|
|
|
return (
|
|
|
- <div className="p-4 md:p-8 h-[calc(100vh-64px)] flex flex-col">
|
|
|
+ <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
|
|
|
{/* Header */}
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
|
|
<div>
|
|
|
@@ -1369,6 +1467,19 @@ export function FileManagerPage() {
|
|
|
<List className="w-4 h-4" />
|
|
|
</button>
|
|
|
</div>
|
|
|
+ <Button
|
|
|
+ variant="secondary"
|
|
|
+ onClick={() => batchThumbnailMutation.mutate()}
|
|
|
+ disabled={batchThumbnailMutation.isPending}
|
|
|
+ title="Generate thumbnails for STL files missing them"
|
|
|
+ >
|
|
|
+ {batchThumbnailMutation.isPending ? (
|
|
|
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
|
+ ) : (
|
|
|
+ <Image className="w-4 h-4 mr-2" />
|
|
|
+ )}
|
|
|
+ Generate Thumbnails
|
|
|
+ </Button>
|
|
|
<Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
|
|
|
<FolderPlus className="w-4 h-4 mr-2" />
|
|
|
New Folder
|
|
|
@@ -1396,7 +1507,7 @@ export function FileManagerPage() {
|
|
|
|
|
|
{/* Stats bar */}
|
|
|
{stats && (
|
|
|
- <div className="flex items-center gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
|
|
|
+ <div className="flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
|
|
|
<div className="flex items-center gap-2 text-sm">
|
|
|
<File className="w-4 h-4 text-bambu-green" />
|
|
|
<span className="text-bambu-gray">Files:</span>
|
|
|
@@ -1412,7 +1523,7 @@ export function FileManagerPage() {
|
|
|
<span className="text-bambu-gray">Size:</span>
|
|
|
<span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
|
|
|
</div>
|
|
|
- <div className="flex items-center gap-2 text-sm ml-auto">
|
|
|
+ <div className="flex items-center gap-2 text-sm sm:ml-auto">
|
|
|
<span className="text-bambu-gray">Free:</span>
|
|
|
<span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
|
|
|
{formatFileSize(stats.disk_free_bytes)}
|
|
|
@@ -1422,11 +1533,40 @@ export function FileManagerPage() {
|
|
|
)}
|
|
|
|
|
|
{/* Main content */}
|
|
|
- <div className="flex-1 flex gap-6 min-h-0">
|
|
|
- {/* Folder sidebar - resizable */}
|
|
|
+ <div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0">
|
|
|
+ {/* Mobile folder selector */}
|
|
|
+ <div className="lg:hidden">
|
|
|
+ <select
|
|
|
+ value={selectedFolderId ?? ''}
|
|
|
+ onChange={(e) => setSelectedFolderId(e.target.value ? parseInt(e.target.value, 10) : null)}
|
|
|
+ className="w-full bg-bambu-card border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green"
|
|
|
+ >
|
|
|
+ <option value="">📁 All Files</option>
|
|
|
+ {folders && (() => {
|
|
|
+ // Flatten folder tree for mobile selector
|
|
|
+ const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
|
|
|
+ const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
|
|
|
+ for (const item of items) {
|
|
|
+ result.push({ id: item.id, name: item.name, fileCount: item.file_count, depth });
|
|
|
+ if (item.children.length > 0) {
|
|
|
+ result.push(...flattenFolders(item.children, depth + 1));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ };
|
|
|
+ return flattenFolders(folders).map((folder) => (
|
|
|
+ <option key={folder.id} value={folder.id}>
|
|
|
+ {'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
|
|
|
+ </option>
|
|
|
+ ));
|
|
|
+ })()}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Folder sidebar - resizable, hidden on mobile */}
|
|
|
<div
|
|
|
ref={sidebarRef}
|
|
|
- className="flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col relative"
|
|
|
+ className="hidden lg:flex flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
|
|
|
style={{ width: `${sidebarWidth}px` }}
|
|
|
>
|
|
|
{/* Resize handle - drag to resize, double-click to reset */}
|
|
|
@@ -1500,12 +1640,12 @@ export function FileManagerPage() {
|
|
|
</div>
|
|
|
|
|
|
{/* Files area */}
|
|
|
- <div className="flex-1 flex flex-col min-w-0">
|
|
|
- {/* Search, Filter, Sort toolbar */}
|
|
|
+ <div className="flex-1 flex flex-col min-w-0 min-h-0">
|
|
|
+ {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
|
|
|
{files && files.length > 0 && (
|
|
|
- <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
|
|
|
+ <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
|
|
|
{/* Search */}
|
|
|
- <div className="relative flex-1 max-w-xs">
|
|
|
+ <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
|
|
<input
|
|
|
type="text"
|
|
|
@@ -1518,7 +1658,7 @@ export function FileManagerPage() {
|
|
|
|
|
|
{/* Type filter */}
|
|
|
<div className="flex items-center gap-2">
|
|
|
- <Filter className="w-4 h-4 text-bambu-gray" />
|
|
|
+ <Filter className="w-4 h-4 text-bambu-gray hidden sm:block" />
|
|
|
<select
|
|
|
value={filterType}
|
|
|
onChange={(e) => setFilterType(e.target.value)}
|
|
|
@@ -1537,17 +1677,25 @@ export function FileManagerPage() {
|
|
|
<div className="flex items-center gap-2">
|
|
|
<select
|
|
|
value={sortField}
|
|
|
- onChange={(e) => setSortField(e.target.value as SortField)}
|
|
|
+ onChange={(e) => {
|
|
|
+ const newField = e.target.value as SortField;
|
|
|
+ setSortField(newField);
|
|
|
+ localStorage.setItem('library-sort-field', newField);
|
|
|
+ }}
|
|
|
className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
|
|
|
>
|
|
|
- <option value="date">Date</option>
|
|
|
<option value="name">Name</option>
|
|
|
+ <option value="date">Date</option>
|
|
|
<option value="size">Size</option>
|
|
|
<option value="type">Type</option>
|
|
|
<option value="prints">Prints</option>
|
|
|
</select>
|
|
|
<button
|
|
|
- onClick={() => setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
|
|
+ onClick={() => setSortDirection((d) => {
|
|
|
+ const newDir = d === 'asc' ? 'desc' : 'asc';
|
|
|
+ localStorage.setItem('library-sort-direction', newDir);
|
|
|
+ return newDir;
|
|
|
+ })}
|
|
|
className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
|
|
|
title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
|
|
|
>
|
|
|
@@ -1561,16 +1709,16 @@ export function FileManagerPage() {
|
|
|
|
|
|
{/* Results count */}
|
|
|
{(searchQuery || filterType !== 'all') && (
|
|
|
- <span className="text-sm text-bambu-gray">
|
|
|
+ <span className="text-sm text-bambu-gray hidden sm:inline">
|
|
|
{filteredAndSortedFiles.length} of {files.length} files
|
|
|
</span>
|
|
|
)}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* Selection toolbar */}
|
|
|
+ {/* Selection toolbar - sticky on mobile below search bar */}
|
|
|
{filteredAndSortedFiles.length > 0 && (
|
|
|
- <div className="flex items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
|
|
|
+ <div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static">
|
|
|
{/* Select all / Deselect all */}
|
|
|
{selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
|
|
|
<Button
|
|
|
@@ -1578,8 +1726,8 @@ export function FileManagerPage() {
|
|
|
size="sm"
|
|
|
onClick={handleDeselectAll}
|
|
|
>
|
|
|
- <Square className="w-4 h-4 mr-1" />
|
|
|
- Deselect All
|
|
|
+ <Square className="w-4 h-4 sm:mr-1" />
|
|
|
+ <span className="hidden sm:inline">Deselect All</span>
|
|
|
</Button>
|
|
|
) : (
|
|
|
<Button
|
|
|
@@ -1587,8 +1735,8 @@ export function FileManagerPage() {
|
|
|
size="sm"
|
|
|
onClick={handleSelectAll}
|
|
|
>
|
|
|
- <CheckSquare className="w-4 h-4 mr-1" />
|
|
|
- Select All
|
|
|
+ <CheckSquare className="w-4 h-4 sm:mr-1" />
|
|
|
+ <span className="hidden sm:inline">Select All</span>
|
|
|
</Button>
|
|
|
)}
|
|
|
|
|
|
@@ -1597,57 +1745,60 @@ export function FileManagerPage() {
|
|
|
<span className="text-sm text-bambu-gray ml-2">
|
|
|
{selectedFiles.length} selected
|
|
|
</span>
|
|
|
- <div className="flex-1" />
|
|
|
- {selectedSlicedFiles.length === 1 && (
|
|
|
+ <div className="hidden sm:block flex-1" />
|
|
|
+ <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
|
|
|
+ {selectedSlicedFiles.length === 1 && (
|
|
|
+ <Button
|
|
|
+ variant="primary"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
|
|
|
+ >
|
|
|
+ <Play className="w-4 h-4 sm:mr-1" />
|
|
|
+ <span className="hidden sm:inline">Print</span>
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ {selectedSlicedFiles.length > 0 && (
|
|
|
+ <Button
|
|
|
+ variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
|
|
|
+ size="sm"
|
|
|
+ onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
|
|
|
+ disabled={addToQueueMutation.isPending}
|
|
|
+ >
|
|
|
+ <Clock className="w-4 h-4 sm:mr-1" />
|
|
|
+ <span className="hidden sm:inline">{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
<Button
|
|
|
- variant="primary"
|
|
|
+ variant="secondary"
|
|
|
size="sm"
|
|
|
- onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
|
|
|
+ onClick={() => setShowMoveModal(true)}
|
|
|
>
|
|
|
- <Play className="w-4 h-4 mr-1" />
|
|
|
- Print
|
|
|
+ <MoveRight className="w-4 h-4 sm:mr-1" />
|
|
|
+ <span className="hidden sm:inline">Move</span>
|
|
|
</Button>
|
|
|
- )}
|
|
|
- {selectedSlicedFiles.length > 0 && (
|
|
|
<Button
|
|
|
- variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
|
|
|
+ variant="danger"
|
|
|
size="sm"
|
|
|
- onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
|
|
|
- disabled={addToQueueMutation.isPending}
|
|
|
+ onClick={() => {
|
|
|
+ if (selectedFiles.length === 1) {
|
|
|
+ setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
|
|
|
+ } else {
|
|
|
+ setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
|
|
|
+ }
|
|
|
+ }}
|
|
|
>
|
|
|
- <Clock className="w-4 h-4 mr-1" />
|
|
|
- {addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
|
|
|
+ <Trash2 className="w-4 h-4 sm:mr-1" />
|
|
|
+ <span className="hidden sm:inline">Delete</span>
|
|
|
</Button>
|
|
|
- )}
|
|
|
- <Button
|
|
|
- variant="secondary"
|
|
|
- size="sm"
|
|
|
- onClick={() => setShowMoveModal(true)}
|
|
|
- >
|
|
|
- <MoveRight className="w-4 h-4 mr-1" />
|
|
|
- Move
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- variant="danger"
|
|
|
- size="sm"
|
|
|
- onClick={() => {
|
|
|
- if (selectedFiles.length === 1) {
|
|
|
- setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
|
|
|
- } else {
|
|
|
- setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
|
|
|
- }
|
|
|
- }}
|
|
|
- >
|
|
|
- <Trash2 className="w-4 h-4 mr-1" />
|
|
|
- Delete
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- variant="secondary"
|
|
|
- size="sm"
|
|
|
- onClick={handleDeselectAll}
|
|
|
- >
|
|
|
- Clear
|
|
|
- </Button>
|
|
|
+ <Button
|
|
|
+ variant="secondary"
|
|
|
+ size="sm"
|
|
|
+ onClick={handleDeselectAll}
|
|
|
+ >
|
|
|
+ <X className="w-4 h-4 sm:mr-1" />
|
|
|
+ <span className="hidden sm:inline">Clear</span>
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
</>
|
|
|
)}
|
|
|
</div>
|
|
|
@@ -1693,28 +1844,31 @@ export function FileManagerPage() {
|
|
|
</Button>
|
|
|
</div>
|
|
|
) : viewMode === 'grid' ? (
|
|
|
- <div className="flex-1 overflow-y-auto">
|
|
|
+ <div className="flex-1 lg:overflow-y-auto">
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
|
|
{filteredAndSortedFiles.map((file) => (
|
|
|
<FileCard
|
|
|
key={file.id}
|
|
|
file={file}
|
|
|
isSelected={selectedFiles.includes(file.id)}
|
|
|
+ isMobile={isMobile}
|
|
|
onSelect={handleFileSelect}
|
|
|
onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
|
|
|
onDownload={handleDownload}
|
|
|
onAddToQueue={(id) => addToQueueMutation.mutate([id])}
|
|
|
onPrint={setPrintFile}
|
|
|
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
|
|
|
+ onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
|
|
|
+ thumbnailVersion={thumbnailVersions[file.id]}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
) : (
|
|
|
- <div className="flex-1 overflow-y-auto">
|
|
|
+ <div className="flex-1 lg:overflow-y-auto">
|
|
|
<div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
|
|
|
- {/* List header */}
|
|
|
- <div className="grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
|
|
|
+ {/* List header - hidden on mobile, show simplified on small screens */}
|
|
|
+ <div className="hidden sm:grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
|
|
|
<div className="w-6" />
|
|
|
<div>Name</div>
|
|
|
<div>Type</div>
|
|
|
@@ -1745,7 +1899,7 @@ export function FileManagerPage() {
|
|
|
<div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
|
|
|
{file.thumbnail_path ? (
|
|
|
<img
|
|
|
- src={api.getLibraryFileThumbnailUrl(file.id)}
|
|
|
+ src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
|
|
|
alt=""
|
|
|
className="w-full h-full object-cover"
|
|
|
/>
|
|
|
@@ -1760,7 +1914,7 @@ export function FileManagerPage() {
|
|
|
<div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
|
|
|
<div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
|
|
|
<img
|
|
|
- src={api.getLibraryFileThumbnailUrl(file.id)}
|
|
|
+ src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
|
|
|
alt={file.filename}
|
|
|
className="w-full h-full object-contain"
|
|
|
/>
|
|
|
@@ -1822,6 +1976,16 @@ export function FileManagerPage() {
|
|
|
>
|
|
|
<Pencil className="w-4 h-4" />
|
|
|
</button>
|
|
|
+ {file.file_type === 'stl' && (
|
|
|
+ <button
|
|
|
+ onClick={() => singleThumbnailMutation.mutate(file.id)}
|
|
|
+ className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
|
|
|
+ title="Generate Thumbnail"
|
|
|
+ disabled={singleThumbnailMutation.isPending}
|
|
|
+ >
|
|
|
+ <Image className="w-4 h-4" />
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
<button
|
|
|
onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
|
|
|
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
|