mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
File Manager: external link, notes and photos on library files (#3128)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'このファイルを削除する権限がありません',
|
||||
|
||||
@@ -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: '파일 삭제 권한이 없습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "У вас нет прав на удаление этого файла",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "Ви не маєте дозволу на видалення цього файлу",
|
||||
|
||||
@@ -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: '您没有删除此文件的权限',
|
||||
|
||||
@@ -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: '您沒有刪除此檔案的權限',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user