|
@@ -1033,9 +1033,15 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// File Card
|
|
// File Card
|
|
|
|
|
+// The mark on a file a ?highlight= link points at; the Archives page's colour.
|
|
|
|
|
+const HIGHLIGHT_CARD_STYLE = { outline: '4px solid #facc15', outlineOffset: '2px' } as const;
|
|
|
|
|
+const HIGHLIGHT_ROW_STYLE = { outline: '2px solid #facc15', outlineOffset: '-2px' } as const;
|
|
|
|
|
+
|
|
|
interface FileCardProps {
|
|
interface FileCardProps {
|
|
|
file: LibraryFileListItem;
|
|
file: LibraryFileListItem;
|
|
|
isSelected: boolean;
|
|
isSelected: boolean;
|
|
|
|
|
+ /** The file a ?highlight= link points at, marked for a few seconds. */
|
|
|
|
|
+ isHighlighted?: boolean;
|
|
|
onSelect: (id: number) => void;
|
|
onSelect: (id: number) => void;
|
|
|
onDelete: (id: number) => void;
|
|
onDelete: (id: number) => void;
|
|
|
onDownload: (id: number) => void;
|
|
onDownload: (id: number) => void;
|
|
@@ -1062,7 +1068,7 @@ interface FileCardProps {
|
|
|
t: TFunction;
|
|
t: TFunction;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-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) {
|
|
|
|
|
|
|
+function FileCard({ file, isSelected, isHighlighted, 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
|
|
@@ -1168,6 +1174,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
|
|
+ data-file-id={file.id}
|
|
|
|
|
+ style={isHighlighted ? HIGHLIGHT_CARD_STYLE : undefined}
|
|
|
className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer ${
|
|
className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer ${
|
|
|
isSelected
|
|
isSelected
|
|
|
? 'border-bambu-green ring-1 ring-bambu-green'
|
|
? 'border-bambu-green ring-1 ring-bambu-green'
|
|
@@ -1530,6 +1538,7 @@ function FileActionStrip({ file, onPrint, onSlice, onOpenInSlicer, onRunPipeline
|
|
|
interface ColumnFileRowProps {
|
|
interface ColumnFileRowProps {
|
|
|
file: LibraryFileListItem;
|
|
file: LibraryFileListItem;
|
|
|
isSelected: boolean;
|
|
isSelected: boolean;
|
|
|
|
|
+ isHighlighted?: boolean;
|
|
|
isFocused: boolean;
|
|
isFocused: boolean;
|
|
|
showModified: boolean;
|
|
showModified: boolean;
|
|
|
thumbnailVersion?: number;
|
|
thumbnailVersion?: number;
|
|
@@ -1539,11 +1548,12 @@ interface ColumnFileRowProps {
|
|
|
t: TFunction;
|
|
t: TFunction;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function ColumnFileRow({ file, isSelected, isFocused, showModified, thumbnailVersion, onSelect, onOpen, actionProps, t }: ColumnFileRowProps) {
|
|
|
|
|
|
|
+function ColumnFileRow({ file, isSelected, isHighlighted, isFocused, showModified, thumbnailVersion, onSelect, onOpen, actionProps, t }: ColumnFileRowProps) {
|
|
|
const thumbnailUrl = api.getLibraryFileThumbnailUrl(file.id);
|
|
const thumbnailUrl = api.getLibraryFileThumbnailUrl(file.id);
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
data-file-id={file.id}
|
|
data-file-id={file.id}
|
|
|
|
|
+ style={isHighlighted ? HIGHLIGHT_ROW_STYLE : undefined}
|
|
|
onClick={() => onSelect(file)}
|
|
onClick={() => onSelect(file)}
|
|
|
onDoubleClick={() => onOpen(file)}
|
|
onDoubleClick={() => onOpen(file)}
|
|
|
className={`group flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors ${
|
|
className={`group flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors ${
|
|
@@ -1745,6 +1755,39 @@ export function FileManagerPage() {
|
|
|
}
|
|
}
|
|
|
}, [searchParams]);
|
|
}, [searchParams]);
|
|
|
|
|
|
|
|
|
|
+ // ?highlight=<file id> points at one file (the queue card's "View in File
|
|
|
|
|
+ // Manager"). The link carries only the id: the folder is looked up here, so
|
|
|
|
|
+ // a file moved since it was queued is still found. The folder's listing is
|
|
|
|
|
+ // refetched before the file is looked for in it, since a cached one may
|
|
|
|
|
+ // predate the move.
|
|
|
|
|
+ const highlightParam = searchParams.get('highlight');
|
|
|
|
|
+ const [pendingHighlight, setPendingHighlight] = useState<{ fileId: number; folderId: number | null; requestedAt: number } | null>(null);
|
|
|
|
|
+ const [highlightedFileId, setHighlightedFileId] = useState<number | null>(null);
|
|
|
|
|
+ const selectedFolderIdRef = useRef(selectedFolderId);
|
|
|
|
|
+ selectedFolderIdRef.current = selectedFolderId;
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const fileId = highlightParam ? parseInt(highlightParam, 10) : NaN;
|
|
|
|
|
+ if (!Number.isFinite(fileId)) return;
|
|
|
|
|
+ let cancelled = false;
|
|
|
|
|
+ const folderAtRequest = selectedFolderIdRef.current;
|
|
|
|
|
+ api.getLibraryFile(fileId).then(
|
|
|
|
|
+ (file) => {
|
|
|
|
|
+ // Someone who opened another folder meanwhile is not pulled back.
|
|
|
|
|
+ if (cancelled || selectedFolderIdRef.current !== folderAtRequest) return;
|
|
|
|
|
+ const requestedAt = Date.now();
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
|
|
|
|
+ setSelectedFolderId(file.folder_id);
|
|
|
|
|
+ setPendingHighlight({ fileId, folderId: file.folder_id, requestedAt });
|
|
|
|
|
+ },
|
|
|
|
|
+ () => {
|
|
|
|
|
+ if (!cancelled) showToast(t('fileManager.highlightedFileNotFound'), 'warning');
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ cancelled = true;
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [highlightParam, queryClient, showToast, t]);
|
|
|
|
|
+
|
|
|
// Queries
|
|
// Queries
|
|
|
const { data: settings } = useUiSettings();
|
|
const { data: settings } = useUiSettings();
|
|
|
|
|
|
|
@@ -1894,7 +1937,7 @@ export function FileManagerPage() {
|
|
|
// folder.
|
|
// folder.
|
|
|
const paneRootOnly = viewMode === 'columns' && selectedFolderId === null && !columnsFilterActive;
|
|
const paneRootOnly = viewMode === 'columns' && selectedFolderId === null && !columnsFilterActive;
|
|
|
|
|
|
|
|
- const { data: files, isLoading: filesLoading } = useQuery({
|
|
|
|
|
|
|
+ const { data: files, isLoading: filesLoading, dataUpdatedAt: filesUpdatedAt } = useQuery({
|
|
|
queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey, paneRootOnly],
|
|
queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey, paneRootOnly],
|
|
|
// When a specific folder is selected we list its contents directly; when
|
|
// When a specific folder is selected we list its contents directly; when
|
|
|
// no folder is selected the topLevelView pseudo-node decides whether the
|
|
// no folder is selected the topLevelView pseudo-node decides whether the
|
|
@@ -1944,6 +1987,31 @@ export function FileManagerPage() {
|
|
|
[files, searchQuery, filterType, filterUsername, sortField, sortDirection],
|
|
[files, searchQuery, filterType, filterUsername, sortField, sortDirection],
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ // Once the file's folder is listed afresh, mark the file; then scroll to it
|
|
|
|
|
+ // and drop the mark after a few seconds, as the Archives page does.
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!pendingHighlight || filesLoading || !files || selectedFolderId !== pendingHighlight.folderId) return;
|
|
|
|
|
+ if (filesUpdatedAt < pendingHighlight.requestedAt) return;
|
|
|
|
|
+ if (filteredAndSortedFiles.some((f) => f.id === pendingHighlight.fileId)) {
|
|
|
|
|
+ setHighlightedFileId(pendingHighlight.fileId);
|
|
|
|
|
+ if (viewMode === 'columns') setColumnsFocusedFileId(pendingHighlight.fileId);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showToast(t('fileManager.highlightedFileNotFound'), 'warning');
|
|
|
|
|
+ }
|
|
|
|
|
+ setPendingHighlight(null);
|
|
|
|
|
+ }, [pendingHighlight, filesLoading, files, filesUpdatedAt, selectedFolderId, filteredAndSortedFiles, viewMode, showToast, t]);
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (highlightedFileId === null) return;
|
|
|
|
|
+ const scrollTimer = setTimeout(() => {
|
|
|
|
|
+ document.querySelector(`[data-file-id="${highlightedFileId}"]`)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
+ }, 100);
|
|
|
|
|
+ const clearTimer = setTimeout(() => setHighlightedFileId(null), 5000);
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ clearTimeout(scrollTimer);
|
|
|
|
|
+ clearTimeout(clearTimer);
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [highlightedFileId]);
|
|
|
|
|
+
|
|
|
// Check if disk space is low
|
|
// Check if disk space is low
|
|
|
const isDiskSpaceLow = useMemo(() => {
|
|
const isDiskSpaceLow = useMemo(() => {
|
|
|
if (!stats || !settings) return false;
|
|
if (!stats || !settings) return false;
|
|
@@ -3546,6 +3614,7 @@ export function FileManagerPage() {
|
|
|
key={file.id}
|
|
key={file.id}
|
|
|
file={file}
|
|
file={file}
|
|
|
isSelected={selectedFiles.includes(file.id)}
|
|
isSelected={selectedFiles.includes(file.id)}
|
|
|
|
|
+ isHighlighted={highlightedFileId === file.id}
|
|
|
isFocused={columnsFocusedFileId === file.id}
|
|
isFocused={columnsFocusedFileId === file.id}
|
|
|
showModified={showModified}
|
|
showModified={showModified}
|
|
|
thumbnailVersion={thumbnailVersions[file.id]}
|
|
thumbnailVersion={thumbnailVersions[file.id]}
|
|
@@ -3592,6 +3661,7 @@ export function FileManagerPage() {
|
|
|
key={file.id}
|
|
key={file.id}
|
|
|
file={file}
|
|
file={file}
|
|
|
isSelected={selectedFiles.includes(file.id)}
|
|
isSelected={selectedFiles.includes(file.id)}
|
|
|
|
|
+ isHighlighted={highlightedFileId === file.id}
|
|
|
isFocused={columnsFocusedFileId === file.id}
|
|
isFocused={columnsFocusedFileId === file.id}
|
|
|
showModified={showModified}
|
|
showModified={showModified}
|
|
|
thumbnailVersion={thumbnailVersions[file.id]}
|
|
thumbnailVersion={thumbnailVersions[file.id]}
|
|
@@ -3657,6 +3727,7 @@ export function FileManagerPage() {
|
|
|
key={file.id}
|
|
key={file.id}
|
|
|
file={file}
|
|
file={file}
|
|
|
isSelected={selectedFiles.includes(file.id)}
|
|
isSelected={selectedFiles.includes(file.id)}
|
|
|
|
|
+ isHighlighted={highlightedFileId === file.id}
|
|
|
t={t}
|
|
t={t}
|
|
|
onSelect={handleFileSelect}
|
|
onSelect={handleFileSelect}
|
|
|
onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
|
|
onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
|
|
@@ -3714,6 +3785,8 @@ export function FileManagerPage() {
|
|
|
<div
|
|
<div
|
|
|
key={file.id}
|
|
key={file.id}
|
|
|
data-testid="file-list-grid-row"
|
|
data-testid="file-list-grid-row"
|
|
|
|
|
+ data-file-id={file.id}
|
|
|
|
|
+ style={highlightedFileId === file.id ? HIGHLIGHT_ROW_STYLE : undefined}
|
|
|
className={`grid ${fileListGridColumns(authEnabled)} ${fileListGridMinWidth} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
|
|
className={`grid ${fileListGridColumns(authEnabled)} ${fileListGridMinWidth} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
|
|
|
selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
|
|
selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
|
|
|
}`}
|
|
}`}
|