|
@@ -51,6 +51,7 @@ import {
|
|
|
Globe,
|
|
Globe,
|
|
|
StickyNote,
|
|
StickyNote,
|
|
|
Camera,
|
|
Camera,
|
|
|
|
|
+ Eye,
|
|
|
} from 'lucide-react';
|
|
} from 'lucide-react';
|
|
|
import { api } from '../api/client';
|
|
import { api } from '../api/client';
|
|
|
import type {
|
|
import type {
|
|
@@ -97,6 +98,9 @@ const PdfPreviewModal = lazy(() =>
|
|
|
const SpreadsheetPreviewModal = lazy(() =>
|
|
const SpreadsheetPreviewModal = lazy(() =>
|
|
|
import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
|
|
import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
|
|
|
);
|
|
);
|
|
|
|
|
+const ImagePreviewModal = lazy(() =>
|
|
|
|
|
+ import('../components/ImagePreviewModal').then((m) => ({ default: m.ImagePreviewModal }))
|
|
|
|
|
+);
|
|
|
|
|
|
|
|
function isSpreadsheetType(fileType: string): boolean {
|
|
function isSpreadsheetType(fileType: string): boolean {
|
|
|
return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
|
|
return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
|
|
@@ -106,6 +110,68 @@ function isStepType(fileType: string): boolean {
|
|
|
return fileType === 'step' || fileType === 'stp';
|
|
return fileType === 'step' || fileType === 'stp';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Mirrors IMAGE_EXTENSIONS in routes/library.py — the types the server both
|
|
|
|
|
+// stores and renders a thumbnail for, and so the ones that get an image icon.
|
|
|
|
|
+const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif']);
|
|
|
|
|
+
|
|
|
|
|
+// The subset ImagePreviewModal can actually show: it hands the bytes to an
|
|
|
|
|
+// <img>, and outside Safari no browser decodes TIFF. Offering the preview
|
|
|
|
|
+// would download up to 50 MB only to report "cannot be previewed", so TIFF
|
|
|
|
|
+// keeps its server-rendered thumbnail and no preview (#2976).
|
|
|
|
|
+const PREVIEWABLE_IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
|
|
|
|
+
|
|
|
|
|
+function isImageType(fileType: string): boolean {
|
|
|
|
|
+ return IMAGE_TYPES.has(fileType.toLowerCase());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isPreviewableImageType(fileType: string): boolean {
|
|
|
|
|
+ return PREVIEWABLE_IMAGE_TYPES.has(fileType.toLowerCase());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Which files have a preview at all: what a double-click opens, and what the
|
|
|
|
|
+// toolbar's Preview button appears for (#2976). Sliced files go to the
|
|
|
|
|
+// full-page gcode viewer, everything else to a modal.
|
|
|
|
|
+function isPreviewableLibraryFile(file: LibraryFileListItem): boolean {
|
|
|
|
|
+ const type = file.file_type;
|
|
|
|
|
+ return (
|
|
|
|
|
+ isSlicedLibraryFile(file) ||
|
|
|
|
|
+ type === '3mf' ||
|
|
|
|
|
+ type === 'stl' ||
|
|
|
|
|
+ isStepType(type) ||
|
|
|
|
|
+ type === 'pdf' ||
|
|
|
|
|
+ isSpreadsheetType(type) ||
|
|
|
|
|
+ isPreviewableImageType(type)
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Spread onto a card/row subtree that is not "the row": its own controls must
|
|
|
|
|
+// neither toggle the selection nor open the preview. `dblclick` is a separate
|
|
|
|
|
+// native event from `click`, so stopping the click alone still lets the second
|
|
|
|
|
+// click of a double-click reach the row's onDoubleClick (#2976).
|
|
|
|
|
+const stopRowActivation = {
|
|
|
|
|
+ onClick: (e: React.MouseEvent) => e.stopPropagation(),
|
|
|
|
|
+ onDoubleClick: (e: React.MouseEvent) => e.stopPropagation(),
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// Whether the preview is the 3D one, which has its own menu label.
|
|
|
|
|
+function isModelPreview(file: LibraryFileListItem): boolean {
|
|
|
|
|
+ return isSlicedLibraryFile(file) || file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function documentPreviewIcon(fileType: string) {
|
|
|
|
|
+ if (fileType === 'pdf') return <FileText className="w-4 h-4" />;
|
|
|
|
|
+ if (isSpreadsheetType(fileType)) return <FileSpreadsheet className="w-4 h-4" />;
|
|
|
|
|
+ return <Image className="w-4 h-4" />;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Types the server renders a thumbnail for on request, STL through trimesh
|
|
|
|
|
+// and PDF through PDFium (#2976), so the per-file "Generate thumbnail" action
|
|
|
|
|
+// applies to them. Mirrors the file types batch_generate_stl_thumbnails in
|
|
|
|
|
+// routes/library.py selects.
|
|
|
|
|
+function hasServerThumbnail(fileType: string): boolean {
|
|
|
|
|
+ return fileType === 'stl' || fileType === 'pdf';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// New Folder Modal
|
|
// New Folder Modal
|
|
|
interface NewFolderModalProps {
|
|
interface NewFolderModalProps {
|
|
|
parentId: number | null;
|
|
parentId: number | null;
|
|
@@ -784,8 +850,7 @@ interface FileCardProps {
|
|
|
// (#3029), so offering one there would only ever fail.
|
|
// (#3029), so offering one there would only ever fail.
|
|
|
desktopSlicer: SlicerType;
|
|
desktopSlicer: SlicerType;
|
|
|
canSlice?: boolean;
|
|
canSlice?: boolean;
|
|
|
- onPreview3d?: (file: LibraryFileListItem) => void;
|
|
|
|
|
- onPreviewDocument?: (file: LibraryFileListItem) => void;
|
|
|
|
|
|
|
+ onPreview?: (file: LibraryFileListItem) => void;
|
|
|
onRename?: (file: LibraryFileListItem) => void;
|
|
onRename?: (file: LibraryFileListItem) => void;
|
|
|
onDetails?: (file: LibraryFileListItem) => void;
|
|
onDetails?: (file: LibraryFileListItem) => void;
|
|
|
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
|
|
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
|
|
@@ -798,7 +863,7 @@ interface FileCardProps {
|
|
|
t: TFunction;
|
|
t: TFunction;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onPreviewDocument, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
|
|
|
|
|
|
|
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
|
|
|
// Viewport coordinates rather than a flag, because the menu is rendered by
|
|
// Viewport coordinates rather than a flag, because the menu is rendered by
|
|
|
// `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
|
|
// `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
|
|
|
// card it belongs to is only ~270px tall for a bare STL, which is shorter
|
|
// card it belongs to is only ~270px tall for a bare STL, which is shorter
|
|
@@ -840,20 +905,12 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
|
|
title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
|
|
title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
- if (onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf' || isStepType(file.file_type))) {
|
|
|
|
|
- menuItems.push({
|
|
|
|
|
- label: t('fileManager.preview3d'),
|
|
|
|
|
- icon: <Box className="w-4 h-4" />,
|
|
|
|
|
- onClick: () => onPreview3d(file),
|
|
|
|
|
- disabled: !canPreview3d,
|
|
|
|
|
- title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- if (onPreviewDocument && (file.file_type === 'pdf' || isSpreadsheetType(file.file_type))) {
|
|
|
|
|
|
|
+ if (onPreview && isPreviewableLibraryFile(file)) {
|
|
|
|
|
+ const modelPreview = isModelPreview(file);
|
|
|
menuItems.push({
|
|
menuItems.push({
|
|
|
- label: t('fileManager.preview.open'),
|
|
|
|
|
- icon: file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />,
|
|
|
|
|
- onClick: () => onPreviewDocument(file),
|
|
|
|
|
|
|
+ label: modelPreview ? t('fileManager.preview3d') : t('fileManager.preview.open'),
|
|
|
|
|
+ icon: modelPreview ? <Box className="w-4 h-4" /> : documentPreviewIcon(file.file_type),
|
|
|
|
|
+ onClick: () => onPreview(file),
|
|
|
disabled: !canPreview3d,
|
|
disabled: !canPreview3d,
|
|
|
title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
|
|
title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
|
|
|
});
|
|
});
|
|
@@ -892,7 +949,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
|
|
onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
|
|
onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
- if (onGenerateThumbnail && file.file_type === 'stl') {
|
|
|
|
|
|
|
+ if (onGenerateThumbnail && hasServerThumbnail(file.file_type)) {
|
|
|
menuItems.push({
|
|
menuItems.push({
|
|
|
label: t('fileManager.generateThumbnail'),
|
|
label: t('fileManager.generateThumbnail'),
|
|
|
icon: <Image className="w-4 h-4" />,
|
|
icon: <Image className="w-4 h-4" />,
|
|
@@ -918,6 +975,9 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
|
|
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
|
|
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
|
|
|
}`}
|
|
}`}
|
|
|
onClick={() => onSelect(file.id)}
|
|
onClick={() => onSelect(file.id)}
|
|
|
|
|
+ // Double-click opens the preview (#2976). The two clicks that precede it
|
|
|
|
|
+ // toggle the selection twice, so the selection is left as it was.
|
|
|
|
|
+ onDoubleClick={() => onPreview?.(file)}
|
|
|
>
|
|
>
|
|
|
{/* Thumbnail */}
|
|
{/* Thumbnail */}
|
|
|
<div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
|
|
<div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden rounded-t-lg">
|
|
@@ -931,6 +991,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
|
|
<FileText className="w-12 h-12 text-bambu-gray/30" />
|
|
<FileText className="w-12 h-12 text-bambu-gray/30" />
|
|
|
) : isSpreadsheetType(file.file_type) ? (
|
|
) : isSpreadsheetType(file.file_type) ? (
|
|
|
<FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
|
|
<FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
|
|
|
|
|
+ ) : isImageType(file.file_type) ? (
|
|
|
|
|
+ <Image className="w-12 h-12 text-bambu-gray/30" />
|
|
|
) : (
|
|
) : (
|
|
|
<FileBox className="w-12 h-12 text-bambu-gray/30" />
|
|
<FileBox className="w-12 h-12 text-bambu-gray/30" />
|
|
|
)}
|
|
)}
|
|
@@ -986,7 +1048,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
|
|
{/* Metadata indicators (#3077): link, notes, photos. The link opens in
|
|
{/* Metadata indicators (#3077): link, notes, photos. The link opens in
|
|
|
a new tab like the archive card's globe; the others open Details. */}
|
|
a new tab like the archive card's globe; the others open Details. */}
|
|
|
{(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
|
|
{(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
|
|
|
- <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
|
+ <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" {...stopRowActivation}>
|
|
|
{file.external_url && (
|
|
{file.external_url && (
|
|
|
<a
|
|
<a
|
|
|
href={file.external_url}
|
|
href={file.external_url}
|
|
@@ -1039,7 +1101,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
{(file.tags?.length ?? 0) > 0 && (
|
|
{(file.tags?.length ?? 0) > 0 && (
|
|
|
- <div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
|
+ <div className="mt-2 flex flex-wrap gap-1" {...stopRowActivation}>
|
|
|
{file.tags!.map((tg) => (
|
|
{file.tags!.map((tg) => (
|
|
|
<button
|
|
<button
|
|
|
key={tg.id}
|
|
key={tg.id}
|
|
@@ -1057,7 +1119,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
|
|
{/* Actions - hover-revealed with a mouse, always there without one (#2865) */}
|
|
|
- <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
|
+ <div className="absolute bottom-2 right-2 transition-opacity can-hover:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100" {...stopRowActivation}>
|
|
|
<button
|
|
<button
|
|
|
onClick={(e) => {
|
|
onClick={(e) => {
|
|
|
// No open/close toggle: the menu's own outside-mousedown handler
|
|
// No open/close toggle: the menu's own outside-mousedown handler
|
|
@@ -1130,6 +1192,7 @@ export function FileManagerPage() {
|
|
|
const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
|
|
const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
|
|
|
const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
|
|
const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
|
|
|
const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
|
|
const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
|
|
|
|
|
+ const [imagePreviewFile, setImagePreviewFile] = useState<LibraryFileListItem | null>(null);
|
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
|
|
|
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
|
|
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
|
|
|
});
|
|
});
|
|
@@ -1676,6 +1739,34 @@ export function FileManagerPage() {
|
|
|
onError: (error: Error) => showToast(error.message, 'error'),
|
|
onError: (error: Error) => showToast(error.message, 'error'),
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // The one way into a preview (#2976): the kebab entry, the action-strip
|
|
|
|
|
+ // icon, a double-click on the card or row, and the toolbar button all end
|
|
|
|
|
+ // up here. Sliced files open the full-page gcode viewer the archive card
|
|
|
|
|
+ // uses; everything else opens the modal for its type. A file with no
|
|
|
|
|
+ // preview does nothing.
|
|
|
|
|
+ const openPreview = useCallback((file: LibraryFileListItem) => {
|
|
|
|
|
+ if (!hasPermission('library:read')) return;
|
|
|
|
|
+ if (isSlicedLibraryFile(file)) {
|
|
|
|
|
+ navigate(`/gcode-viewer?library_file=${file.id}`);
|
|
|
|
|
+ } else if (file.file_type === '3mf' || file.file_type === 'stl' || isStepType(file.file_type)) {
|
|
|
|
|
+ setViewerFile(file);
|
|
|
|
|
+ } else if (file.file_type === 'pdf') {
|
|
|
|
|
+ setPdfPreviewFile(file);
|
|
|
|
|
+ } else if (isSpreadsheetType(file.file_type)) {
|
|
|
|
|
+ setSheetPreviewFile(file);
|
|
|
|
|
+ } else if (isPreviewableImageType(file.file_type)) {
|
|
|
|
|
+ setImagePreviewFile(file);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [hasPermission, navigate]);
|
|
|
|
|
+
|
|
|
|
|
+ // The toolbar's Preview button acts on one file, so it is offered only for
|
|
|
|
|
+ // a single previewable selection.
|
|
|
|
|
+ const previewSelection = useMemo(() => {
|
|
|
|
|
+ if (!files || selectedFiles.length !== 1) return null;
|
|
|
|
|
+ const file = files.find((f) => f.id === selectedFiles[0]);
|
|
|
|
|
+ return file && isPreviewableLibraryFile(file) ? file : null;
|
|
|
|
|
+ }, [files, selectedFiles]);
|
|
|
|
|
+
|
|
|
// Get sliced files from selection
|
|
// Get sliced files from selection
|
|
|
const selectedSlicedFiles = useMemo(() => {
|
|
const selectedSlicedFiles = useMemo(() => {
|
|
|
if (!files) return [];
|
|
if (!files) return [];
|
|
@@ -2411,6 +2502,18 @@ export function FileManagerPage() {
|
|
|
</span>
|
|
</span>
|
|
|
<div className="hidden sm:block flex-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">
|
|
<div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
|
|
|
|
|
+ {previewSelection && (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => openPreview(previewSelection)}
|
|
|
|
|
+ disabled={!hasPermission('library:read')}
|
|
|
|
|
+ title={!hasPermission('library:read') ? t('fileManager.noPermissionPreview') : undefined}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Eye className="w-4 h-4 sm:mr-1" />
|
|
|
|
|
+ <span className="hidden sm:inline">{t('fileManager.preview.open')}</span>
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
{/* Print used to disappear the moment a second sliced file was
|
|
{/* Print used to disappear the moment a second sliced file was
|
|
|
selected. Selecting several is now how you say "same job,
|
|
selected. Selecting several is now how you say "same job,
|
|
|
different printers" (#671) — one queue item, whichever
|
|
different printers" (#671) — one queue item, whichever
|
|
@@ -2564,21 +2667,7 @@ export function FileManagerPage() {
|
|
|
onRunPipeline={setRunPipelineFile}
|
|
onRunPipeline={setRunPipelineFile}
|
|
|
useSlicerApi={settings?.use_slicer_api ?? false}
|
|
useSlicerApi={settings?.use_slicer_api ?? false}
|
|
|
canSlice={canSlice()}
|
|
canSlice={canSlice()}
|
|
|
- onPreview3d={(f) => {
|
|
|
|
|
- // Sliced files (.gcode / .gcode.3mf) open the same
|
|
|
|
|
- // full-page gcode viewer the archive card uses, so
|
|
|
|
|
- // the two paths feel consistent. STL / source 3MF
|
|
|
|
|
- // continue to use the in-app 3D model viewer modal.
|
|
|
|
|
- if (isSlicedLibraryFile(f)) {
|
|
|
|
|
- navigate(`/gcode-viewer?library_file=${f.id}`);
|
|
|
|
|
- } else {
|
|
|
|
|
- setViewerFile(f);
|
|
|
|
|
- }
|
|
|
|
|
- }}
|
|
|
|
|
- onPreviewDocument={(f) => {
|
|
|
|
|
- if (f.file_type === 'pdf') setPdfPreviewFile(f);
|
|
|
|
|
- else setSheetPreviewFile(f);
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ onPreview={openPreview}
|
|
|
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
|
|
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
|
|
|
onDetails={setDetailsFile}
|
|
onDetails={setDetailsFile}
|
|
|
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
|
|
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
|
|
@@ -2624,6 +2713,8 @@ export function FileManagerPage() {
|
|
|
selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
|
|
selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
|
|
|
}`}
|
|
}`}
|
|
|
onClick={() => handleFileSelect(file.id)}
|
|
onClick={() => handleFileSelect(file.id)}
|
|
|
|
|
+ // Double-click opens the preview (#2976), as in the grid.
|
|
|
|
|
+ onDoubleClick={() => openPreview(file)}
|
|
|
>
|
|
>
|
|
|
{/* Checkbox */}
|
|
{/* Checkbox */}
|
|
|
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
|
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
|
@@ -2649,6 +2740,8 @@ export function FileManagerPage() {
|
|
|
<FileText className="w-5 h-5 text-bambu-gray/50" />
|
|
<FileText className="w-5 h-5 text-bambu-gray/50" />
|
|
|
) : isSpreadsheetType(file.file_type) ? (
|
|
) : isSpreadsheetType(file.file_type) ? (
|
|
|
<FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
|
|
<FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
|
|
|
|
|
+ ) : isImageType(file.file_type) ? (
|
|
|
|
|
+ <Image className="w-5 h-5 text-bambu-gray/50" />
|
|
|
) : (
|
|
) : (
|
|
|
<FileBox className="w-5 h-5 text-bambu-gray/50" />
|
|
<FileBox className="w-5 h-5 text-bambu-gray/50" />
|
|
|
)}
|
|
)}
|
|
@@ -2677,7 +2770,7 @@ export function FileManagerPage() {
|
|
|
href={file.external_url}
|
|
href={file.external_url}
|
|
|
target="_blank"
|
|
target="_blank"
|
|
|
rel="noopener noreferrer"
|
|
rel="noopener noreferrer"
|
|
|
- onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
|
+ {...stopRowActivation}
|
|
|
className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
|
|
className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
|
|
|
title={t('fileManager.details.openLink')}
|
|
title={t('fileManager.details.openLink')}
|
|
|
aria-label={t('fileManager.details.openLink')}
|
|
aria-label={t('fileManager.details.openLink')}
|
|
@@ -2742,7 +2835,7 @@ export function FileManagerPage() {
|
|
|
filter; minmax(0,200px) on the column lets the cell
|
|
filter; minmax(0,200px) on the column lets the cell
|
|
|
shrink/wrap on narrow viewports without pushing the
|
|
shrink/wrap on narrow viewports without pushing the
|
|
|
Actions cell off-screen. */}
|
|
Actions cell off-screen. */}
|
|
|
- <div className="min-w-0" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
|
+ <div className="min-w-0" {...stopRowActivation}>
|
|
|
{!file.tags || file.tags.length === 0 ? (
|
|
{!file.tags || file.tags.length === 0 ? (
|
|
|
<span className="text-xs text-bambu-gray/50">-</span>
|
|
<span className="text-xs text-bambu-gray/50">-</span>
|
|
|
) : (
|
|
) : (
|
|
@@ -2763,7 +2856,7 @@ export function FileManagerPage() {
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
{/* Actions */}
|
|
{/* Actions */}
|
|
|
- <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
|
+ <div className="flex items-center gap-1" {...stopRowActivation}>
|
|
|
{isSlicedLibraryFile(file) && (
|
|
{isSlicedLibraryFile(file) && (
|
|
|
<>
|
|
<>
|
|
|
<button
|
|
<button
|
|
@@ -2811,16 +2904,9 @@ export function FileManagerPage() {
|
|
|
<Play className="w-4 h-4" />
|
|
<Play className="w-4 h-4" />
|
|
|
</button>
|
|
</button>
|
|
|
)}
|
|
)}
|
|
|
- {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl' || isStepType(file.file_type)) && (
|
|
|
|
|
|
|
+ {isModelPreview(file) && (
|
|
|
<button
|
|
<button
|
|
|
- onClick={() => {
|
|
|
|
|
- if (!hasPermission('library:read')) return;
|
|
|
|
|
- if (isSlicedLibraryFile(file)) {
|
|
|
|
|
- navigate(`/gcode-viewer?library_file=${file.id}`);
|
|
|
|
|
- } else {
|
|
|
|
|
- setViewerFile(file);
|
|
|
|
|
- }
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ onClick={() => openPreview(file)}
|
|
|
className={`p-1.5 rounded transition-colors ${
|
|
className={`p-1.5 rounded transition-colors ${
|
|
|
hasPermission('library:read')
|
|
hasPermission('library:read')
|
|
|
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
|
|
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
|
|
@@ -2832,13 +2918,9 @@ export function FileManagerPage() {
|
|
|
<Box className="w-4 h-4" />
|
|
<Box className="w-4 h-4" />
|
|
|
</button>
|
|
</button>
|
|
|
)}
|
|
)}
|
|
|
- {(file.file_type === 'pdf' || isSpreadsheetType(file.file_type)) && (
|
|
|
|
|
|
|
+ {!isModelPreview(file) && isPreviewableLibraryFile(file) && (
|
|
|
<button
|
|
<button
|
|
|
- onClick={() => {
|
|
|
|
|
- if (!hasPermission('library:read')) return;
|
|
|
|
|
- if (file.file_type === 'pdf') setPdfPreviewFile(file);
|
|
|
|
|
- else setSheetPreviewFile(file);
|
|
|
|
|
- }}
|
|
|
|
|
|
|
+ onClick={() => openPreview(file)}
|
|
|
className={`p-1.5 rounded transition-colors ${
|
|
className={`p-1.5 rounded transition-colors ${
|
|
|
hasPermission('library:read')
|
|
hasPermission('library:read')
|
|
|
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
|
|
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
|
|
@@ -2847,7 +2929,7 @@ export function FileManagerPage() {
|
|
|
title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
|
|
title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
|
|
|
disabled={!hasPermission('library:read')}
|
|
disabled={!hasPermission('library:read')}
|
|
|
>
|
|
>
|
|
|
- {file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />}
|
|
|
|
|
|
|
+ {documentPreviewIcon(file.file_type)}
|
|
|
</button>
|
|
</button>
|
|
|
)}
|
|
)}
|
|
|
<button
|
|
<button
|
|
@@ -2886,7 +2968,7 @@ export function FileManagerPage() {
|
|
|
>
|
|
>
|
|
|
<Pencil className="w-4 h-4" />
|
|
<Pencil className="w-4 h-4" />
|
|
|
</button>
|
|
</button>
|
|
|
- {file.file_type === 'stl' && (
|
|
|
|
|
|
|
+ {hasServerThumbnail(file.file_type) && (
|
|
|
<button
|
|
<button
|
|
|
onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
|
|
onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
|
|
|
className={`p-1.5 rounded transition-colors ${
|
|
className={`p-1.5 rounded transition-colors ${
|
|
@@ -3087,7 +3169,7 @@ export function FileManagerPage() {
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {(pdfPreviewFile || sheetPreviewFile) && (
|
|
|
|
|
|
|
+ {(pdfPreviewFile || sheetPreviewFile || imagePreviewFile) && (
|
|
|
<Suspense fallback={null}>
|
|
<Suspense fallback={null}>
|
|
|
{pdfPreviewFile && (
|
|
{pdfPreviewFile && (
|
|
|
<PdfPreviewModal
|
|
<PdfPreviewModal
|
|
@@ -3108,6 +3190,14 @@ export function FileManagerPage() {
|
|
|
onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
|
|
onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|
|
|
|
|
+ {imagePreviewFile && (
|
|
|
|
|
+ <ImagePreviewModal
|
|
|
|
|
+ libraryFileId={imagePreviewFile.id}
|
|
|
|
|
+ filename={imagePreviewFile.print_name || imagePreviewFile.filename}
|
|
|
|
|
+ fileSize={imagePreviewFile.file_size}
|
|
|
|
|
+ onClose={() => setImagePreviewFile(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
</Suspense>
|
|
</Suspense>
|
|
|
)}
|
|
)}
|
|
|
|
|
|