File Manager: external link, notes and photos on library files (#3128)

This commit is contained in:
Thomansky
2026-09-26 08:56:48 +02:00
committed by GitHub
parent 222f28c7f8
commit 12dddada0a
49 changed files with 4817 additions and 57 deletions
+241 -7
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useState, useRef, useCallback, useMemo, useEffect, lazy, Suspense } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -45,6 +45,12 @@ import {
Lock,
FolderSymlink,
Tag as TagIcon,
FileText,
FileSpreadsheet,
Info,
Globe,
StickyNote,
Camera,
} from 'lucide-react';
import { api } from '../api/client';
import type {
@@ -68,6 +74,7 @@ import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
import { FileUploadModal } from '../components/FileUploadModal';
import { FolderReadmePanel } from '../components/FolderReadmePanel';
import { LibraryTagsModal } from '../components/LibraryTagsModal';
import { LibraryFileDetailsModal } from '../components/LibraryFileDetailsModal';
import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
import { useToast } from '../contexts/ToastContext';
import { usePageFileDrop } from '../hooks/usePageFileDrop';
@@ -82,6 +89,23 @@ type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
type SortDirection = 'asc' | 'desc';
type TFunction = (key: string, options?: Record<string, unknown>) => string;
// Document previews (#2976) are code-split: pdf.js and the spreadsheet
// parsers only load when a preview is actually opened.
const PdfPreviewModal = lazy(() =>
import('../components/PdfPreviewModal').then((m) => ({ default: m.PdfPreviewModal }))
);
const SpreadsheetPreviewModal = lazy(() =>
import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
);
function isSpreadsheetType(fileType: string): boolean {
return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
}
function isStepType(fileType: string): boolean {
return fileType === 'step' || fileType === 'stp';
}
// New Folder Modal
interface NewFolderModalProps {
parentId: number | null;
@@ -761,7 +785,9 @@ interface FileCardProps {
desktopSlicer: SlicerType;
canSlice?: boolean;
onPreview3d?: (file: LibraryFileListItem) => void;
onPreviewDocument?: (file: LibraryFileListItem) => void;
onRename?: (file: LibraryFileListItem) => void;
onDetails?: (file: LibraryFileListItem) => void;
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
onTagClick?: (tagId: number) => void;
thumbnailVersion?: number;
@@ -772,7 +798,7 @@ interface FileCardProps {
t: TFunction;
}
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
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) {
// Viewport coordinates rather than a flag, because the menu is rendered by
// `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
@@ -814,7 +840,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
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')) {
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" />,
@@ -823,6 +849,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
});
}
if (onPreviewDocument && (file.file_type === 'pdf' || isSpreadsheetType(file.file_type))) {
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),
disabled: !canPreview3d,
title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
});
}
menuItems.push({
label: t('common.download'),
icon: <Download className="w-4 h-4" />,
@@ -839,6 +874,24 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
title: !canRename ? t('fileManager.noPermissionRenameFile') : undefined,
});
}
if (onDetails) {
menuItems.push({
label: t('fileManager.details.title'),
icon: <Info className="w-4 h-4" />,
onClick: () => onDetails(file),
disabled: !canPreview3d,
title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
});
}
if (file.external_url) {
menuItems.push({
label: t('fileManager.details.openLink'),
icon: <Globe className="w-4 h-4" />,
// The URL is stored by whoever owns the file, so the opened page must
// not get a handle on this window (#3077).
onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
});
}
if (onGenerateThumbnail && file.file_type === 'stl') {
menuItems.push({
label: t('fileManager.generateThumbnail'),
@@ -874,6 +927,10 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
alt={file.filename}
className="w-full h-full object-cover"
/>
) : file.file_type === 'pdf' ? (
<FileText className="w-12 h-12 text-bambu-gray/30" />
) : isSpreadsheetType(file.file_type) ? (
<FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
) : (
<FileBox className="w-12 h-12 text-bambu-gray/30" />
)}
@@ -884,6 +941,9 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
// that the file is already sliced and ready to print (#1543).
: file.file_type === 'gcode' || file.file_type === 'gcode.3mf' ? 'bg-blue-500/90 text-white'
: file.file_type === 'stl' ? 'bg-purple-500/90 text-white'
: isStepType(file.file_type) ? 'bg-amber-500/90 text-white'
: file.file_type === 'pdf' ? 'bg-red-500/90 text-white'
: isSpreadsheetType(file.file_type) ? 'bg-teal-500/90 text-white'
: 'bg-bambu-gray/90 text-white'
}`}>
{file.file_type.toUpperCase()}
@@ -923,6 +983,47 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
{t('fileManager.printedCount', { count: file.print_count })}
</div>
)}
{/* Metadata indicators (#3077): link, notes, photos. The link opens in
a new tab like the archive card's globe; the others open Details. */}
{(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()}>
{file.external_url && (
<a
href={file.external_url}
target="_blank"
rel="noopener noreferrer"
className="p-0.5 rounded hover:text-bambu-green"
title={t('fileManager.details.openLink')}
aria-label={t('fileManager.details.openLink')}
>
<Globe className="w-3.5 h-3.5" />
</a>
)}
{file.has_notes && (
<button
type="button"
onClick={() => onDetails?.(file)}
className="p-0.5 rounded hover:text-bambu-green"
title={t('fileManager.details.hasNotes')}
aria-label={t('fileManager.details.hasNotes')}
>
<StickyNote className="w-3.5 h-3.5" />
</button>
)}
{(file.photo_count ?? 0) > 0 && (
<button
type="button"
onClick={() => onDetails?.(file)}
className="flex items-center gap-0.5 p-0.5 rounded hover:text-bambu-green"
title={t('fileManager.details.photoCount', { count: file.photo_count })}
aria-label={t('fileManager.details.photoCount', { count: file.photo_count })}
>
<Camera className="w-3.5 h-3.5" />
<span>{file.photo_count}</span>
</button>
)}
</div>
)}
{authEnabled && file.created_by_username && (
<div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
<User className="w-3 h-3" />
@@ -1026,6 +1127,9 @@ export function FileManagerPage() {
const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
});
@@ -1646,6 +1750,30 @@ export function FileManagerPage() {
},
});
// Returns the snapshot callback the preview components call with their
// first render, or undefined when nothing should be persisted — the file
// already has a thumbnail, or the user may not update it (#2976).
const previewSnapshotHandler = useCallback(
(file: LibraryFileListItem): ((blob: Blob) => void) | undefined => {
if (file.thumbnail_path) return undefined;
if (!canModify('library', 'update', file.created_by_id)) return undefined;
return (blob: Blob) => {
api
.uploadLibraryPreviewThumbnail(file.id, blob)
.then((res) => {
if (res.updated) {
setThumbnailVersions((prev) => ({ ...prev, [file.id]: (prev[file.id] || 0) + 1 }));
queryClient.invalidateQueries({ queryKey: ['library-files'] });
}
})
.catch(() => {
// Thumbnail persistence is best-effort; the preview already rendered.
});
};
},
[canModify, queryClient]
);
const handleDownload = (id: number) => {
api.downloadLibraryFile(id).catch((err) => {
console.error('Library file download failed:', err);
@@ -2447,7 +2575,12 @@ export function FileManagerPage() {
setViewerFile(f);
}
}}
onPreviewDocument={(f) => {
if (f.file_type === 'pdf') setPdfPreviewFile(f);
else setSheetPreviewFile(f);
}}
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
onDetails={setDetailsFile}
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
onTagClick={toggleTagFilter}
thumbnailVersion={thumbnailVersions[file.id]}
@@ -2512,7 +2645,13 @@ export function FileManagerPage() {
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<FileBox className="w-5 h-5 text-bambu-gray/50" />
{file.file_type === 'pdf' ? (
<FileText className="w-5 h-5 text-bambu-gray/50" />
) : isSpreadsheetType(file.file_type) ? (
<FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
) : (
<FileBox className="w-5 h-5 text-bambu-gray/50" />
)}
</div>
)}
</div>
@@ -2530,7 +2669,34 @@ export function FileManagerPage() {
)}
</div>
<div className="min-w-0">
<div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm text-white truncate">{file.print_name || file.filename}</span>
{/* Metadata indicators (#3077), same set as the card. */}
{file.external_url && (
<a
href={file.external_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
title={t('fileManager.details.openLink')}
aria-label={t('fileManager.details.openLink')}
>
<Globe className="w-3.5 h-3.5" />
</a>
)}
{file.has_notes && (
<span className="flex-shrink-0 text-bambu-gray" title={t('fileManager.details.hasNotes')} aria-label={t('fileManager.details.hasNotes')}>
<StickyNote className="w-3.5 h-3.5" />
</span>
)}
{(file.photo_count ?? 0) > 0 && (
<span className="flex-shrink-0 flex items-center gap-0.5 text-xs text-bambu-gray" title={t('fileManager.details.photoCount', { count: file.photo_count })}>
<Camera className="w-3.5 h-3.5" />
{file.photo_count}
</span>
)}
</div>
{/* #2680: last-modified date under the name, toggled from
the toolbar. Real on-disk mtime when known, else created_at. */}
{showModified && (
@@ -2560,6 +2726,9 @@ export function FileManagerPage() {
file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
: (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
: file.file_type === 'stl' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400'
: isStepType(file.file_type) ? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'
: file.file_type === 'pdf' ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
: isSpreadsheetType(file.file_type) ? 'bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400'
: 'bg-bambu-gray/20 text-bambu-gray'
}`}>
{file.file_type.toUpperCase()}
@@ -2642,7 +2811,7 @@ export function FileManagerPage() {
<Play className="w-4 h-4" />
</button>
)}
{(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl') && (
{(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl' || isStepType(file.file_type)) && (
<button
onClick={() => {
if (!hasPermission('library:read')) return;
@@ -2657,12 +2826,30 @@ export function FileManagerPage() {
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}
title={hasPermission('library:read') ? t('fileManager.preview3d') : t('fileManager.noPermissionPreview')}
disabled={!hasPermission('library:read')}
>
<Box className="w-4 h-4" />
</button>
)}
{(file.file_type === 'pdf' || isSpreadsheetType(file.file_type)) && (
<button
onClick={() => {
if (!hasPermission('library:read')) return;
if (file.file_type === 'pdf') setPdfPreviewFile(file);
else setSheetPreviewFile(file);
}}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:read')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
disabled={!hasPermission('library:read')}
>
{file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />}
</button>
)}
<button
onClick={() => hasPermission('library:read') && handleDownload(file.id)}
className={`p-1.5 rounded transition-colors ${
@@ -2675,6 +2862,18 @@ export function FileManagerPage() {
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => hasPermission('library:read') && setDetailsFile(file)}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:read')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:read') ? t('fileManager.details.title') : t('fileManager.noPermissionPreview')}
disabled={!hasPermission('library:read')}
>
<Info className="w-4 h-4" />
</button>
<button
onClick={() => canModify('library', 'update', file.created_by_id) && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
className={`p-1.5 rounded transition-colors ${
@@ -2871,6 +3070,9 @@ export function FileManagerPage() {
title={viewerFile.print_name || viewerFile.filename}
fileType={viewerFile.file_type}
onClose={() => setViewerFile(null)}
// STEP has no server-side renderer; persist the first client render
// as the grid thumbnail (#2976).
onSnapshot={isStepType(viewerFile.file_type) ? previewSnapshotHandler(viewerFile) : undefined}
onSliceWithBambuddy={
// Only offer in-app slicing on files the SliceModal can actually
// handle (matches the file-row Cog visibility check at :2127).
@@ -2885,6 +3087,38 @@ export function FileManagerPage() {
/>
)}
{(pdfPreviewFile || sheetPreviewFile) && (
<Suspense fallback={null}>
{pdfPreviewFile && (
<PdfPreviewModal
libraryFileId={pdfPreviewFile.id}
filename={pdfPreviewFile.print_name || pdfPreviewFile.filename}
fileSize={pdfPreviewFile.file_size}
onClose={() => setPdfPreviewFile(null)}
onSnapshot={previewSnapshotHandler(pdfPreviewFile)}
/>
)}
{sheetPreviewFile && (
<SpreadsheetPreviewModal
libraryFileId={sheetPreviewFile.id}
filename={sheetPreviewFile.print_name || sheetPreviewFile.filename}
fileType={sheetPreviewFile.file_type}
fileSize={sheetPreviewFile.file_size}
onClose={() => setSheetPreviewFile(null)}
onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
/>
)}
</Suspense>
)}
{detailsFile && (
<LibraryFileDetailsModal
file={detailsFile}
canEdit={canModify('library', 'update', detailsFile.created_by_id)}
onClose={() => setDetailsFile(null)}
/>
)}
{renameItem && (
<RenameModal
type={renameItem.type}