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
@@ -0,0 +1,324 @@
/**
* Tests for the LibraryFileDetailsModal component (#3077).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { LibraryFileDetailsModal } from '../../components/LibraryFileDetailsModal';
import type { LibraryFileListItem } from '../../api/client';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const listItem: LibraryFileListItem = {
id: 7,
folder_id: null,
is_external: false,
filename: 'benchy.gcode.3mf',
file_type: 'gcode.3mf',
file_size: 1048576,
thumbnail_path: null,
print_count: 2,
duplicate_count: 0,
created_by_id: null,
created_by_username: null,
created_at: '2024-01-01T00:00:00Z',
fs_modified_at: null,
print_name: 'Benchy',
print_time_seconds: 3600,
filament_used_grams: 12.5,
sliced_for_model: 'X1C',
tags: [],
};
const details = {
...listItem,
folder_name: null,
project_id: null,
project_name: null,
file_path: 'library/files/benchy.gcode.3mf',
file_hash: null,
metadata: null,
last_printed_at: null,
notes: 'Print with brim',
external_url: 'https://www.printables.com/model/1',
photos: ['abc123.jpg'],
source_url: 'https://makerworld.com/models/42',
duplicates: null,
updated_at: '2024-02-01T00:00:00Z',
};
describe('LibraryFileDetailsModal', () => {
const onClose = vi.fn();
let lastUpdate: Record<string, unknown> | null = null;
beforeEach(() => {
vi.clearAllMocks();
lastUpdate = null;
server.use(
http.get('/api/v1/library/files/7', () => HttpResponse.json(details)),
http.put('/api/v1/library/files/7', async ({ request }) => {
lastUpdate = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ...details, ...lastUpdate });
}),
http.delete('/api/v1/library/files/7/photos/:filename', () =>
HttpResponse.json({ status: 'deleted', photos: [] })
)
);
});
it('renders the facts, notes, link and photos from the detail response', async () => {
render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
expect(screen.getByText('benchy.gcode.3mf')).toBeInTheDocument();
// Header badge and the Type fact.
expect(screen.getAllByText('GCODE.3MF')).toHaveLength(2);
expect(screen.getByText('1.0 MB')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByDisplayValue('Print with brim')).toBeInTheDocument();
});
expect(screen.getByDisplayValue('https://www.printables.com/model/1')).toBeInTheDocument();
expect(screen.getByText('Benchy')).toBeInTheDocument();
expect(screen.getByText('X1C')).toBeInTheDocument();
expect(screen.getByText('12.5 g')).toBeInTheDocument();
// Source provenance is a link, not an editable field.
const source = screen.getByRole('link', { name: /makerworld\.com\/models\/42/ });
expect(source).toHaveAttribute('href', 'https://makerworld.com/models/42');
expect(source).toHaveAttribute('target', '_blank');
const photo = screen.getByAltText('Photos') as HTMLImageElement;
expect(photo.src).toContain('/library/files/7/photos/abc123.jpg');
});
it('saves edited notes and link through updateLibraryFile', async () => {
const user = userEvent.setup();
render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
const notes = await screen.findByDisplayValue('Print with brim');
const saveButton = screen.getByRole('button', { name: /save/i });
// Nothing changed yet, so there is nothing to save.
expect(saveButton).toBeDisabled();
await user.clear(notes);
await user.type(notes, 'Use 0.2 mm layers');
const link = screen.getByDisplayValue('https://www.printables.com/model/1');
await user.clear(link);
await user.type(link, 'https://example.com/part ');
expect(saveButton).toBeEnabled();
await user.click(saveButton);
await waitFor(() => {
expect(lastUpdate).toEqual({ notes: 'Use 0.2 mm layers', external_url: 'https://example.com/part' });
});
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('sends an empty external_url when the link is cleared', async () => {
const user = userEvent.setup();
render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
const link = await screen.findByDisplayValue('https://www.printables.com/model/1');
await user.clear(link);
await user.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(lastUpdate).toEqual({ notes: 'Print with brim', external_url: '' });
});
});
it('is read-only without edit permission', async () => {
render(<LibraryFileDetailsModal file={listItem} canEdit={false} onClose={onClose} />);
const notes = await screen.findByDisplayValue('Print with brim');
expect(notes).toBeDisabled();
expect(screen.getByDisplayValue('https://www.printables.com/model/1')).toBeDisabled();
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Add photo')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Delete photo')).not.toBeInTheDocument();
});
it('removes a photo from the grid after deleting it', async () => {
const user = userEvent.setup();
render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
await screen.findByAltText('Photos');
await user.click(screen.getByLabelText('Delete photo'));
await waitFor(() => {
expect(screen.queryByAltText('Photos')).not.toBeInTheDocument();
});
});
it('keeps unsaved notes when a photo change refetches the file', async () => {
// Deleting a photo invalidates the detail query; the refetched file has a
// different photo list, so it is a new object and must not reseed the form.
let serverPhotos = ['abc123.jpg'];
let detailFetches = 0;
server.use(
http.get('/api/v1/library/files/7', () => {
detailFetches += 1;
return HttpResponse.json({ ...details, photos: serverPhotos });
}),
http.delete('/api/v1/library/files/7/photos/:filename', () => {
serverPhotos = [];
return HttpResponse.json({ status: 'deleted', photos: [] });
})
);
const user = userEvent.setup();
render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
const notes = await screen.findByDisplayValue('Print with brim');
await user.clear(notes);
await user.type(notes, 'Draft not saved yet');
await user.click(screen.getByLabelText('Delete photo'));
await waitFor(() => {
expect(screen.queryByAltText('Photos')).not.toBeInTheDocument();
});
await waitFor(() => expect(detailFetches).toBeGreaterThan(1));
expect(screen.getByDisplayValue('Draft not saved yet')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
describe('lightbox', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/library/files/7', () =>
HttpResponse.json({ ...details, photos: ['one.jpg', 'two.jpg', 'three.jpg'] })
)
);
});
it('opens on the photo that was clicked, not the first one', async () => {
const user = userEvent.setup();
render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
const thumbnails = await screen.findAllByAltText('Photos');
expect(thumbnails).toHaveLength(3);
await user.click(thumbnails[2]);
expect(await screen.findByText('Photo 3 of 3')).toBeInTheDocument();
const shown = screen.getByAltText('Photo 3') as HTMLImageElement;
expect(shown.src).toContain('/library/files/7/photos/three.jpg');
});
it('keeps the details modal open when the delete confirmation is dismissed', async () => {
// The confirmation renders inside the details overlay, whose root closes
// on a backdrop click — dismissing the confirmation used to discard the
// unsaved notes and link along with it.
const user = userEvent.setup();
const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
const notes = await screen.findByDisplayValue('Print with brim');
await user.clear(notes);
await user.type(notes, 'Draft not saved yet');
await user.click(screen.getAllByAltText('Photos')[0]);
await screen.findByText('Photo 1 of 3');
await user.click(container.querySelector('.text-red-400') as HTMLElement);
const confirmation = await screen.findByText('Delete Photo');
await user.click(confirmation.closest('div.fixed') as HTMLElement);
await waitFor(() => expect(screen.queryByText('Delete Photo')).not.toBeInTheDocument());
expect(screen.getByText('Photo 1 of 3')).toBeInTheDocument();
expect(screen.getByDisplayValue('Draft not saved yet')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it('closes the details modal on Escape, and only the lightbox while that is open', async () => {
const user = userEvent.setup();
render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
await screen.findByDisplayValue('Print with brim');
await user.click(screen.getAllByAltText('Photos')[1]);
await screen.findByText('Photo 2 of 3');
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByText('Photo 2 of 3')).not.toBeInTheDocument());
expect(onClose).not.toHaveBeenCalled();
await user.keyboard('{Escape}');
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('keeps the lightbox open when the delete confirmation is dismissed with Escape', async () => {
// The confirmation has its own Escape handler. With the gallery's still
// listening, one press cancelled the prompt and closed the gallery under
// it, losing the user's place in the photo list.
const user = userEvent.setup();
const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
await screen.findByDisplayValue('Print with brim');
await user.click(screen.getAllByAltText('Photos')[1]);
await screen.findByText('Photo 2 of 3');
await user.click(container.querySelector('.text-red-400') as HTMLElement);
await screen.findByText('Delete Photo');
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByText('Delete Photo')).not.toBeInTheDocument());
expect(screen.getByText('Photo 2 of 3')).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
describe('after the last photo is deleted from inside the lightbox', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/library/files/7', () => HttpResponse.json({ ...details, photos: ['only.jpg'] }))
);
});
const deleteTheOnlyPhotoFromTheLightbox = async (
user: ReturnType<typeof userEvent.setup>,
container: HTMLElement
) => {
await screen.findByDisplayValue('Print with brim');
await user.click(screen.getByAltText('Photos'));
await screen.findByText('Photo 1 of 1');
await user.click(container.querySelector('.text-red-400') as HTMLElement);
await user.click(await screen.findByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Photo 1 of 1')).not.toBeInTheDocument());
};
it('leaves Escape closing the details modal', async () => {
// Emptying the list unmounts the gallery before its own "nothing left
// to show" branch can call onClose, so the details modal never learned
// the lightbox had gone and kept its Escape handler stood down.
const user = userEvent.setup();
const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
await deleteTheOnlyPhotoFromTheLightbox(user, container);
await user.keyboard('{Escape}');
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('does not re-open the lightbox when the next photo is uploaded', async () => {
server.use(
http.post('/api/v1/library/files/7/photos', () =>
HttpResponse.json({ status: 'uploaded', photos: ['replacement.jpg'] })
)
);
const user = userEvent.setup();
const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
await deleteTheOnlyPhotoFromTheLightbox(user, container);
await user.upload(
screen.getByLabelText('Add photo'),
new File(['jpeg'], 'replacement.jpg', { type: 'image/jpeg' })
);
const photo = (await screen.findByAltText('Photos')) as HTMLImageElement;
expect(photo.src).toContain('/library/files/7/photos/replacement.jpg');
expect(screen.queryByText('Photo 1 of 1')).not.toBeInTheDocument();
});
});
});
});
@@ -0,0 +1,101 @@
/**
* Tests for PdfPreviewModal (#2976).
*
* pdf.js cannot rasterise inside jsdom (no real canvas), so the library is
* mocked at the module boundary; the tests cover the modal's own logic —
* loading, page navigation, and error/size fallbacks.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PdfPreviewModal } from '../../components/PdfPreviewModal';
const pdfjsMocks = vi.hoisted(() => {
const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
const getPage = vi.fn(async () => ({
getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
render,
}));
const getDocument = vi.fn(() => ({
promise: Promise.resolve({ numPages: 3, getPage }),
destroy: vi.fn(),
}));
return { render, getPage, getDocument };
});
vi.mock('pdfjs-dist', () => ({
GlobalWorkerOptions: { workerSrc: '' },
getDocument: pdfjsMocks.getDocument,
}));
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: 'pdf.worker.min.mjs' }));
vi.mock('../../api/client', () => ({
api: {
getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
},
getAuthToken: () => null,
}));
const mockOnClose = vi.fn();
function renderModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {}) {
return render(
<PdfPreviewModal
libraryFileId={7}
filename="drawing.pdf"
fileSize={1024}
onClose={mockOnClose}
{...props}
/>,
);
}
describe('PdfPreviewModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('shows the page indicator once the document loads', async () => {
renderModal();
expect(await screen.findByText('Page 1 of 3')).toBeInTheDocument();
expect(pdfjsMocks.render).toHaveBeenCalled();
});
it('navigates between pages', async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText('Page 1 of 3');
await user.click(screen.getByRole('button', { name: 'Next page' }));
expect(await screen.findByText('Page 2 of 3')).toBeInTheDocument();
expect(pdfjsMocks.getPage).toHaveBeenLastCalledWith(2);
await user.click(screen.getByRole('button', { name: 'Previous page' }));
expect(await screen.findByText('Page 1 of 3')).toBeInTheDocument();
});
it('shows an error message when the document cannot be parsed', async () => {
pdfjsMocks.getDocument.mockReturnValueOnce({ promise: Promise.reject(new Error('bad pdf')), destroy: vi.fn() } as never);
renderModal();
expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
});
it('refuses oversized files without fetching them', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
renderModal({ fileSize: 500 * 1024 * 1024 });
expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,114 @@
/**
* Tests for SpreadsheetPreviewModal (#2976).
*
* CSV parsing uses the real papaparse and XLSX parsing the real SheetJS —
* only the network fetch is stubbed, so the tests cover the actual parse
* paths the preview relies on.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as XLSX from 'xlsx';
import { SpreadsheetPreviewModal } from '../../components/SpreadsheetPreviewModal';
vi.mock('../../api/client', () => ({
api: {
getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
},
getAuthToken: () => null,
}));
const mockOnClose = vi.fn();
function stubFetchWith(bytes: ArrayBuffer | Uint8Array | string) {
const body = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes;
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(body as BodyInit, { status: 200 })),
);
}
function renderModal(props: Partial<Parameters<typeof SpreadsheetPreviewModal>[0]> = {}) {
return render(
<SpreadsheetPreviewModal
libraryFileId={42}
filename="parts.csv"
fileType="csv"
fileSize={1234}
onClose={mockOnClose}
{...props}
/>,
);
}
describe('SpreadsheetPreviewModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders CSV cells as a read-only grid', async () => {
stubFetchWith('Article,Qty\nM3 screw,12\nBearing 608,4\n');
renderModal();
expect(await screen.findByText('M3 screw')).toBeInTheDocument();
expect(screen.getByText('Bearing 608')).toBeInTheDocument();
expect(screen.getByText('Qty')).toBeInTheDocument();
});
it('shows a truncation notice for long CSV files', async () => {
const rows = Array.from({ length: 600 }, (_, i) => `row${i},${i}`).join('\n');
stubFetchWith(`name,value\n${rows}\n`);
renderModal();
expect(await screen.findByText('row0')).toBeInTheDocument();
expect(screen.getByText(/Showing the first/)).toBeInTheDocument();
expect(screen.queryByText('row599')).not.toBeInTheDocument();
});
it('renders XLSX workbooks with one tab per sheet', async () => {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.aoa_to_sheet([
['Part', 'Price'],
['Nozzle', '12.50'],
]),
'Parts',
);
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['SupplierList']]), 'Suppliers');
const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
stubFetchWith(bytes);
const user = userEvent.setup();
renderModal({ filename: 'bom.xlsx', fileType: 'xlsx' });
expect(await screen.findByText('Nozzle')).toBeInTheDocument();
// Both sheets appear as tabs; switching shows the second sheet's content.
await user.click(screen.getByRole('button', { name: 'Suppliers' }));
expect(await screen.findByText('SupplierList')).toBeInTheDocument();
expect(screen.queryByText('Nozzle')).not.toBeInTheDocument();
});
it('shows an error message for a broken workbook', async () => {
// A truncated ZIP: SheetJS recognises the PK magic, then fails to parse.
// (Plain text bytes would be leniently read as CSV, not rejected.)
stubFetchWith(new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x01, 0x02, 0x03]));
renderModal({ filename: 'broken.xlsx', fileType: 'xlsx' });
expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
});
it('refuses oversized files without fetching them', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
renderModal({ fileSize: 100 * 1024 * 1024 });
expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,183 @@
/**
* File details entry points and indicators on the File Manager (#3077).
*
* The card kebab and the list row both offer "File details"; a card or row
* shows a globe (external link, opens in a new tab), a sticky-note icon
* (has notes) and a photo count only when the listing says so.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { FileManagerPage } from '../../pages/FileManagerPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
vi.mock('../../components/LibraryFileDetailsModal', () => ({
LibraryFileDetailsModal: ({ file }: { file: { filename: string } }) => (
<div data-testid="details-modal">{file.filename}</div>
),
}));
const base = {
file_path: '/library/x',
file_size: 1048576,
folder_id: null,
thumbnail_path: null,
print_name: null,
print_time_seconds: null,
print_count: 0,
duplicate_count: 0,
created_at: '2024-01-01T00:00:00Z',
};
const files = [
{
...base,
id: 1,
filename: 'documented.stl',
file_type: 'stl',
external_url: 'https://www.printables.com/model/1',
has_notes: true,
photo_count: 3,
},
{ ...base, id: 2, filename: 'bare.stl', file_type: 'stl', external_url: null, has_notes: false, photo_count: 0 },
];
function serve() {
server.use(
http.get('/api/v1/library/folders', () => HttpResponse.json([])),
http.get('/api/v1/library/files', () => HttpResponse.json(files)),
http.get('/api/v1/library/stats', () =>
HttpResponse.json({
total_files: files.length,
total_folders: 0,
total_size_bytes: 1,
disk_free_bytes: 1,
disk_total_bytes: 2,
}),
),
);
}
function cardFor(filename: string): HTMLElement {
return screen.getByText(filename).closest('.group') as HTMLElement;
}
function rowFor(filename: string): HTMLElement {
return screen.getByText(filename).closest('div[class*="grid-cols-"]') as HTMLElement;
}
describe('FileManagerPage — file details (#3077)', () => {
beforeEach(() => {
serve();
});
afterEach(() => {
(localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
});
describe('grid view', () => {
it('opens the details modal from the card menu', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bare.stl')).toBeInTheDocument());
const card = cardFor('bare.stl');
const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
await user.click(kebab);
await user.click(within(card).getByText('File details'));
expect(await screen.findByTestId('details-modal')).toHaveTextContent('bare.stl');
});
it('shows the link, notes and photo indicators only when the listing carries them', async () => {
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('documented.stl')).toBeInTheDocument());
const documented = cardFor('documented.stl');
const globe = within(documented).getByLabelText('Open link');
expect(globe).toHaveAttribute('href', 'https://www.printables.com/model/1');
expect(globe).toHaveAttribute('target', '_blank');
expect(within(documented).getByLabelText('Has notes')).toBeInTheDocument();
expect(within(documented).getByLabelText('3 photos')).toHaveTextContent('3');
const bare = cardFor('bare.stl');
expect(within(bare).queryByLabelText('Open link')).not.toBeInTheDocument();
expect(within(bare).queryByLabelText('Has notes')).not.toBeInTheDocument();
expect(within(bare).queryByLabelText(/photos?$/)).not.toBeInTheDocument();
});
it('offers the link in the card menu only when the file has one', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('documented.stl')).toBeInTheDocument());
const documented = cardFor('documented.stl');
await user.click(documented.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement);
expect(within(documented).getByText('Open link')).toBeInTheDocument();
await user.keyboard('{Escape}');
const bare = cardFor('bare.stl');
await user.click(bare.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement);
expect(within(bare).getByText('File details')).toBeInTheDocument();
expect(within(bare).queryByText('Open link')).not.toBeInTheDocument();
});
it('opens the stored link without handing the new tab a window.opener', async () => {
// The URL comes from whoever owns the file, so the page it opens must
// not get a handle back on Bambuddy's window.
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('documented.stl')).toBeInTheDocument());
const documented = cardFor('documented.stl');
await user.click(documented.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement);
await user.click(within(documented).getByText('Open link'));
expect(open).toHaveBeenCalledWith('https://www.printables.com/model/1', '_blank', 'noopener,noreferrer');
open.mockRestore();
});
});
describe('list view', () => {
beforeEach(() => {
(localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
key === 'library-view-mode' ? 'list' : null,
);
});
it('opens the details modal from the row action', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bare.stl')).toBeInTheDocument());
await user.click(within(rowFor('bare.stl')).getByTitle('File details'));
expect(await screen.findByTestId('details-modal')).toHaveTextContent('bare.stl');
});
it('shows the indicators next to the name', async () => {
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('documented.stl')).toBeInTheDocument());
const documented = rowFor('documented.stl');
expect(within(documented).getByLabelText('Open link')).toHaveAttribute(
'href',
'https://www.printables.com/model/1',
);
expect(within(documented).getByLabelText('Has notes')).toBeInTheDocument();
expect(within(documented).getByTitle('3 photos')).toHaveTextContent('3');
const bare = rowFor('bare.stl');
expect(within(bare).queryByLabelText('Open link')).not.toBeInTheDocument();
expect(within(bare).queryByLabelText('Has notes')).not.toBeInTheDocument();
});
});
});
+57
View File
@@ -7479,8 +7479,54 @@ export const api = {
window.URL.revokeObjectURL(url);
},
getLibraryFileThumbnailUrl: (id: number) => withMediaToken(`${API_BASE}/library/files/${id}/thumbnail`),
// Client-rendered preview thumbnail upload (#2976). STEP/PDF/spreadsheet
// previews render in the browser; the first render is posted back so the
// grid gets a thumbnail without a server-side renderer for those formats.
uploadLibraryPreviewThumbnail: async (fileId: number, thumbnail: Blob): Promise<{ updated: boolean }> => {
const formData = new FormData();
formData.append('thumbnail', thumbnail, 'preview.png');
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/library/files/${fileId}/preview-thumbnail`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
withMediaToken(`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`),
// Photos of the printed result (#3077) — same shape as the archive photo API.
getLibraryFilePhotoUrl: (fileId: number, filename: string) =>
withMediaToken(`${API_BASE}/library/files/${fileId}/photos/${encodeURIComponent(filename)}`),
uploadLibraryFilePhoto: async (fileId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/library/files/${fileId}/photos`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
},
deleteLibraryFilePhoto: (fileId: number, filename: string) =>
request<{ status: string; photos: string[] }>(`/library/files/${fileId}/photos/${encodeURIComponent(filename)}`, {
method: 'DELETE',
}),
getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
request<{ status: string; moved: number }>('/library/files/move', {
@@ -8046,6 +8092,11 @@ export interface LibraryFile {
print_count: number;
last_printed_at: string | null;
notes: string | null;
// User link + photos of the printed result (#3077); source_url is the
// read-only import provenance (MakerWorld).
external_url: string | null;
photos: string[];
source_url: string | null;
duplicates: LibraryFileDuplicate[] | null;
duplicate_count: number;
// User tracking (Issue #206)
@@ -8096,6 +8147,11 @@ export interface LibraryFileListItem {
// matching rows on screen. 0 when the file is not grouped.
variant_group_id?: number | null;
variant_count?: number;
// Metadata indicators (#3077). Optional for the same reason as `tags`: older
// mocks construct list items without them. Read sites default to falsy.
external_url?: string | null;
has_notes?: boolean;
photo_count?: number;
}
// Variant groups (#671 / #2570): the same job sliced for different printers.
@@ -8133,6 +8189,7 @@ export interface LibraryFileUpdate {
folder_id?: number | null;
project_id?: number | null;
notes?: string | null;
external_url?: string | null;
}
// Library trash (#1008)
@@ -0,0 +1,346 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Link, Camera, Trash2, Loader2, Plus, ExternalLink, StickyNote } from 'lucide-react';
import { api } from '../api/client';
import type { LibraryFileListItem } from '../api/client';
import { Button } from './Button';
import { PhotoGalleryModal } from './PhotoGalleryModal';
import { useToast } from '../contexts/ToastContext';
import { formatDate, formatDuration } from '../utils/date';
import { formatFileSize } from '../utils/file';
interface LibraryFileDetailsModalProps {
file: LibraryFileListItem;
// Notes, link and photos are edits to the file, so they follow the same
// ownership gate as rename (`canModify('library', 'update', ...)`).
canEdit: boolean;
onClose: () => void;
}
// Notes, an external link and photos of the printed result on a library
// file (#3077) — the same trio EditArchiveModal offers for an archive. Photos
// are saved as they are added; notes and the link on Save.
export function LibraryFileDetailsModal({ file, canEdit, onClose }: LibraryFileDetailsModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const { data: details } = useQuery({
queryKey: ['library-file', file.id],
queryFn: () => api.getLibraryFile(file.id),
});
const [notes, setNotes] = useState('');
const [externalUrl, setExternalUrl] = useState('');
const [photos, setPhotos] = useState<string[]>([]);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
// The photo the lightbox opens on, or null while it is closed.
const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
const photoInputRef = useRef<HTMLInputElement>(null);
// Seed the form once per file. Later refetches (photo changes below, window
// focus) must not overwrite notes or a link the user is still typing.
const seededForId = useRef<number | null>(null);
useEffect(() => {
if (!details || seededForId.current === details.id) return;
seededForId.current = details.id;
setNotes(details.notes ?? '');
setExternalUrl(details.external_url ?? '');
setPhotos(details.photos ?? []);
}, [details]);
// Escape closes the modal, as it does in the rest of the app. Not while the
// lightbox is open on top of it — that handles Escape itself, and a second
// listener here would close both at once.
useEffect(() => {
if (galleryIndex !== null) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [galleryIndex, onClose]);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['library-files'] });
queryClient.invalidateQueries({ queryKey: ['library-file', file.id] });
};
const saveMutation = useMutation({
mutationFn: () => api.updateLibraryFile(file.id, { notes, external_url: externalUrl.trim() }),
onSuccess: () => {
invalidate();
showToast(t('fileManager.details.saved'), 'success');
onClose();
},
onError: () => {
showToast(t('fileManager.details.saveFailed'), 'error');
},
});
const hasChanges =
!!details && (notes !== (details.notes ?? '') || externalUrl.trim() !== (details.external_url ?? ''));
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0];
if (!picked) return;
setUploadingPhoto(true);
try {
const result = await api.uploadLibraryFilePhoto(file.id, picked);
setPhotos(result.photos);
invalidate();
} catch {
showToast(t('fileManager.details.uploadFailed'), 'error');
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) {
photoInputRef.current.value = '';
}
}
};
const handlePhotoDelete = async (filename: string) => {
try {
const result = await api.deleteLibraryFilePhoto(file.id, filename);
const remaining = result.photos ?? [];
setPhotos(remaining);
// Deleting the last photo unmounts the lightbox through the render
// guard below before its own "nothing left to show" branch can call
// onClose, so the index has to be cleared here. Left set, it keeps
// Escape disabled for good and re-opens the lightbox unasked as soon
// as another photo is uploaded.
if (remaining.length === 0) setGalleryIndex(null);
invalidate();
} catch {
showToast(t('fileManager.details.deleteFailed'), 'error');
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!canEdit || !hasChanges) return;
saveMutation.mutate();
};
const trimmedUrl = externalUrl.trim();
const facts: Array<{ label: string; value: React.ReactNode }> = [
{ label: t('fileManager.details.size'), value: formatFileSize(file.file_size) },
{ label: t('fileManager.details.type'), value: file.file_type.toUpperCase() },
];
if (details?.print_name) facts.push({ label: t('fileManager.details.printName'), value: details.print_name });
if (details?.print_time_seconds) {
facts.push({ label: t('fileManager.details.printTime'), value: formatDuration(details.print_time_seconds) });
}
if (details?.filament_used_grams) {
facts.push({ label: t('fileManager.details.filament'), value: `${details.filament_used_grams.toFixed(1)} g` });
}
if (details?.sliced_for_model) facts.push({ label: t('fileManager.details.slicedFor'), value: details.sliced_for_model });
if (details?.source_url) {
facts.push({
label: t('fileManager.details.source'),
value: (
<a
href={details.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-green hover:underline inline-flex items-center gap-1 min-w-0"
>
<span className="truncate">{details.source_url}</span>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
),
});
}
facts.push({ label: t('fileManager.details.created'), value: formatDate(file.created_at) });
facts.push({
label: t('fileManager.details.modified'),
value: formatDate(file.fs_modified_at ?? details?.updated_at ?? file.created_at),
});
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-b border-bambu-dark-tertiary">
<div className="min-w-0">
<p className="text-xs text-bambu-gray">{t('fileManager.details.title')}</p>
<div className="flex items-center gap-2 min-w-0">
<h2 className="text-lg font-semibold text-white truncate" title={file.filename}>
{file.filename}
</h2>
<span className="flex-shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-bambu-dark-tertiary text-bambu-gray">
{file.file_type.toUpperCase()}
</span>
</div>
</div>
<button
type="button"
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors flex-shrink-0"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{/* Facts */}
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
{facts.map((fact) => (
<div key={fact.label} className="contents">
<dt className="text-bambu-gray whitespace-nowrap">{fact.label}</dt>
<dd className="text-white min-w-0 truncate">{fact.value}</dd>
</div>
))}
</dl>
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<StickyNote className="w-4 h-4 inline mr-1" />
{t('fileManager.details.notes')}
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
disabled={!canEdit}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none resize-none disabled:opacity-60"
placeholder={t('fileManager.details.notesPlaceholder')}
/>
</div>
{/* External link */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Link className="w-4 h-4 inline mr-1" />
{t('fileManager.details.externalLink')}
</label>
<div className="flex items-center gap-2">
<input
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
disabled={!canEdit}
className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-60"
placeholder={t('fileManager.details.externalLinkPlaceholder')}
/>
{trimmedUrl && (
<a
href={trimmedUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-bambu-gray hover:text-bambu-green hover:bg-bambu-dark transition-colors"
title={t('fileManager.details.openLink')}
aria-label={t('fileManager.details.openLink')}
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
{/* Photos */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Camera className="w-4 h-4 inline mr-1" />
{t('fileManager.details.photos')}
</label>
<div className="flex flex-wrap gap-2">
{photos.map((filename, index) => (
<div key={filename} className="relative group">
<button
type="button"
onClick={() => setGalleryIndex(index)}
className="block w-20 h-20 rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
>
<img
src={api.getLibraryFilePhotoUrl(file.id, filename)}
alt={t('fileManager.details.photos')}
className="w-full h-full object-cover"
/>
</button>
{canEdit && (
<button
type="button"
onClick={() => handlePhotoDelete(filename)}
className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
title={t('fileManager.details.deletePhoto')}
aria-label={t('fileManager.details.deletePhoto')}
>
<Trash2 className="w-3 h-3 text-white" />
</button>
)}
</div>
))}
{canEdit && (
<label
className="w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors"
title={t('fileManager.details.addPhoto')}
>
<input
ref={photoInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handlePhotoUpload}
className="hidden"
disabled={uploadingPhoto}
aria-label={t('fileManager.details.addPhoto')}
/>
{uploadingPhoto ? (
<Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
) : (
<Plus className="w-6 h-6 text-bambu-gray" />
)}
</label>
)}
{photos.length === 0 && !canEdit && (
<p className="text-xs text-bambu-gray">{t('fileManager.details.noPhotos')}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
{t('common.close')}
</Button>
{canEdit && (
<Button
type="submit"
variant="primary"
className="flex-1"
disabled={!hasChanges || saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{t('fileManager.details.save')}
</Button>
)}
</div>
</form>
</div>
{galleryIndex !== null && photos.length > 0 && (
<PhotoGalleryModal
archiveName={file.filename}
photos={photos}
initialIndex={galleryIndex}
getPhotoUrl={(filename) => api.getLibraryFilePhotoUrl(file.id, filename)}
onClose={() => setGalleryIndex(null)}
onDelete={canEdit ? handlePhotoDelete : undefined}
/>
)}
</div>
);
}
+174 -12
View File
@@ -9,6 +9,60 @@ import JSZip from 'jszip';
import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from './Button';
import { getAuthToken } from '../api/client';
import type { StepWorkerMesh, StepWorkerResponse } from '../workers/stepPreview.worker';
// STEP triangulation runs in a dedicated worker (#2976): OpenCascade-as-WASM
// takes seconds on a real assembly and its embind glue needs an eval-relaxed
// CSP that only the worker's own script response carries (see the worker
// file and security_headers_middleware in backend/app/main.py). The worker —
// and with it the ~7 MB wasm — loads on the first STEP preview and is then
// kept warm for the page's lifetime.
let stepWorker: Worker | null = null;
let stepRequestId = 0;
const stepPendingRequests = new Map<
number,
{ resolve: (meshes: StepWorkerMesh[]) => void; reject: (err: Error) => void }
>();
function failAllStepRequests(message: string): void {
for (const pending of stepPendingRequests.values()) {
pending.reject(new Error(message));
}
stepPendingRequests.clear();
}
function getStepWorker(): Worker {
if (!stepWorker) {
const worker = new Worker(new URL('../workers/stepPreview.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event: MessageEvent<StepWorkerResponse>) => {
const pending = stepPendingRequests.get(event.data.id);
if (!pending) return;
stepPendingRequests.delete(event.data.id);
if (event.data.ok) {
pending.resolve(event.data.meshes);
} else {
pending.reject(new Error(event.data.reason));
}
};
// A crashed or unloadable worker is dropped so the next preview attempt
// starts a fresh one instead of reusing a dead instance.
worker.onerror = () => {
failAllStepRequests('error');
worker.terminate();
if (stepWorker === worker) stepWorker = null;
};
stepWorker = worker;
}
return stepWorker;
}
function parseStepInWorker(buffer: ArrayBuffer): Promise<StepWorkerMesh[]> {
return new Promise((resolve, reject) => {
const id = ++stepRequestId;
stepPendingRequests.set(id, { resolve, reject });
getStepWorker().postMessage({ id, buffer }, [buffer]);
});
}
/**
* Frame the camera on a bounding box.
@@ -61,8 +115,15 @@ interface ModelViewerProps {
filamentColors?: string[];
selectedPlateId?: number | null;
className?: string;
/** Called once with a 256px PNG of the first render — used by the file
* manager to persist a thumbnail for formats the server cannot render
* itself (STEP, #2976). */
onSnapshot?: (blob: Blob) => void;
}
// Triangulated STEP shape as posted back by the step preview worker.
type StepMeshData = StepWorkerMesh;
interface MeshData {
vertices: number[];
triangles: number[];
@@ -666,6 +727,41 @@ function buildModelGroup(
return group;
}
// One mesh per STEP shape so per-part colours survive; parts without a colour
// fall back to the same filament colour the STL path uses.
function buildStepGroup(meshes: StepMeshData[], filamentColors?: string[]): THREE.Group {
const group = new THREE.Group();
const fallback = filamentColors?.[0] || '#00ae42';
for (const meshData of meshes) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3));
if (meshData.normals) {
geometry.setAttribute('normal', new THREE.BufferAttribute(meshData.normals, 3));
}
if (meshData.indices) {
geometry.setIndex(new THREE.BufferAttribute(meshData.indices, 1));
}
if (!meshData.normals) {
geometry.computeVertexNormals();
}
// STEP is Z-up like STL; three.js is Y-up.
geometry.rotateX(-Math.PI / 2);
const color = meshData.color
? new THREE.Color(meshData.color[0], meshData.color[1], meshData.color[2])
: new THREE.Color(fallback);
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.62,
metalness: 0.0,
envMapIntensity: 0.55,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
group.add(mesh);
}
return group;
}
export function ModelViewer({
url,
fileType,
@@ -673,6 +769,7 @@ export function ModelViewer({
filamentColors,
selectedPlateId = null,
className = '',
onSnapshot,
}: ModelViewerProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
@@ -693,6 +790,14 @@ export function ModelViewer({
const [error, setError] = useState<string | null>(null);
const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
const [stepMeshes, setStepMeshes] = useState<StepMeshData[] | null>(null);
// Snapshot is a one-shot per loaded url; the callback lives in a ref so its
// identity never retriggers the (expensive) scene effects.
const snapshotSentRef = useRef(false);
const onSnapshotRef = useRef(onSnapshot);
useEffect(() => {
onSnapshotRef.current = onSnapshot;
});
useEffect(() => {
if (!containerRef.current) return;
@@ -819,6 +924,8 @@ export function ModelViewer({
setError(null);
setParsedData(null);
setStlGeometry(null);
setStepMeshes(null);
snapshotSentRef.current = false;
const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
@@ -863,6 +970,27 @@ export function ModelViewer({
setError(err.message);
setLoading(false);
});
} else if (normalizedType === 'step' || normalizedType === 'stp') {
// STEP has no three.js loader; triangulation happens in the dedicated
// OpenCascade worker (#2976), off the UI thread.
fetch(url, { headers })
.then((res) => {
if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad'));
return res.arrayBuffer();
})
.then(parseStepInWorker)
.then(setStepMeshes)
.catch((err: Error) => {
// Worker rejections carry machine reasons; everything else already
// is a translated message from the fetch step above.
const message = err.message === 'no-meshes'
? t('modelViewer.errors.noMeshes')
: err.message === 'error'
? t('modelViewer.errors.failedToLoad')
: err.message;
setError(message);
setLoading(false);
});
} else {
setError(t('modelViewer.errors.unsupportedFormat'));
setLoading(false);
@@ -909,15 +1037,17 @@ export function ModelViewer({
useEffect(() => {
if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
if (!parsedData && !stlGeometry) return;
if (!parsedData && !stlGeometry && !stepMeshes) return;
if (modelGroupRef.current) {
sceneRef.current.remove(modelGroupRef.current);
disposeGroup(modelGroupRef.current);
}
const isStlModel = !!stlGeometry;
const group = isStlModel
// STL and STEP are plain single models with no plate/build-item layout;
// they share the same centre-on-plate placement below.
const isPlainModel = !!stlGeometry || !!stepMeshes;
const group = stlGeometry
? (() => {
const materialColor = filamentColors?.[0] || '#00ae42';
const material = new THREE.MeshStandardMaterial({
@@ -926,13 +1056,15 @@ export function ModelViewer({
metalness: 0.0,
envMapIntensity: 0.55,
});
const mesh = new THREE.Mesh(stlGeometry!, material);
const mesh = new THREE.Mesh(stlGeometry, material);
mesh.castShadow = true;
const stlGroup = new THREE.Group();
stlGroup.add(mesh);
return stlGroup;
})()
: buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
: stepMeshes
? buildStepGroup(stepMeshes, filamentColors)
: buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
modelGroupRef.current = group;
sceneRef.current.add(group);
@@ -943,13 +1075,13 @@ export function ModelViewer({
// Always place models on the build plate (Y=0)
group.position.y = -box.min.y;
const selectedPlateBounds = (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0)
const selectedPlateBounds = (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0)
? parsedData!.plateBounds.get(selectedPlateId)
: undefined;
const selectedPlateOffset = (!isStlModel && selectedPlateId != null)
const selectedPlateOffset = (!isPlainModel && selectedPlateId != null)
? parsedData!.plateOffsets.get(selectedPlateId)
: undefined;
const shouldCenterOnPlate = isStlModel
const shouldCenterOnPlate = isPlainModel
|| parsedData!.buildItems.length === 0
|| (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset);
const centerOffsetX = shouldCenterOnPlate ? -center.x : 0;
@@ -957,7 +1089,7 @@ export function ModelViewer({
let plateOffsetX = 0;
let plateOffsetZ = 0;
if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
if (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
const plateBox = new THREE.Box3().setFromObject(group);
plateOffsetX = plateBox.min.x - selectedPlateBounds.minX;
plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY;
@@ -966,10 +1098,10 @@ export function ModelViewer({
const plateCenterX = buildVolume.x / 2;
const plateCenterZ = buildVolume.y / 2;
if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
if (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
group.position.x = centerOffsetX - plateOffsetX;
group.position.z = centerOffsetZ - plateOffsetZ;
} else if (!isStlModel && selectedPlateId != null && selectedPlateOffset) {
} else if (!isPlainModel && selectedPlateId != null && selectedPlateOffset) {
group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX);
group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY);
} else if (shouldCenterOnPlate) {
@@ -1003,7 +1135,37 @@ export function ModelViewer({
fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
setLoading(false);
}, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
// One-shot snapshot of the first framed render (#2976). Rendering
// explicitly right before reading the canvas keeps the WebGL buffer
// valid without preserveDrawingBuffer.
if (onSnapshotRef.current && !snapshotSentRef.current) {
snapshotSentRef.current = true;
requestAnimationFrame(() => {
const renderer = rendererRef.current;
const scene = sceneRef.current;
const camera = cameraRef.current;
if (!renderer || !scene || !camera) return;
try {
renderer.render(scene, camera);
const source = renderer.domElement;
const size = 256;
const side = Math.min(source.width, source.height);
const target = document.createElement('canvas');
target.width = size;
target.height = size;
const ctx = target.getContext('2d');
if (!ctx) return;
ctx.drawImage(source, (source.width - side) / 2, (source.height - side) / 2, side, side, 0, 0, size, size);
target.toBlob((blob) => {
if (blob) onSnapshotRef.current?.(blob);
}, 'image/png');
} catch {
// Snapshot is best-effort; the preview itself already rendered.
}
});
}
}, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]);
const resetView = () => {
if (cameraRef.current && controlsRef.current) {
+7 -2
View File
@@ -23,6 +23,9 @@ interface ModelViewerModalProps {
// externally — so the preview modal's slice action matches the file row's
// Cog (in-app Bambuddy SliceModal) when the slicer API is enabled.
onSliceWithBambuddy?: () => void;
// Forwarded to ModelViewer: one 256px PNG of the first render, used by the
// file manager to persist a STEP thumbnail (#2976).
onSnapshot?: (blob: Blob) => void;
}
interface Capabilities {
@@ -136,7 +139,7 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: Sli
);
}
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy, onSnapshot }: ModelViewerModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
@@ -185,7 +188,8 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
// upload path tags it `3mf`, so we accept both shapes here for
// the 3D-tab + g-code-tab gating (#1543).
const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
const hasModel = isThreeMfFamily || normalizedType === 'stl';
// STEP joins the 3D tab via the OpenCascade WASM loader in ModelViewer (#2976).
const hasModel = isThreeMfFamily || normalizedType === 'stl' || normalizedType === 'step' || normalizedType === 'stp';
setCapabilities({
has_model: hasModel,
has_source: false,
@@ -772,6 +776,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
filamentColors={capabilities.filament_colors}
selectedPlateId={selectedPlateId}
className="w-full h-full"
onSnapshot={onSnapshot}
/>
</div>
</div>
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, FileText, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react';
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
import { api, getAuthToken } from '../api/client';
import { formatFileSize } from '../utils/file';
// Fetching and parsing happen fully in the browser; beyond this size the
// preview shows a notice instead of stalling the tab on a giant download.
export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
interface PdfPreviewModalProps {
libraryFileId: number;
filename: string;
fileSize: number;
onClose: () => void;
/** Called once with a 256px PNG of the first page, for the grid thumbnail (#2976). */
onSnapshot?: (blob: Blob) => void;
}
// Square 256px crop of the rendered page, white-backed like a paper page.
function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
const size = 256;
const target = document.createElement('canvas');
target.width = size;
target.height = size;
const ctx = target.getContext('2d');
if (!ctx) return Promise.resolve(null);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
const scale = size / Math.max(source.width, source.height);
const w = source.width * scale;
const h = source.height * scale;
ctx.drawImage(source, (size - w) / 2, (size - h) / 2, w, h);
return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
}
export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const renderTaskRef = useRef<RenderTask | null>(null);
const snapshotSentRef = useRef(false);
const onSnapshotRef = useRef(onSnapshot);
useEffect(() => {
onSnapshotRef.current = onSnapshot;
});
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [pageNum, setPageNum] = useState(1);
const [zoom, setZoom] = useState(1);
const [error, setError] = useState<string | null>(null);
const [rendering, setRendering] = useState(true);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Load the document. pdf.js is imported on demand so the viewer and its
// worker stay out of the main bundle.
useEffect(() => {
let cancelled = false;
let loadingTask: PDFDocumentLoadingTask | null = null;
setDoc(null);
setError(null);
setPageNum(1);
setRendering(true);
if (fileSize > PDF_PREVIEW_MAX_BYTES) {
setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
setRendering(false);
return;
}
const headers: HeadersInit = {};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
(async () => {
const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = await res.arrayBuffer();
const pdfjs = await import('pdfjs-dist');
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
}
loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) });
const loaded = await loadingTask.promise;
if (cancelled) {
// Cleanup below already ran; destroying the task tears down the doc.
return;
}
setDoc(loaded);
})().catch(() => {
if (!cancelled) {
setError(t('fileManager.preview.error'));
setRendering(false);
}
});
return () => {
cancelled = true;
renderTaskRef.current?.cancel();
renderTaskRef.current = null;
// Destroying the loading task also destroys the document and worker.
loadingTask?.destroy();
};
}, [libraryFileId, fileSize, t]);
// Render the current page into the canvas.
useEffect(() => {
if (!doc) return;
let cancelled = false;
setRendering(true);
(async () => {
const page = await doc.getPage(pageNum);
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || cancelled) return;
const baseViewport = page.getViewport({ scale: 1 });
// Fit the page width to the panel at zoom 1; render at device pixels.
const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const viewport = page.getViewport({ scale: fitScale * zoom * dpr });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
renderTaskRef.current?.cancel();
const task = page.render({ canvas, viewport });
renderTaskRef.current = task;
await task.promise;
if (cancelled) return;
setRendering(false);
if (pageNum === 1 && onSnapshotRef.current && !snapshotSentRef.current) {
snapshotSentRef.current = true;
const blob = await snapshotFromCanvas(canvas);
if (blob && !cancelled) onSnapshotRef.current?.(blob);
}
})().catch((err: unknown) => {
// A cancelled render throws RenderingCancelledException — not an error.
if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
setError(t('fileManager.preview.error'));
setRendering(false);
}
});
return () => {
cancelled = true;
};
}, [doc, pageNum, zoom, t]);
const pageCount = doc?.numPages ?? 0;
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-5xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />
<h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{doc && pageCount > 1 && (
<div className="flex items-center gap-1 mr-2">
<button
onClick={() => setPageNum((p) => Math.max(1, p - 1))}
disabled={pageNum <= 1}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
aria-label={t('fileManager.preview.prevPage')}
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-bambu-gray whitespace-nowrap">
{t('fileManager.preview.page', { current: pageNum, total: pageCount })}
</span>
<button
onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
disabled={pageNum >= pageCount}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
aria-label={t('fileManager.preview.nextPage')}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
{doc && (
<>
<button
onClick={() => setZoom((z) => Math.max(0.4, z * 0.8))}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('fileManager.preview.zoomOut')}
>
<ZoomOut className="w-4 h-4" />
</button>
<button
onClick={() => setZoom((z) => Math.min(4, z * 1.25))}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('fileManager.preview.zoomIn')}
>
<ZoomIn className="w-4 h-4" />
</button>
</>
)}
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Page */}
<div ref={containerRef} className="relative flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg p-4">
{error ? (
<div className="h-full flex items-center justify-center">
<p className="text-bambu-gray text-center">{error}</p>
</div>
) : (
<div className="flex justify-center min-w-fit">
<canvas ref={canvasRef} className="shadow-lg" />
</div>
)}
{!error && rendering && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
)}
</div>
</div>
</div>
);
}
+55 -28
View File
@@ -4,26 +4,37 @@ import { api } from '../api/client';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
interface PhotoGalleryModalProps {
archiveId: number;
// Archive photos resolve through `archiveId`; any other owner (a library
// file, #3077) passes `getPhotoUrl` instead. Exactly one of the two is
// required so a caller cannot silently end up requesting archive 0.
type PhotoSource =
| { archiveId: number; getPhotoUrl?: undefined }
| { archiveId?: undefined; getPhotoUrl: (filename: string) => string };
type PhotoGalleryModalProps = PhotoSource & {
archiveName: string;
photos: string[];
// Which photo to open on. A caller that opens the gallery from a per-photo
// grid passes the clicked index (#3077); one with a single "view photos"
// button leaves it at the first.
initialIndex?: number;
onClose: () => void;
onDelete?: (filename: string) => void;
}
};
export function PhotoGalleryModal({
archiveId,
archiveName,
photos,
onClose,
onDelete,
}: PhotoGalleryModalProps) {
const [currentIndex, setCurrentIndex] = useState(0);
export function PhotoGalleryModal(props: PhotoGalleryModalProps) {
const { archiveName, photos, initialIndex, onClose, onDelete } = props;
const [currentIndex, setCurrentIndex] = useState(() =>
Math.min(Math.max(initialIndex ?? 0, 0), Math.max(photos.length - 1, 0))
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Keyboard navigation
// Keyboard navigation. Stands down while the delete confirmation is up:
// that has its own Escape handler, so one press would cancel the prompt and
// close the gallery underneath it, and arrow keys would move the selection
// out from under a confirmation already naming a photo.
useEffect(() => {
if (showDeleteConfirm) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
@@ -31,7 +42,7 @@ export function PhotoGalleryModal({
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, photos.length]);
}, [onClose, photos.length, showDeleteConfirm]);
// Reset index if photos change
useEffect(() => {
@@ -45,8 +56,11 @@ export function PhotoGalleryModal({
return null;
}
const resolvePhotoUrl = (filename: string) =>
props.getPhotoUrl ? props.getPhotoUrl(filename) : api.getArchivePhotoUrl(props.archiveId, filename);
const currentPhoto = photos[currentIndex];
const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);
const photoUrl = resolvePhotoUrl(currentPhoto);
const handleDownload = () => {
const link = document.createElement('a');
@@ -61,10 +75,18 @@ export function PhotoGalleryModal({
}
};
// The gallery can be rendered inside another modal's overlay, and that
// overlay closes on a backdrop click (#3077). Dismissing the gallery must
// not bubble up and take the parent — and its unsaved edits — with it.
const handleBackdropClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClose();
};
return (
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
onClick={onClose}
onClick={handleBackdropClick}
>
<div
className="relative w-full h-full flex flex-col"
@@ -142,7 +164,7 @@ export function PhotoGalleryModal({
}`}
>
<img
src={api.getArchivePhotoUrl(archiveId, photo)}
src={resolvePhotoUrl(photo)}
alt={`Thumbnail ${index + 1}`}
className="w-full h-full object-cover"
/>
@@ -152,19 +174,24 @@ export function PhotoGalleryModal({
)}
</div>
{/* Delete Confirmation Modal */}
{/* Delete Confirmation Modal. Wrapped so that cancelling it by clicking
its backdrop cancels only the confirmation — the click would
otherwise reach the gallery backdrop below it and close the gallery
as well. */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Photo"
message="Delete this photo? This cannot be undone."
confirmText="Delete"
variant="danger"
onConfirm={() => {
onDelete?.(currentPhoto);
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
<div onClick={(e) => e.stopPropagation()}>
<ConfirmModal
title="Delete Photo"
message="Delete this photo? This cannot be undone."
confirmText="Delete"
variant="danger"
onConfirm={() => {
onDelete?.(currentPhoto);
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
</div>
)}
</div>
);
@@ -0,0 +1,305 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileSpreadsheet, Loader2, X } from 'lucide-react';
import { api, getAuthToken } from '../api/client';
import { formatFileSize } from '../utils/file';
// Parsing an arbitrarily large workbook would freeze the tab — anything over
// this size (or beyond the row/column caps) falls back to a truncation notice.
export const SPREADSHEET_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
const MAX_ROWS = 500;
const MAX_COLS = 40;
interface SheetGrid {
name: string;
rows: string[][];
totalRows: number;
totalCols: number;
}
interface SpreadsheetPreviewModalProps {
libraryFileId: number;
filename: string;
/** csv | xlsx | ods */
fileType: string;
fileSize: number;
onClose: () => void;
/** Called once with a 256px PNG of the first sheet, for the grid thumbnail (#2976). */
onSnapshot?: (blob: Blob) => void;
}
// Spreadsheet-style column letters: 0 -> A, 25 -> Z, 26 -> AA, ...
function columnLabel(index: number): string {
let label = '';
let i = index;
while (i >= 0) {
label = String.fromCharCode(65 + (i % 26)) + label;
i = Math.floor(i / 26) - 1;
}
return label;
}
// Mini table rendered onto a canvas as the grid thumbnail. Dark background to
// match the STL thumbnails the grid already shows.
function drawSheetSnapshot(rows: string[][]): Promise<Blob | null> {
const size = 256;
const cols = 5;
const rowCount = 9;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return Promise.resolve(null);
const cellW = size / cols;
const cellH = size / rowCount;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = 'rgba(0, 174, 66, 0.25)';
ctx.fillRect(0, 0, size, cellH);
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
for (let c = 1; c < cols; c++) {
ctx.beginPath();
ctx.moveTo(c * cellW + 0.5, 0);
ctx.lineTo(c * cellW + 0.5, size);
ctx.stroke();
}
for (let r = 1; r < rowCount; r++) {
ctx.beginPath();
ctx.moveTo(0, r * cellH + 0.5);
ctx.lineTo(size, r * cellH + 0.5);
ctx.stroke();
}
ctx.fillStyle = '#d4d4d4';
ctx.font = '11px sans-serif';
ctx.textBaseline = 'middle';
for (let r = 0; r < rowCount; r++) {
for (let c = 0; c < cols; c++) {
const text = rows[r]?.[c];
if (!text) continue;
ctx.fillText(String(text), c * cellW + 4, r * cellH + cellH / 2, cellW - 8);
}
}
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
}
export function SpreadsheetPreviewModal({
libraryFileId,
filename,
fileType,
fileSize,
onClose,
onSnapshot,
}: SpreadsheetPreviewModalProps) {
const { t } = useTranslation();
const [sheets, setSheets] = useState<SheetGrid[] | null>(null);
const [activeSheet, setActiveSheet] = useState(0);
const [error, setError] = useState<string | null>(null);
const snapshotSentRef = useRef(false);
const onSnapshotRef = useRef(onSnapshot);
useEffect(() => {
onSnapshotRef.current = onSnapshot;
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
let cancelled = false;
setSheets(null);
setError(null);
setActiveSheet(0);
if (fileSize > SPREADSHEET_PREVIEW_MAX_BYTES) {
setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
return;
}
const headers: HeadersInit = {};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
(async () => {
const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = await res.arrayBuffer();
let parsed: SheetGrid[];
if (fileType === 'csv') {
// papaparse is loaded on demand so it stays out of the main bundle.
const Papa = (await import('papaparse')).default;
const text = new TextDecoder().decode(buffer);
const result = Papa.parse<string[]>(text, { skipEmptyLines: false });
const all = result.data.filter((row) => Array.isArray(row));
// A trailing newline parses as one empty row — drop trailing blanks.
while (all.length > 0 && all[all.length - 1].every((cell) => !cell)) {
all.pop();
}
const totalRows = all.length;
const totalCols = all.reduce((max, row) => Math.max(max, row.length), 0);
const rows = all.slice(0, MAX_ROWS).map((row) => row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
parsed = [{ name: filename, rows, totalRows, totalCols }];
} else {
// SheetJS handles both XLSX and ODS; loaded on demand like papaparse.
const XLSX = await import('xlsx');
const workbook = XLSX.read(buffer, { dense: true });
parsed = workbook.SheetNames.map((name) => {
const ws = workbook.Sheets[name];
const ref = ws?.['!ref'];
if (!ws || !ref) return { name, rows: [], totalRows: 0, totalCols: 0 };
const range = XLSX.utils.decode_range(ref);
const totalRows = range.e.r - range.s.r + 1;
const totalCols = range.e.c - range.s.c + 1;
// Cap the extracted range instead of slicing afterwards, so a huge
// sheet is never materialised in full.
const capped = {
s: range.s,
e: {
r: Math.min(range.e.r, range.s.r + MAX_ROWS - 1),
c: Math.min(range.e.c, range.s.c + MAX_COLS - 1),
},
};
const rows = XLSX.utils.sheet_to_json(ws, {
header: 1,
raw: false,
defval: '',
range: XLSX.utils.encode_range(capped),
}) as string[][];
return { name, rows, totalRows, totalCols };
});
}
if (cancelled) return;
setSheets(parsed);
if (onSnapshotRef.current && !snapshotSentRef.current) {
const first = parsed.find((sheet) => sheet.rows.length > 0);
if (first) {
snapshotSentRef.current = true;
const blob = await drawSheetSnapshot(first.rows);
if (blob && !cancelled) onSnapshotRef.current?.(blob);
}
}
})().catch(() => {
if (!cancelled) setError(t('fileManager.preview.error'));
});
return () => {
cancelled = true;
};
}, [libraryFileId, fileType, fileSize, filename, t]);
const sheet = sheets?.[activeSheet] ?? null;
const rowsTruncated = sheet != null && sheet.totalRows > sheet.rows.length;
const colsTruncated = sheet != null && sheet.totalCols > MAX_COLS;
const shownCols = sheet == null ? 0 : Math.min(sheet.totalCols, MAX_COLS);
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-6xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2 min-w-0">
<FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />
<h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Sheet tabs */}
{sheets && sheets.length > 1 && (
<div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
{sheets.map((s, index) => (
<button
key={`${s.name}-${index}`}
onClick={() => setActiveSheet(index)}
className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
index === activeSheet
? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
}`}
>
{s.name}
</button>
))}
</div>
)}
{/* Content */}
<div className="flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg">
{error ? (
<div className="h-full flex items-center justify-center p-6">
<p className="text-bambu-gray text-center">{error}</p>
</div>
) : !sheets ? (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : !sheet || sheet.rows.length === 0 ? (
<div className="h-full flex items-center justify-center p-6">
<p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
</div>
) : (
<table className="border-collapse text-xs">
<thead>
<tr>
<th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
{Array.from({ length: shownCols }, (_, c) => (
<th
key={c}
className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
>
{columnLabel(c)}
</th>
))}
</tr>
</thead>
<tbody>
{sheet.rows.map((row, r) => (
<tr key={r}>
<td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
{r + 1}
</td>
{Array.from({ length: shownCols }, (_, c) => (
<td
key={c}
className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
title={row[c] || undefined}
>
{row[c] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Truncation notices */}
{(rowsTruncated || colsTruncated) && sheet && (
<div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
{rowsTruncated && (
<span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
)}
{colsTruncated && (
<span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
)}
</div>
)}
</div>
</div>
);
}
+43
View File
@@ -3995,6 +3995,49 @@ export default {
noPermissionDownload: 'Sie haben keine Berechtigung, Dateien herunterzuladen',
noPermissionPreview: 'Sie haben keine Berechtigung, Dateien in der Vorschau anzuzeigen',
preview3d: '3D-Vorschau',
// In-App-Dokumentvorschau (#2976): PDF- und Tabellendateien.
preview: {
open: 'Vorschau',
error: 'Für diese Datei ist keine Vorschau möglich.',
tooLarge: 'Die Datei ist zu groß für eine Vorschau ({{size}}).',
page: 'Seite {{current}} von {{total}}',
prevPage: 'Vorherige Seite',
nextPage: 'Nächste Seite',
zoomIn: 'Vergrößern',
zoomOut: 'Verkleinern',
emptySheet: 'Dieses Tabellenblatt ist leer',
truncatedRows: 'Die ersten {{shown}} von {{total}} Zeilen werden angezeigt',
truncatedCols: 'Die ersten {{shown}} von {{total}} Spalten werden angezeigt',
},
details: {
title: 'Dateidetails',
size: 'Größe',
type: 'Typ',
printName: 'Druckname',
printTime: 'Druckzeit',
filament: 'Filament',
slicedFor: 'Gesliced für',
source: 'Quelle',
created: 'Erstellt',
modified: 'Geändert',
notes: 'Notizen',
notesPlaceholder: 'Druckeinstellungen, Tipps, was funktioniert hat...',
externalLink: 'Externer Link',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Link öffnen',
photos: 'Fotos',
addPhoto: 'Foto hinzufügen',
noPhotos: 'Noch keine Fotos',
deletePhoto: 'Foto löschen',
photoCount_one: '{{count}} Foto',
photoCount_other: '{{count}} Fotos',
hasNotes: 'Hat Notizen',
save: 'Speichern',
saved: 'Dateidetails gespeichert',
saveFailed: 'Dateidetails konnten nicht gespeichert werden',
uploadFailed: 'Foto konnte nicht hochgeladen werden',
deleteFailed: 'Foto konnte nicht gelöscht werden',
},
noPermissionRenameFile: 'Sie haben keine Berechtigung, diese Datei umzubenennen',
noPermissionGenerateThumbnail: 'Sie haben keine Berechtigung, Vorschaubilder zu generieren',
noPermissionDeleteFile: 'Sie haben keine Berechtigung, diese Datei zu löschen',
+43
View File
@@ -4025,6 +4025,49 @@ export default {
noPermissionDownload: 'You do not have permission to download files',
noPermissionPreview: 'You do not have permission to preview files',
preview3d: '3D Preview',
// In-app document previews (#2976): PDF and spreadsheet files.
preview: {
open: 'Preview',
error: 'This file cannot be previewed.',
tooLarge: 'File is too large to preview ({{size}}).',
page: 'Page {{current}} of {{total}}',
prevPage: 'Previous page',
nextPage: 'Next page',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
emptySheet: 'This sheet is empty',
truncatedRows: 'Showing the first {{shown}} of {{total}} rows',
truncatedCols: 'Showing the first {{shown}} of {{total}} columns',
},
details: {
title: 'File details',
size: 'Size',
type: 'Type',
printName: 'Print name',
printTime: 'Print time',
filament: 'Filament',
slicedFor: 'Sliced for',
source: 'Source',
created: 'Created',
modified: 'Modified',
notes: 'Notes',
notesPlaceholder: 'Print settings, tips, what worked...',
externalLink: 'External link',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Open link',
photos: 'Photos',
addPhoto: 'Add photo',
noPhotos: 'No photos yet',
deletePhoto: 'Delete photo',
photoCount_one: '{{count}} photo',
photoCount_other: '{{count}} photos',
hasNotes: 'Has notes',
save: 'Save',
saved: 'File details saved',
saveFailed: 'Failed to save file details',
uploadFailed: 'Failed to upload photo',
deleteFailed: 'Failed to delete photo',
},
noPermissionRenameFile: 'You do not have permission to rename this file',
noPermissionGenerateThumbnail: 'You do not have permission to generate thumbnails',
noPermissionDeleteFile: 'You do not have permission to delete this file',
+43
View File
@@ -3997,6 +3997,49 @@ export default {
noPermissionDownload: 'No tiene permiso para descargar archivos',
noPermissionPreview: 'No tienes permiso para previsualizar archivos',
preview3d: 'Vista previa 3D',
// Vistas previas de documentos en la aplicación (#2976): archivos PDF y hojas de cálculo.
preview: {
open: 'Vista previa',
error: 'Este archivo no se puede previsualizar.',
tooLarge: 'El archivo es demasiado grande para previsualizarlo ({{size}}).',
page: 'Página {{current}} de {{total}}',
prevPage: 'Página anterior',
nextPage: 'Página siguiente',
zoomIn: 'Acercar',
zoomOut: 'Alejar',
emptySheet: 'Esta hoja está vacía',
truncatedRows: 'Mostrando las primeras {{shown}} de {{total}} filas',
truncatedCols: 'Mostrando las primeras {{shown}} de {{total}} columnas',
},
details: {
title: 'Detalles del archivo',
size: 'Tamaño',
type: 'Tipo',
printName: 'Nombre de impresión',
printTime: 'Tiempo de impresión',
filament: 'Filamento',
slicedFor: 'Laminado para',
source: 'Origen',
created: 'Creado',
modified: 'Modificado',
notes: 'Notas',
notesPlaceholder: 'Ajustes de impresión, consejos, qué funcionó...',
externalLink: 'Enlace externo',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Abrir enlace',
photos: 'Fotos',
addPhoto: 'Añadir foto',
noPhotos: 'Aún no hay fotos',
deletePhoto: 'Eliminar foto',
photoCount_one: '{{count}} foto',
photoCount_other: '{{count}} fotos',
hasNotes: 'Tiene notas',
save: 'Guardar',
saved: 'Detalles del archivo guardados',
saveFailed: 'No se pudieron guardar los detalles del archivo',
uploadFailed: 'No se pudo subir la foto',
deleteFailed: 'No se pudo eliminar la foto',
},
noPermissionRenameFile: 'No tiene permiso para renombrar este archivo',
noPermissionGenerateThumbnail: 'No tiene permiso para generar miniaturas',
noPermissionDeleteFile: 'No tiene permiso para eliminar este archivo',
+43
View File
@@ -3984,6 +3984,49 @@ export default {
noPermissionDownload: 'Pas d\'autorisation téléchargement',
noPermissionPreview: 'Vous n\'avez pas la permission de prévisualiser les fichiers',
preview3d: 'Aperçu 3D',
// Aperçus de documents intégrés (#2976) : fichiers PDF et tableurs.
preview: {
open: 'Aperçu',
error: 'Ce fichier ne peut pas être prévisualisé.',
tooLarge: 'Le fichier est trop volumineux pour être prévisualisé ({{size}}).',
page: 'Page {{current}} sur {{total}}',
prevPage: 'Page précédente',
nextPage: 'Page suivante',
zoomIn: 'Zoom avant',
zoomOut: 'Zoom arrière',
emptySheet: 'Cette feuille est vide',
truncatedRows: 'Affichage des {{shown}} premières lignes sur {{total}}',
truncatedCols: 'Affichage des {{shown}} premières colonnes sur {{total}}',
},
details: {
title: 'Détails du fichier',
size: 'Taille',
type: 'Type',
printName: 'Nom d\'impression',
printTime: 'Temps d\'impression',
filament: 'Filament',
slicedFor: 'Tranché pour',
source: 'Source',
created: 'Créé',
modified: 'Modifié',
notes: 'Notes',
notesPlaceholder: 'Réglages d\'impression, astuces, ce qui a fonctionné...',
externalLink: 'Lien externe',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Ouvrir le lien',
photos: 'Photos',
addPhoto: 'Ajouter une photo',
noPhotos: 'Pas encore de photos',
deletePhoto: 'Supprimer la photo',
photoCount_one: '{{count}} photo',
photoCount_other: '{{count}} photos',
hasNotes: 'Contient des notes',
save: 'Enregistrer',
saved: 'Détails du fichier enregistrés',
saveFailed: 'Échec de l\'enregistrement des détails du fichier',
uploadFailed: 'Échec de l\'envoi de la photo',
deleteFailed: 'Échec de la suppression de la photo',
},
noPermissionRenameFile: 'Pas d\'autorisation renommage fichier',
noPermissionGenerateThumbnail: 'Pas d\'autorisation vignettes',
noPermissionDeleteFile: 'Pas d\'autorisation suppression fichier',
+43
View File
@@ -3983,6 +3983,49 @@ export default {
noPermissionDownload: 'Non hai il permesso di scaricare file',
noPermissionPreview: 'Non hai il permesso di visualizzare l\'anteprima dei file',
preview3d: 'Anteprima 3D',
// Anteprime dei documenti in-app (#2976): file PDF e fogli di calcolo.
preview: {
open: 'Anteprima',
error: 'Impossibile visualizzare l\'anteprima di questo file.',
tooLarge: 'Il file è troppo grande per l\'anteprima ({{size}}).',
page: 'Pagina {{current}} di {{total}}',
prevPage: 'Pagina precedente',
nextPage: 'Pagina successiva',
zoomIn: 'Ingrandisci',
zoomOut: 'Riduci',
emptySheet: 'Questo foglio è vuoto',
truncatedRows: 'Visualizzate le prime {{shown}} righe di {{total}}',
truncatedCols: 'Visualizzate le prime {{shown}} colonne di {{total}}',
},
details: {
title: 'Dettagli del file',
size: 'Dimensione',
type: 'Tipo',
printName: 'Nome stampa',
printTime: 'Tempo di stampa',
filament: 'Filamento',
slicedFor: 'Slicing per',
source: 'Origine',
created: 'Creato',
modified: 'Modificato',
notes: 'Note',
notesPlaceholder: 'Impostazioni di stampa, consigli, cosa ha funzionato...',
externalLink: 'Link esterno',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Apri link',
photos: 'Foto',
addPhoto: 'Aggiungi foto',
noPhotos: 'Ancora nessuna foto',
deletePhoto: 'Elimina foto',
photoCount_one: '{{count}} foto',
photoCount_other: '{{count}} foto',
hasNotes: 'Contiene note',
save: 'Salva',
saved: 'Dettagli del file salvati',
saveFailed: 'Impossibile salvare i dettagli del file',
uploadFailed: 'Impossibile caricare la foto',
deleteFailed: 'Impossibile eliminare la foto',
},
noPermissionRenameFile: 'Non hai il permesso di rinominare questo file',
noPermissionGenerateThumbnail: 'Non hai il permesso di generare miniature',
noPermissionDeleteFile: 'Non hai il permesso di eliminare questo file',
+43
View File
@@ -3995,6 +3995,49 @@ export default {
noPermissionDownload: 'ファイルをダウンロードする権限がありません',
noPermissionPreview: 'ファイルをプレビューする権限がありません',
preview3d: '3Dプレビュー',
// アプリ内ドキュメントプレビュー(#2976):PDFおよびスプレッドシートファイル。
preview: {
open: 'プレビュー',
error: 'このファイルはプレビューできません。',
tooLarge: 'ファイルが大きすぎるためプレビューできません({{size}})。',
page: '{{current}} / {{total}} ページ',
prevPage: '前のページ',
nextPage: '次のページ',
zoomIn: '拡大',
zoomOut: '縮小',
emptySheet: 'このシートは空です',
truncatedRows: '全{{total}}行のうち最初の{{shown}}行を表示しています',
truncatedCols: '全{{total}}列のうち最初の{{shown}}列を表示しています',
},
details: {
title: 'ファイルの詳細',
size: 'サイズ',
type: '種類',
printName: 'プリント名',
printTime: '印刷時間',
filament: 'フィラメント',
slicedFor: 'スライス対象',
source: 'ソース',
created: '作成日',
modified: '更新日',
notes: 'メモ',
notesPlaceholder: '印刷設定、コツ、うまくいった点など...',
externalLink: '外部リンク',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'リンクを開く',
photos: '写真',
addPhoto: '写真を追加',
noPhotos: 'まだ写真がありません',
deletePhoto: '写真を削除',
photoCount_one: '写真 {{count}} 枚',
photoCount_other: '写真 {{count}} 枚',
hasNotes: 'メモあり',
save: '保存',
saved: 'ファイルの詳細を保存しました',
saveFailed: 'ファイルの詳細を保存できませんでした',
uploadFailed: '写真をアップロードできませんでした',
deleteFailed: '写真を削除できませんでした',
},
noPermissionRenameFile: 'このファイル名を変更する権限がありません',
noPermissionGenerateThumbnail: 'サムネイルを生成する権限がありません',
noPermissionDeleteFile: 'このファイルを削除する権限がありません',
+43
View File
@@ -3805,6 +3805,49 @@ export default {
noPermissionDownload: '파일 다운로드 권한이 없습니다',
noPermissionPreview: '파일을 미리 볼 권한이 없습니다',
preview3d: '3D 미리보기',
// In-app document previews (#2976): PDF and spreadsheet files.
preview: {
open: '미리보기',
error: '이 파일은 미리 볼 수 없습니다.',
tooLarge: '파일이 너무 커서 미리 볼 수 없습니다 ({{size}}).',
page: '{{total}}페이지 중 {{current}}페이지',
prevPage: '이전 페이지',
nextPage: '다음 페이지',
zoomIn: '확대',
zoomOut: '축소',
emptySheet: '이 시트는 비어 있습니다',
truncatedRows: '전체 {{total}}개 행 중 처음 {{shown}}개 표시 중',
truncatedCols: '전체 {{total}}개 열 중 처음 {{shown}}개 표시 중',
},
details: {
title: '파일 세부 정보',
size: '크기',
type: '유형',
printName: '출력물 이름',
printTime: '출력 시간',
filament: '필라멘트',
slicedFor: '슬라이싱 대상',
source: '출처',
created: '생성일',
modified: '수정일',
notes: '메모',
notesPlaceholder: '출력 설정, 팁, 잘 된 점...',
externalLink: '외부 링크',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: '링크 열기',
photos: '사진',
addPhoto: '사진 추가',
noPhotos: '아직 사진이 없습니다',
deletePhoto: '사진 삭제',
photoCount_one: '사진 {{count}}장',
photoCount_other: '사진 {{count}}장',
hasNotes: '메모 있음',
save: '저장',
saved: '파일 세부 정보가 저장되었습니다',
saveFailed: '파일 세부 정보를 저장하지 못했습니다',
uploadFailed: '사진을 업로드하지 못했습니다',
deleteFailed: '사진을 삭제하지 못했습니다',
},
noPermissionRenameFile: '파일 이름 변경 권한이 없습니다',
noPermissionGenerateThumbnail: '썸네일 생성 권한이 없습니다',
noPermissionDeleteFile: '파일 삭제 권한이 없습니다',
+43
View File
@@ -4025,6 +4025,49 @@ export default {
noPermissionDownload: 'Je hebt geen toestemming om bestanden te downloaden',
noPermissionPreview: 'Je hebt geen toestemming om bestanden te bekijken',
preview3d: '3D-voorbeeld',
// In-app documentvoorbeelden (#2976): PDF- en spreadsheetbestanden.
preview: {
open: 'Voorbeeld',
error: 'Dit bestand kan niet worden weergegeven.',
tooLarge: 'Bestand is te groot om weer te geven ({{size}}).',
page: 'Pagina {{current}} van {{total}}',
prevPage: 'Vorige pagina',
nextPage: 'Volgende pagina',
zoomIn: 'Inzoomen',
zoomOut: 'Uitzoomen',
emptySheet: 'Dit werkblad is leeg',
truncatedRows: 'Eerste {{shown}} van {{total}} rijen worden getoond',
truncatedCols: 'Eerste {{shown}} van {{total}} kolommen worden getoond',
},
details: {
title: 'Bestandsdetails',
size: 'Grootte',
type: 'Type',
printName: 'Printnaam',
printTime: 'Printtijd',
filament: 'Filament',
slicedFor: 'Gesliced voor',
source: 'Bron',
created: 'Aangemaakt',
modified: 'Gewijzigd',
notes: 'Notities',
notesPlaceholder: 'Printinstellingen, tips, wat werkte...',
externalLink: 'Externe link',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Link openen',
photos: 'Foto\'s',
addPhoto: 'Foto toevoegen',
noPhotos: 'Nog geen foto\'s',
deletePhoto: 'Foto verwijderen',
photoCount_one: '{{count}} foto',
photoCount_other: '{{count}} foto\'s',
hasNotes: 'Heeft notities',
save: 'Opslaan',
saved: 'Bestandsdetails opgeslagen',
saveFailed: 'Bestandsdetails konden niet worden opgeslagen',
uploadFailed: 'Foto kon niet worden geüpload',
deleteFailed: 'Foto kon niet worden verwijderd',
},
noPermissionRenameFile: 'Je hebt geen toestemming om dit bestand te hernoemen',
noPermissionGenerateThumbnail: 'Je hebt geen toestemming om miniaturen te genereren',
noPermissionDeleteFile: 'Je hebt geen toestemming om dit bestand te verwijderen',
+43
View File
@@ -3983,6 +3983,49 @@ export default {
noPermissionDownload: 'Você não tem permissão para baixar arquivos',
noPermissionPreview: 'Você não tem permissão para pré-visualizar arquivos',
preview3d: 'Pré-visualização 3D',
// Pré-visualizações de documentos no app (#2976): arquivos PDF e planilhas.
preview: {
open: 'Pré-visualizar',
error: 'Este arquivo não pode ser pré-visualizado.',
tooLarge: 'O arquivo é muito grande para pré-visualizar ({{size}}).',
page: 'Página {{current}} de {{total}}',
prevPage: 'Página anterior',
nextPage: 'Próxima página',
zoomIn: 'Aumentar zoom',
zoomOut: 'Diminuir zoom',
emptySheet: 'Esta planilha está vazia',
truncatedRows: 'Mostrando as primeiras {{shown}} de {{total}} linhas',
truncatedCols: 'Mostrando as primeiras {{shown}} de {{total}} colunas',
},
details: {
title: 'Detalhes do arquivo',
size: 'Tamanho',
type: 'Tipo',
printName: 'Nome da impressão',
printTime: 'Tempo de impressão',
filament: 'Filamento',
slicedFor: 'Fatiado para',
source: 'Origem',
created: 'Criado',
modified: 'Modificado',
notes: 'Notas',
notesPlaceholder: 'Configurações de impressão, dicas, o que funcionou...',
externalLink: 'Link externo',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Abrir link',
photos: 'Fotos',
addPhoto: 'Adicionar foto',
noPhotos: 'Ainda não há fotos',
deletePhoto: 'Excluir foto',
photoCount_one: '{{count}} foto',
photoCount_other: '{{count}} fotos',
hasNotes: 'Tem notas',
save: 'Salvar',
saved: 'Detalhes do arquivo salvos',
saveFailed: 'Falha ao salvar os detalhes do arquivo',
uploadFailed: 'Falha ao enviar a foto',
deleteFailed: 'Falha ao excluir a foto',
},
noPermissionRenameFile: 'Você não tem permissão para renomear este arquivo',
noPermissionGenerateThumbnail: 'Você não tem permissão para gerar miniaturas',
noPermissionDeleteFile: 'Você não tem permissão para excluir este arquivo',
+43
View File
@@ -3797,6 +3797,49 @@ export default {
noPermissionDownload: "У вас нет прав на скачивание файлов",
noPermissionPreview: 'У вас нет прав на предварительный просмотр файлов',
preview3d: '3D-просмотр',
// Встроенный предварительный просмотр документов (#2976): PDF и электронные таблицы.
preview: {
open: 'Предварительный просмотр',
error: 'Предварительный просмотр этого файла невозможен.',
tooLarge: 'Файл слишком большой для предварительного просмотра ({{size}}).',
page: 'Страница {{current}} из {{total}}',
prevPage: 'Предыдущая страница',
nextPage: 'Следующая страница',
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
emptySheet: 'Этот лист пуст',
truncatedRows: 'Показаны первые {{shown}} из {{total}} строк',
truncatedCols: 'Показаны первые {{shown}} из {{total}} столбцов',
},
details: {
title: 'Сведения о файле',
size: 'Размер',
type: 'Тип',
printName: 'Название печати',
printTime: 'Время печати',
filament: 'Филамент',
slicedFor: 'Нарезано для',
source: 'Источник',
created: 'Создан',
modified: 'Изменён',
notes: 'Заметки',
notesPlaceholder: 'Настройки печати, советы, что сработало...',
externalLink: 'Внешняя ссылка',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Открыть ссылку',
photos: 'Фото',
addPhoto: 'Добавить фото',
noPhotos: 'Фото пока нет',
deletePhoto: 'Удалить фото',
photoCount_one: '{{count}} фото',
photoCount_other: '{{count}} фото',
hasNotes: 'Есть заметки',
save: 'Сохранить',
saved: 'Сведения о файле сохранены',
saveFailed: 'Не удалось сохранить сведения о файле',
uploadFailed: 'Не удалось загрузить фото',
deleteFailed: 'Не удалось удалить фото',
},
noPermissionRenameFile: "У вас нет прав на переименование этого файла",
noPermissionGenerateThumbnail: "У вас нет прав на создание миниатюр",
noPermissionDeleteFile: "У вас нет прав на удаление этого файла",
+43
View File
@@ -4025,6 +4025,49 @@ errors: {
noPermissionDownload: 'Du har inte behörighet att ladda ner filer',
noPermissionPreview: 'Du har inte behörighet att förhandsgranska filer',
preview3d: '3D-förhandsgranskning',
// Dokumentförhandsgranskning i appen (#2976): PDF- och kalkylbladsfiler.
preview: {
open: 'Förhandsgranska',
error: 'Den här filen kan inte förhandsgranskas.',
tooLarge: 'Filen är för stor för att förhandsgranskas ({{size}}).',
page: 'Sida {{current}} av {{total}}',
prevPage: 'Föregående sida',
nextPage: 'Nästa sida',
zoomIn: 'Zooma in',
zoomOut: 'Zooma ut',
emptySheet: 'Det här bladet är tomt',
truncatedRows: 'Visar de första {{shown}} av {{total}} raderna',
truncatedCols: 'Visar de första {{shown}} av {{total}} kolumnerna',
},
details: {
title: 'Filinformation',
size: 'Storlek',
type: 'Typ',
printName: 'Utskriftsnamn',
printTime: 'Utskriftstid',
filament: 'Filament',
slicedFor: 'Slicad för',
source: 'Källa',
created: 'Skapad',
modified: 'Ändrad',
notes: 'Anteckningar',
notesPlaceholder: 'Utskriftsinställningar, tips, vad som fungerade...',
externalLink: 'Extern länk',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Öppna länk',
photos: 'Foton',
addPhoto: 'Lägg till foto',
noPhotos: 'Inga foton än',
deletePhoto: 'Ta bort foto',
photoCount_one: '{{count}} foto',
photoCount_other: '{{count}} foton',
hasNotes: 'Har anteckningar',
save: 'Spara',
saved: 'Filinformationen har sparats',
saveFailed: 'Filinformationen kunde inte sparas',
uploadFailed: 'Fotot kunde inte laddas upp',
deleteFailed: 'Fotot kunde inte tas bort',
},
noPermissionRenameFile: 'Du har inte behörighet att döpa om denna fil',
noPermissionGenerateThumbnail: 'Du har inte behörighet att generera minibilder',
noPermissionDeleteFile: 'Du har inte behörighet att ta bort denna fil',
+43
View File
@@ -3990,6 +3990,49 @@ export default {
noPermissionDownload: 'Dosyaları indirme izniniz yok',
noPermissionPreview: 'Dosyaları önizleme izniniz yok',
preview3d: '3B Önizleme',
// Uygulama içi belge önizlemeleri (#2976): PDF ve elektronik tablo dosyaları.
preview: {
open: 'Önizleme',
error: 'Bu dosya önizlenemiyor.',
tooLarge: 'Dosya önizleme için çok büyük ({{size}}).',
page: 'Sayfa {{current}} / {{total}}',
prevPage: 'Önceki sayfa',
nextPage: 'Sonraki sayfa',
zoomIn: 'Yakınlaştır',
zoomOut: 'Uzaklaştır',
emptySheet: 'Bu sayfa boş',
truncatedRows: '{{total}} satırdan ilk {{shown}} tanesi gösteriliyor',
truncatedCols: '{{total}} sütundan ilk {{shown}} tanesi gösteriliyor',
},
details: {
title: 'Dosya ayrıntıları',
size: 'Boyut',
type: 'Tür',
printName: 'Baskı adı',
printTime: 'Baskı süresi',
filament: 'Filament',
slicedFor: 'Dilimlenen yazıcı',
source: 'Kaynak',
created: 'Oluşturulma',
modified: 'Değiştirilme',
notes: 'Notlar',
notesPlaceholder: 'Baskı ayarları, ipuçları, işe yarayanlar...',
externalLink: 'Harici bağlantı',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Bağlantıyı aç',
photos: 'Fotoğraflar',
addPhoto: 'Fotoğraf ekle',
noPhotos: 'Henüz fotoğraf yok',
deletePhoto: 'Fotoğrafı sil',
photoCount_one: '{{count}} fotoğraf',
photoCount_other: '{{count}} fotoğraf',
hasNotes: 'Not içeriyor',
save: 'Kaydet',
saved: 'Dosya ayrıntıları kaydedildi',
saveFailed: 'Dosya ayrıntıları kaydedilemedi',
uploadFailed: 'Fotoğraf yüklenemedi',
deleteFailed: 'Fotoğraf silinemedi',
},
noPermissionRenameFile: 'Bu dosyayı yeniden adlandırma izniniz yok',
noPermissionGenerateThumbnail: 'Küçük resim oluşturma izniniz yok',
noPermissionDeleteFile: 'Bu dosyayı silme izniniz yok',
+43
View File
@@ -4023,6 +4023,49 @@ export default {
noPermissionDownload: "У вас немає дозволу на завантаження файлів",
noPermissionPreview: 'У вас немає дозволу на попередній перегляд файлів',
preview3d: '3D-перегляд',
// Попередній перегляд документів у застосунку (#2976): PDF та електронні таблиці.
preview: {
open: 'Попередній перегляд',
error: 'Цей файл неможливо переглянути.',
tooLarge: 'Файл завеликий для попереднього перегляду ({{size}}).',
page: 'Сторінка {{current}} з {{total}}',
prevPage: 'Попередня сторінка',
nextPage: 'Наступна сторінка',
zoomIn: 'Збільшити',
zoomOut: 'Зменшити',
emptySheet: 'Цей аркуш порожній',
truncatedRows: 'Показано перші {{shown}} з {{total}} рядків',
truncatedCols: 'Показано перші {{shown}} з {{total}} стовпців',
},
details: {
title: 'Відомості про файл',
size: 'Розмір',
type: 'Тип',
printName: 'Назва друку',
printTime: 'Час друку',
filament: 'Філамент',
slicedFor: 'Нарізано для',
source: 'Джерело',
created: 'Створено',
modified: 'Змінено',
notes: 'Нотатки',
notesPlaceholder: 'Налаштування друку, поради, що спрацювало...',
externalLink: 'Зовнішнє посилання',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: 'Відкрити посилання',
photos: 'Фото',
addPhoto: 'Додати фото',
noPhotos: 'Фото поки немає',
deletePhoto: 'Видалити фото',
photoCount_one: '{{count}} фото',
photoCount_other: '{{count}} фото',
hasNotes: 'Є нотатки',
save: 'Зберегти',
saved: 'Відомості про файл збережено',
saveFailed: 'Не вдалося зберегти відомості про файл',
uploadFailed: 'Не вдалося завантажити фото',
deleteFailed: 'Не вдалося видалити фото',
},
noPermissionRenameFile: "Ви не маєте дозволу на перейменування цього файлу",
noPermissionGenerateThumbnail: "Ви не маєте дозволу створювати мініатюри",
noPermissionDeleteFile: "Ви не маєте дозволу на видалення цього файлу",
+43
View File
@@ -3983,6 +3983,49 @@ export default {
noPermissionDownload: '您没有下载文件的权限',
noPermissionPreview: '您没有预览文件的权限',
preview3d: '3D 预览',
// 应用内文档预览(#2976):PDF 和电子表格文件。
preview: {
open: '预览',
error: '此文件无法预览。',
tooLarge: '文件过大,无法预览({{size}})。',
page: '第 {{current}} 页,共 {{total}} 页',
prevPage: '上一页',
nextPage: '下一页',
zoomIn: '放大',
zoomOut: '缩小',
emptySheet: '此工作表为空',
truncatedRows: '仅显示前 {{shown}} 行,共 {{total}} 行',
truncatedCols: '仅显示前 {{shown}} 列,共 {{total}} 列',
},
details: {
title: '文件详情',
size: '大小',
type: '类型',
printName: '打印名称',
printTime: '打印时间',
filament: '耗材',
slicedFor: '切片目标',
source: '来源',
created: '创建时间',
modified: '修改时间',
notes: '备注',
notesPlaceholder: '打印设置、技巧、有效的做法...',
externalLink: '外部链接',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: '打开链接',
photos: '照片',
addPhoto: '添加照片',
noPhotos: '暂无照片',
deletePhoto: '删除照片',
photoCount_one: '{{count}} 张照片',
photoCount_other: '{{count}} 张照片',
hasNotes: '有备注',
save: '保存',
saved: '文件详情已保存',
saveFailed: '文件详情保存失败',
uploadFailed: '照片上传失败',
deleteFailed: '照片删除失败',
},
noPermissionRenameFile: '您没有重命名此文件的权限',
noPermissionGenerateThumbnail: '您没有生成缩略图的权限',
noPermissionDeleteFile: '您没有删除此文件的权限',
+43
View File
@@ -3983,6 +3983,49 @@ export default {
noPermissionDownload: '您沒有下載檔案的權限',
noPermissionPreview: '您沒有預覽檔案的權限',
preview3d: '3D 預覽',
// 應用程式內文件預覽(#2976):PDF 與試算表檔案。
preview: {
open: '預覽',
error: '此檔案無法預覽。',
tooLarge: '檔案過大,無法預覽({{size}})。',
page: '第 {{current}} 頁,共 {{total}} 頁',
prevPage: '上一頁',
nextPage: '下一頁',
zoomIn: '放大',
zoomOut: '縮小',
emptySheet: '此工作表是空的',
truncatedRows: '顯示前 {{shown}} 列,共 {{total}} 列',
truncatedCols: '顯示前 {{shown}} 欄,共 {{total}} 欄',
},
details: {
title: '檔案詳細資料',
size: '大小',
type: '類型',
printName: '列印名稱',
printTime: '列印時間',
filament: '線材',
slicedFor: '切片目標',
source: '來源',
created: '建立時間',
modified: '修改時間',
notes: '備註',
notesPlaceholder: '列印設定、技巧、有效的做法...',
externalLink: '外部連結',
externalLinkPlaceholder: 'https://printables.com/model/...',
openLink: '開啟連結',
photos: '照片',
addPhoto: '新增照片',
noPhotos: '尚無照片',
deletePhoto: '刪除照片',
photoCount_one: '{{count}} 張照片',
photoCount_other: '{{count}} 張照片',
hasNotes: '有備註',
save: '儲存',
saved: '檔案詳細資料已儲存',
saveFailed: '檔案詳細資料儲存失敗',
uploadFailed: '照片上傳失敗',
deleteFailed: '照片刪除失敗',
},
noPermissionRenameFile: '您沒有重新命名此檔案的權限',
noPermissionGenerateThumbnail: '您沒有產生縮圖的權限',
noPermissionDeleteFile: '您沒有刪除此檔案的權限',
+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}
+31
View File
@@ -0,0 +1,31 @@
// Hand-written types for occt-import-js (#2976) — the package ships none.
// Only the surface the STEP preview uses is declared.
declare module 'occt-import-js' {
export interface OcctAttributeArray {
array: number[];
}
export interface OcctMesh {
name?: string;
// Face colour as 0-1 RGB floats when the STEP file defines one.
color?: [number, number, number];
attributes: {
position: OcctAttributeArray;
normal?: OcctAttributeArray;
};
index: OcctAttributeArray;
}
export interface OcctImportResult {
success: boolean;
meshes: OcctMesh[];
}
export interface OcctInstance {
ReadStepFile: (content: Uint8Array, params: unknown) => OcctImportResult;
}
export default function occtimportjs(options?: {
locateFile?: (name: string) => string;
}): Promise<OcctInstance>;
}
@@ -0,0 +1,77 @@
/// <reference lib="webworker" />
// STEP triangulation worker (#2976).
//
// OpenCascade compiled to WASM does the triangulation. It runs in a worker
// for two reasons: parsing a real assembly takes seconds and would freeze
// the UI thread, and — decisive — the emscripten/embind glue generates its
// invoker functions with `new Function(...)`, which the app's nonce-strict
// CSP rightly blocks on the document. Per CSP3 a dedicated worker is
// governed by the policy delivered with this script's own response, so the
// backend relaxes 'unsafe-eval' for exactly this asset and nothing else
// (see security_headers_middleware in backend/app/main.py).
import occtimportjs from 'occt-import-js';
import type { OcctInstance } from 'occt-import-js';
import wasmUrl from 'occt-import-js/dist/occt-import-js.wasm?url';
export interface StepWorkerRequest {
/** Correlation id echoed on the response — the worker is shared. */
id: number;
buffer: ArrayBuffer;
}
export interface StepWorkerMesh {
positions: Float32Array;
normals: Float32Array | null;
indices: Uint32Array | null;
color: [number, number, number] | null;
}
export type StepWorkerResponse =
| { id: number; ok: true; meshes: StepWorkerMesh[] }
| { id: number; ok: false; reason: 'no-meshes' | 'error' };
// The ~7 MB wasm instance is expensive to initialise — created once and kept
// for the worker's lifetime. A failed init is not cached so a later preview
// retries from scratch.
let instancePromise: Promise<OcctInstance> | null = null;
function getInstance(): Promise<OcctInstance> {
if (!instancePromise) {
instancePromise = occtimportjs({ locateFile: () => wasmUrl }).catch((err: unknown) => {
instancePromise = null;
throw err;
});
}
return instancePromise;
}
self.onmessage = async (event: MessageEvent<StepWorkerRequest>) => {
const { id } = event.data;
try {
const occt = await getInstance();
const result = occt.ReadStepFile(new Uint8Array(event.data.buffer), null);
if (!result.success || result.meshes.length === 0) {
self.postMessage({ id, ok: false, reason: 'no-meshes' } satisfies StepWorkerResponse);
return;
}
const meshes = result.meshes.map(
(mesh): StepWorkerMesh => ({
positions: new Float32Array(mesh.attributes.position.array),
normals: mesh.attributes.normal ? new Float32Array(mesh.attributes.normal.array) : null,
indices: mesh.index ? new Uint32Array(mesh.index.array) : null,
color: mesh.color ?? null,
})
);
// Transfer the typed-array buffers instead of structured-cloning them —
// large assemblies are tens of MB of vertex data.
const transfers = meshes.flatMap((mesh) => {
const buffers = [mesh.positions.buffer];
if (mesh.normals) buffers.push(mesh.normals.buffer);
if (mesh.indices) buffers.push(mesh.indices.buffer);
return buffers;
});
self.postMessage({ id, ok: true, meshes } satisfies StepWorkerResponse, transfers);
} catch {
self.postMessage({ id, ok: false, reason: 'error' } satisfies StepWorkerResponse);
}
};