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

This commit is contained in:
Thomansky
2026-09-26 08:56:48 +02:00
committed by GitHub
parent 222f28c7f8
commit 12dddada0a
49 changed files with 4817 additions and 57 deletions
@@ -0,0 +1,346 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Link, Camera, Trash2, Loader2, Plus, ExternalLink, StickyNote } from 'lucide-react';
import { api } from '../api/client';
import type { LibraryFileListItem } from '../api/client';
import { Button } from './Button';
import { PhotoGalleryModal } from './PhotoGalleryModal';
import { useToast } from '../contexts/ToastContext';
import { formatDate, formatDuration } from '../utils/date';
import { formatFileSize } from '../utils/file';
interface LibraryFileDetailsModalProps {
file: LibraryFileListItem;
// Notes, link and photos are edits to the file, so they follow the same
// ownership gate as rename (`canModify('library', 'update', ...)`).
canEdit: boolean;
onClose: () => void;
}
// Notes, an external link and photos of the printed result on a library
// file (#3077) — the same trio EditArchiveModal offers for an archive. Photos
// are saved as they are added; notes and the link on Save.
export function LibraryFileDetailsModal({ file, canEdit, onClose }: LibraryFileDetailsModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const { data: details } = useQuery({
queryKey: ['library-file', file.id],
queryFn: () => api.getLibraryFile(file.id),
});
const [notes, setNotes] = useState('');
const [externalUrl, setExternalUrl] = useState('');
const [photos, setPhotos] = useState<string[]>([]);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
// The photo the lightbox opens on, or null while it is closed.
const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
const photoInputRef = useRef<HTMLInputElement>(null);
// Seed the form once per file. Later refetches (photo changes below, window
// focus) must not overwrite notes or a link the user is still typing.
const seededForId = useRef<number | null>(null);
useEffect(() => {
if (!details || seededForId.current === details.id) return;
seededForId.current = details.id;
setNotes(details.notes ?? '');
setExternalUrl(details.external_url ?? '');
setPhotos(details.photos ?? []);
}, [details]);
// Escape closes the modal, as it does in the rest of the app. Not while the
// lightbox is open on top of it — that handles Escape itself, and a second
// listener here would close both at once.
useEffect(() => {
if (galleryIndex !== null) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [galleryIndex, onClose]);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['library-files'] });
queryClient.invalidateQueries({ queryKey: ['library-file', file.id] });
};
const saveMutation = useMutation({
mutationFn: () => api.updateLibraryFile(file.id, { notes, external_url: externalUrl.trim() }),
onSuccess: () => {
invalidate();
showToast(t('fileManager.details.saved'), 'success');
onClose();
},
onError: () => {
showToast(t('fileManager.details.saveFailed'), 'error');
},
});
const hasChanges =
!!details && (notes !== (details.notes ?? '') || externalUrl.trim() !== (details.external_url ?? ''));
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0];
if (!picked) return;
setUploadingPhoto(true);
try {
const result = await api.uploadLibraryFilePhoto(file.id, picked);
setPhotos(result.photos);
invalidate();
} catch {
showToast(t('fileManager.details.uploadFailed'), 'error');
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) {
photoInputRef.current.value = '';
}
}
};
const handlePhotoDelete = async (filename: string) => {
try {
const result = await api.deleteLibraryFilePhoto(file.id, filename);
const remaining = result.photos ?? [];
setPhotos(remaining);
// Deleting the last photo unmounts the lightbox through the render
// guard below before its own "nothing left to show" branch can call
// onClose, so the index has to be cleared here. Left set, it keeps
// Escape disabled for good and re-opens the lightbox unasked as soon
// as another photo is uploaded.
if (remaining.length === 0) setGalleryIndex(null);
invalidate();
} catch {
showToast(t('fileManager.details.deleteFailed'), 'error');
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!canEdit || !hasChanges) return;
saveMutation.mutate();
};
const trimmedUrl = externalUrl.trim();
const facts: Array<{ label: string; value: React.ReactNode }> = [
{ label: t('fileManager.details.size'), value: formatFileSize(file.file_size) },
{ label: t('fileManager.details.type'), value: file.file_type.toUpperCase() },
];
if (details?.print_name) facts.push({ label: t('fileManager.details.printName'), value: details.print_name });
if (details?.print_time_seconds) {
facts.push({ label: t('fileManager.details.printTime'), value: formatDuration(details.print_time_seconds) });
}
if (details?.filament_used_grams) {
facts.push({ label: t('fileManager.details.filament'), value: `${details.filament_used_grams.toFixed(1)} g` });
}
if (details?.sliced_for_model) facts.push({ label: t('fileManager.details.slicedFor'), value: details.sliced_for_model });
if (details?.source_url) {
facts.push({
label: t('fileManager.details.source'),
value: (
<a
href={details.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-green hover:underline inline-flex items-center gap-1 min-w-0"
>
<span className="truncate">{details.source_url}</span>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
),
});
}
facts.push({ label: t('fileManager.details.created'), value: formatDate(file.created_at) });
facts.push({
label: t('fileManager.details.modified'),
value: formatDate(file.fs_modified_at ?? details?.updated_at ?? file.created_at),
});
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-b border-bambu-dark-tertiary">
<div className="min-w-0">
<p className="text-xs text-bambu-gray">{t('fileManager.details.title')}</p>
<div className="flex items-center gap-2 min-w-0">
<h2 className="text-lg font-semibold text-white truncate" title={file.filename}>
{file.filename}
</h2>
<span className="flex-shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-bambu-dark-tertiary text-bambu-gray">
{file.file_type.toUpperCase()}
</span>
</div>
</div>
<button
type="button"
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors flex-shrink-0"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{/* Facts */}
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
{facts.map((fact) => (
<div key={fact.label} className="contents">
<dt className="text-bambu-gray whitespace-nowrap">{fact.label}</dt>
<dd className="text-white min-w-0 truncate">{fact.value}</dd>
</div>
))}
</dl>
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<StickyNote className="w-4 h-4 inline mr-1" />
{t('fileManager.details.notes')}
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
disabled={!canEdit}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none resize-none disabled:opacity-60"
placeholder={t('fileManager.details.notesPlaceholder')}
/>
</div>
{/* External link */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Link className="w-4 h-4 inline mr-1" />
{t('fileManager.details.externalLink')}
</label>
<div className="flex items-center gap-2">
<input
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
disabled={!canEdit}
className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-60"
placeholder={t('fileManager.details.externalLinkPlaceholder')}
/>
{trimmedUrl && (
<a
href={trimmedUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-bambu-gray hover:text-bambu-green hover:bg-bambu-dark transition-colors"
title={t('fileManager.details.openLink')}
aria-label={t('fileManager.details.openLink')}
>
<ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
{/* Photos */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Camera className="w-4 h-4 inline mr-1" />
{t('fileManager.details.photos')}
</label>
<div className="flex flex-wrap gap-2">
{photos.map((filename, index) => (
<div key={filename} className="relative group">
<button
type="button"
onClick={() => setGalleryIndex(index)}
className="block w-20 h-20 rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
>
<img
src={api.getLibraryFilePhotoUrl(file.id, filename)}
alt={t('fileManager.details.photos')}
className="w-full h-full object-cover"
/>
</button>
{canEdit && (
<button
type="button"
onClick={() => handlePhotoDelete(filename)}
className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
title={t('fileManager.details.deletePhoto')}
aria-label={t('fileManager.details.deletePhoto')}
>
<Trash2 className="w-3 h-3 text-white" />
</button>
)}
</div>
))}
{canEdit && (
<label
className="w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors"
title={t('fileManager.details.addPhoto')}
>
<input
ref={photoInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handlePhotoUpload}
className="hidden"
disabled={uploadingPhoto}
aria-label={t('fileManager.details.addPhoto')}
/>
{uploadingPhoto ? (
<Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
) : (
<Plus className="w-6 h-6 text-bambu-gray" />
)}
</label>
)}
{photos.length === 0 && !canEdit && (
<p className="text-xs text-bambu-gray">{t('fileManager.details.noPhotos')}</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
{t('common.close')}
</Button>
{canEdit && (
<Button
type="submit"
variant="primary"
className="flex-1"
disabled={!hasChanges || saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{t('fileManager.details.save')}
</Button>
)}
</div>
</form>
</div>
{galleryIndex !== null && photos.length > 0 && (
<PhotoGalleryModal
archiveName={file.filename}
photos={photos}
initialIndex={galleryIndex}
getPhotoUrl={(filename) => api.getLibraryFilePhotoUrl(file.id, filename)}
onClose={() => setGalleryIndex(null)}
onDelete={canEdit ? handlePhotoDelete : undefined}
/>
)}
</div>
);
}
+174 -12
View File
@@ -9,6 +9,60 @@ import JSZip from 'jszip';
import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import { Button } from './Button';
import { getAuthToken } from '../api/client';
import type { StepWorkerMesh, StepWorkerResponse } from '../workers/stepPreview.worker';
// STEP triangulation runs in a dedicated worker (#2976): OpenCascade-as-WASM
// takes seconds on a real assembly and its embind glue needs an eval-relaxed
// CSP that only the worker's own script response carries (see the worker
// file and security_headers_middleware in backend/app/main.py). The worker —
// and with it the ~7 MB wasm — loads on the first STEP preview and is then
// kept warm for the page's lifetime.
let stepWorker: Worker | null = null;
let stepRequestId = 0;
const stepPendingRequests = new Map<
number,
{ resolve: (meshes: StepWorkerMesh[]) => void; reject: (err: Error) => void }
>();
function failAllStepRequests(message: string): void {
for (const pending of stepPendingRequests.values()) {
pending.reject(new Error(message));
}
stepPendingRequests.clear();
}
function getStepWorker(): Worker {
if (!stepWorker) {
const worker = new Worker(new URL('../workers/stepPreview.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event: MessageEvent<StepWorkerResponse>) => {
const pending = stepPendingRequests.get(event.data.id);
if (!pending) return;
stepPendingRequests.delete(event.data.id);
if (event.data.ok) {
pending.resolve(event.data.meshes);
} else {
pending.reject(new Error(event.data.reason));
}
};
// A crashed or unloadable worker is dropped so the next preview attempt
// starts a fresh one instead of reusing a dead instance.
worker.onerror = () => {
failAllStepRequests('error');
worker.terminate();
if (stepWorker === worker) stepWorker = null;
};
stepWorker = worker;
}
return stepWorker;
}
function parseStepInWorker(buffer: ArrayBuffer): Promise<StepWorkerMesh[]> {
return new Promise((resolve, reject) => {
const id = ++stepRequestId;
stepPendingRequests.set(id, { resolve, reject });
getStepWorker().postMessage({ id, buffer }, [buffer]);
});
}
/**
* Frame the camera on a bounding box.
@@ -61,8 +115,15 @@ interface ModelViewerProps {
filamentColors?: string[];
selectedPlateId?: number | null;
className?: string;
/** Called once with a 256px PNG of the first render — used by the file
* manager to persist a thumbnail for formats the server cannot render
* itself (STEP, #2976). */
onSnapshot?: (blob: Blob) => void;
}
// Triangulated STEP shape as posted back by the step preview worker.
type StepMeshData = StepWorkerMesh;
interface MeshData {
vertices: number[];
triangles: number[];
@@ -666,6 +727,41 @@ function buildModelGroup(
return group;
}
// One mesh per STEP shape so per-part colours survive; parts without a colour
// fall back to the same filament colour the STL path uses.
function buildStepGroup(meshes: StepMeshData[], filamentColors?: string[]): THREE.Group {
const group = new THREE.Group();
const fallback = filamentColors?.[0] || '#00ae42';
for (const meshData of meshes) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3));
if (meshData.normals) {
geometry.setAttribute('normal', new THREE.BufferAttribute(meshData.normals, 3));
}
if (meshData.indices) {
geometry.setIndex(new THREE.BufferAttribute(meshData.indices, 1));
}
if (!meshData.normals) {
geometry.computeVertexNormals();
}
// STEP is Z-up like STL; three.js is Y-up.
geometry.rotateX(-Math.PI / 2);
const color = meshData.color
? new THREE.Color(meshData.color[0], meshData.color[1], meshData.color[2])
: new THREE.Color(fallback);
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.62,
metalness: 0.0,
envMapIntensity: 0.55,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
group.add(mesh);
}
return group;
}
export function ModelViewer({
url,
fileType,
@@ -673,6 +769,7 @@ export function ModelViewer({
filamentColors,
selectedPlateId = null,
className = '',
onSnapshot,
}: ModelViewerProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
@@ -693,6 +790,14 @@ export function ModelViewer({
const [error, setError] = useState<string | null>(null);
const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
const [stepMeshes, setStepMeshes] = useState<StepMeshData[] | null>(null);
// Snapshot is a one-shot per loaded url; the callback lives in a ref so its
// identity never retriggers the (expensive) scene effects.
const snapshotSentRef = useRef(false);
const onSnapshotRef = useRef(onSnapshot);
useEffect(() => {
onSnapshotRef.current = onSnapshot;
});
useEffect(() => {
if (!containerRef.current) return;
@@ -819,6 +924,8 @@ export function ModelViewer({
setError(null);
setParsedData(null);
setStlGeometry(null);
setStepMeshes(null);
snapshotSentRef.current = false;
const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
@@ -863,6 +970,27 @@ export function ModelViewer({
setError(err.message);
setLoading(false);
});
} else if (normalizedType === 'step' || normalizedType === 'stp') {
// STEP has no three.js loader; triangulation happens in the dedicated
// OpenCascade worker (#2976), off the UI thread.
fetch(url, { headers })
.then((res) => {
if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad'));
return res.arrayBuffer();
})
.then(parseStepInWorker)
.then(setStepMeshes)
.catch((err: Error) => {
// Worker rejections carry machine reasons; everything else already
// is a translated message from the fetch step above.
const message = err.message === 'no-meshes'
? t('modelViewer.errors.noMeshes')
: err.message === 'error'
? t('modelViewer.errors.failedToLoad')
: err.message;
setError(message);
setLoading(false);
});
} else {
setError(t('modelViewer.errors.unsupportedFormat'));
setLoading(false);
@@ -909,15 +1037,17 @@ export function ModelViewer({
useEffect(() => {
if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
if (!parsedData && !stlGeometry) return;
if (!parsedData && !stlGeometry && !stepMeshes) return;
if (modelGroupRef.current) {
sceneRef.current.remove(modelGroupRef.current);
disposeGroup(modelGroupRef.current);
}
const isStlModel = !!stlGeometry;
const group = isStlModel
// STL and STEP are plain single models with no plate/build-item layout;
// they share the same centre-on-plate placement below.
const isPlainModel = !!stlGeometry || !!stepMeshes;
const group = stlGeometry
? (() => {
const materialColor = filamentColors?.[0] || '#00ae42';
const material = new THREE.MeshStandardMaterial({
@@ -926,13 +1056,15 @@ export function ModelViewer({
metalness: 0.0,
envMapIntensity: 0.55,
});
const mesh = new THREE.Mesh(stlGeometry!, material);
const mesh = new THREE.Mesh(stlGeometry, material);
mesh.castShadow = true;
const stlGroup = new THREE.Group();
stlGroup.add(mesh);
return stlGroup;
})()
: buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
: stepMeshes
? buildStepGroup(stepMeshes, filamentColors)
: buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
modelGroupRef.current = group;
sceneRef.current.add(group);
@@ -943,13 +1075,13 @@ export function ModelViewer({
// Always place models on the build plate (Y=0)
group.position.y = -box.min.y;
const selectedPlateBounds = (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0)
const selectedPlateBounds = (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0)
? parsedData!.plateBounds.get(selectedPlateId)
: undefined;
const selectedPlateOffset = (!isStlModel && selectedPlateId != null)
const selectedPlateOffset = (!isPlainModel && selectedPlateId != null)
? parsedData!.plateOffsets.get(selectedPlateId)
: undefined;
const shouldCenterOnPlate = isStlModel
const shouldCenterOnPlate = isPlainModel
|| parsedData!.buildItems.length === 0
|| (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset);
const centerOffsetX = shouldCenterOnPlate ? -center.x : 0;
@@ -957,7 +1089,7 @@ export function ModelViewer({
let plateOffsetX = 0;
let plateOffsetZ = 0;
if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
if (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
const plateBox = new THREE.Box3().setFromObject(group);
plateOffsetX = plateBox.min.x - selectedPlateBounds.minX;
plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY;
@@ -966,10 +1098,10 @@ export function ModelViewer({
const plateCenterX = buildVolume.x / 2;
const plateCenterZ = buildVolume.y / 2;
if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
if (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
group.position.x = centerOffsetX - plateOffsetX;
group.position.z = centerOffsetZ - plateOffsetZ;
} else if (!isStlModel && selectedPlateId != null && selectedPlateOffset) {
} else if (!isPlainModel && selectedPlateId != null && selectedPlateOffset) {
group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX);
group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY);
} else if (shouldCenterOnPlate) {
@@ -1003,7 +1135,37 @@ export function ModelViewer({
fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
setLoading(false);
}, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
// One-shot snapshot of the first framed render (#2976). Rendering
// explicitly right before reading the canvas keeps the WebGL buffer
// valid without preserveDrawingBuffer.
if (onSnapshotRef.current && !snapshotSentRef.current) {
snapshotSentRef.current = true;
requestAnimationFrame(() => {
const renderer = rendererRef.current;
const scene = sceneRef.current;
const camera = cameraRef.current;
if (!renderer || !scene || !camera) return;
try {
renderer.render(scene, camera);
const source = renderer.domElement;
const size = 256;
const side = Math.min(source.width, source.height);
const target = document.createElement('canvas');
target.width = size;
target.height = size;
const ctx = target.getContext('2d');
if (!ctx) return;
ctx.drawImage(source, (source.width - side) / 2, (source.height - side) / 2, side, side, 0, 0, size, size);
target.toBlob((blob) => {
if (blob) onSnapshotRef.current?.(blob);
}, 'image/png');
} catch {
// Snapshot is best-effort; the preview itself already rendered.
}
});
}
}, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]);
const resetView = () => {
if (cameraRef.current && controlsRef.current) {
+7 -2
View File
@@ -23,6 +23,9 @@ interface ModelViewerModalProps {
// externally — so the preview modal's slice action matches the file row's
// Cog (in-app Bambuddy SliceModal) when the slicer API is enabled.
onSliceWithBambuddy?: () => void;
// Forwarded to ModelViewer: one 256px PNG of the first render, used by the
// file manager to persist a STEP thumbnail (#2976).
onSnapshot?: (blob: Blob) => void;
}
interface Capabilities {
@@ -136,7 +139,7 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: Sli
);
}
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy, onSnapshot }: ModelViewerModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
@@ -185,7 +188,8 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
// upload path tags it `3mf`, so we accept both shapes here for
// the 3D-tab + g-code-tab gating (#1543).
const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
const hasModel = isThreeMfFamily || normalizedType === 'stl';
// STEP joins the 3D tab via the OpenCascade WASM loader in ModelViewer (#2976).
const hasModel = isThreeMfFamily || normalizedType === 'stl' || normalizedType === 'step' || normalizedType === 'stp';
setCapabilities({
has_model: hasModel,
has_source: false,
@@ -772,6 +776,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
filamentColors={capabilities.filament_colors}
selectedPlateId={selectedPlateId}
className="w-full h-full"
onSnapshot={onSnapshot}
/>
</div>
</div>
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, FileText, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react';
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
import { api, getAuthToken } from '../api/client';
import { formatFileSize } from '../utils/file';
// Fetching and parsing happen fully in the browser; beyond this size the
// preview shows a notice instead of stalling the tab on a giant download.
export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
interface PdfPreviewModalProps {
libraryFileId: number;
filename: string;
fileSize: number;
onClose: () => void;
/** Called once with a 256px PNG of the first page, for the grid thumbnail (#2976). */
onSnapshot?: (blob: Blob) => void;
}
// Square 256px crop of the rendered page, white-backed like a paper page.
function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
const size = 256;
const target = document.createElement('canvas');
target.width = size;
target.height = size;
const ctx = target.getContext('2d');
if (!ctx) return Promise.resolve(null);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
const scale = size / Math.max(source.width, source.height);
const w = source.width * scale;
const h = source.height * scale;
ctx.drawImage(source, (size - w) / 2, (size - h) / 2, w, h);
return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
}
export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const renderTaskRef = useRef<RenderTask | null>(null);
const snapshotSentRef = useRef(false);
const onSnapshotRef = useRef(onSnapshot);
useEffect(() => {
onSnapshotRef.current = onSnapshot;
});
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [pageNum, setPageNum] = useState(1);
const [zoom, setZoom] = useState(1);
const [error, setError] = useState<string | null>(null);
const [rendering, setRendering] = useState(true);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Load the document. pdf.js is imported on demand so the viewer and its
// worker stay out of the main bundle.
useEffect(() => {
let cancelled = false;
let loadingTask: PDFDocumentLoadingTask | null = null;
setDoc(null);
setError(null);
setPageNum(1);
setRendering(true);
if (fileSize > PDF_PREVIEW_MAX_BYTES) {
setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
setRendering(false);
return;
}
const headers: HeadersInit = {};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
(async () => {
const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = await res.arrayBuffer();
const pdfjs = await import('pdfjs-dist');
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
}
loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) });
const loaded = await loadingTask.promise;
if (cancelled) {
// Cleanup below already ran; destroying the task tears down the doc.
return;
}
setDoc(loaded);
})().catch(() => {
if (!cancelled) {
setError(t('fileManager.preview.error'));
setRendering(false);
}
});
return () => {
cancelled = true;
renderTaskRef.current?.cancel();
renderTaskRef.current = null;
// Destroying the loading task also destroys the document and worker.
loadingTask?.destroy();
};
}, [libraryFileId, fileSize, t]);
// Render the current page into the canvas.
useEffect(() => {
if (!doc) return;
let cancelled = false;
setRendering(true);
(async () => {
const page = await doc.getPage(pageNum);
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || cancelled) return;
const baseViewport = page.getViewport({ scale: 1 });
// Fit the page width to the panel at zoom 1; render at device pixels.
const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const viewport = page.getViewport({ scale: fitScale * zoom * dpr });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
renderTaskRef.current?.cancel();
const task = page.render({ canvas, viewport });
renderTaskRef.current = task;
await task.promise;
if (cancelled) return;
setRendering(false);
if (pageNum === 1 && onSnapshotRef.current && !snapshotSentRef.current) {
snapshotSentRef.current = true;
const blob = await snapshotFromCanvas(canvas);
if (blob && !cancelled) onSnapshotRef.current?.(blob);
}
})().catch((err: unknown) => {
// A cancelled render throws RenderingCancelledException — not an error.
if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
setError(t('fileManager.preview.error'));
setRendering(false);
}
});
return () => {
cancelled = true;
};
}, [doc, pageNum, zoom, t]);
const pageCount = doc?.numPages ?? 0;
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-5xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />
<h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{doc && pageCount > 1 && (
<div className="flex items-center gap-1 mr-2">
<button
onClick={() => setPageNum((p) => Math.max(1, p - 1))}
disabled={pageNum <= 1}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
aria-label={t('fileManager.preview.prevPage')}
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-bambu-gray whitespace-nowrap">
{t('fileManager.preview.page', { current: pageNum, total: pageCount })}
</span>
<button
onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
disabled={pageNum >= pageCount}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
aria-label={t('fileManager.preview.nextPage')}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
{doc && (
<>
<button
onClick={() => setZoom((z) => Math.max(0.4, z * 0.8))}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('fileManager.preview.zoomOut')}
>
<ZoomOut className="w-4 h-4" />
</button>
<button
onClick={() => setZoom((z) => Math.min(4, z * 1.25))}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('fileManager.preview.zoomIn')}
>
<ZoomIn className="w-4 h-4" />
</button>
</>
)}
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Page */}
<div ref={containerRef} className="relative flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg p-4">
{error ? (
<div className="h-full flex items-center justify-center">
<p className="text-bambu-gray text-center">{error}</p>
</div>
) : (
<div className="flex justify-center min-w-fit">
<canvas ref={canvasRef} className="shadow-lg" />
</div>
)}
{!error && rendering && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
)}
</div>
</div>
</div>
);
}
+55 -28
View File
@@ -4,26 +4,37 @@ import { api } from '../api/client';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
interface PhotoGalleryModalProps {
archiveId: number;
// Archive photos resolve through `archiveId`; any other owner (a library
// file, #3077) passes `getPhotoUrl` instead. Exactly one of the two is
// required so a caller cannot silently end up requesting archive 0.
type PhotoSource =
| { archiveId: number; getPhotoUrl?: undefined }
| { archiveId?: undefined; getPhotoUrl: (filename: string) => string };
type PhotoGalleryModalProps = PhotoSource & {
archiveName: string;
photos: string[];
// Which photo to open on. A caller that opens the gallery from a per-photo
// grid passes the clicked index (#3077); one with a single "view photos"
// button leaves it at the first.
initialIndex?: number;
onClose: () => void;
onDelete?: (filename: string) => void;
}
};
export function PhotoGalleryModal({
archiveId,
archiveName,
photos,
onClose,
onDelete,
}: PhotoGalleryModalProps) {
const [currentIndex, setCurrentIndex] = useState(0);
export function PhotoGalleryModal(props: PhotoGalleryModalProps) {
const { archiveName, photos, initialIndex, onClose, onDelete } = props;
const [currentIndex, setCurrentIndex] = useState(() =>
Math.min(Math.max(initialIndex ?? 0, 0), Math.max(photos.length - 1, 0))
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Keyboard navigation
// Keyboard navigation. Stands down while the delete confirmation is up:
// that has its own Escape handler, so one press would cancel the prompt and
// close the gallery underneath it, and arrow keys would move the selection
// out from under a confirmation already naming a photo.
useEffect(() => {
if (showDeleteConfirm) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
@@ -31,7 +42,7 @@ export function PhotoGalleryModal({
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, photos.length]);
}, [onClose, photos.length, showDeleteConfirm]);
// Reset index if photos change
useEffect(() => {
@@ -45,8 +56,11 @@ export function PhotoGalleryModal({
return null;
}
const resolvePhotoUrl = (filename: string) =>
props.getPhotoUrl ? props.getPhotoUrl(filename) : api.getArchivePhotoUrl(props.archiveId, filename);
const currentPhoto = photos[currentIndex];
const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);
const photoUrl = resolvePhotoUrl(currentPhoto);
const handleDownload = () => {
const link = document.createElement('a');
@@ -61,10 +75,18 @@ export function PhotoGalleryModal({
}
};
// The gallery can be rendered inside another modal's overlay, and that
// overlay closes on a backdrop click (#3077). Dismissing the gallery must
// not bubble up and take the parent — and its unsaved edits — with it.
const handleBackdropClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClose();
};
return (
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
onClick={onClose}
onClick={handleBackdropClick}
>
<div
className="relative w-full h-full flex flex-col"
@@ -142,7 +164,7 @@ export function PhotoGalleryModal({
}`}
>
<img
src={api.getArchivePhotoUrl(archiveId, photo)}
src={resolvePhotoUrl(photo)}
alt={`Thumbnail ${index + 1}`}
className="w-full h-full object-cover"
/>
@@ -152,19 +174,24 @@ export function PhotoGalleryModal({
)}
</div>
{/* Delete Confirmation Modal */}
{/* Delete Confirmation Modal. Wrapped so that cancelling it by clicking
its backdrop cancels only the confirmation — the click would
otherwise reach the gallery backdrop below it and close the gallery
as well. */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Photo"
message="Delete this photo? This cannot be undone."
confirmText="Delete"
variant="danger"
onConfirm={() => {
onDelete?.(currentPhoto);
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
<div onClick={(e) => e.stopPropagation()}>
<ConfirmModal
title="Delete Photo"
message="Delete this photo? This cannot be undone."
confirmText="Delete"
variant="danger"
onConfirm={() => {
onDelete?.(currentPhoto);
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
</div>
)}
</div>
);
@@ -0,0 +1,305 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileSpreadsheet, Loader2, X } from 'lucide-react';
import { api, getAuthToken } from '../api/client';
import { formatFileSize } from '../utils/file';
// Parsing an arbitrarily large workbook would freeze the tab — anything over
// this size (or beyond the row/column caps) falls back to a truncation notice.
export const SPREADSHEET_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
const MAX_ROWS = 500;
const MAX_COLS = 40;
interface SheetGrid {
name: string;
rows: string[][];
totalRows: number;
totalCols: number;
}
interface SpreadsheetPreviewModalProps {
libraryFileId: number;
filename: string;
/** csv | xlsx | ods */
fileType: string;
fileSize: number;
onClose: () => void;
/** Called once with a 256px PNG of the first sheet, for the grid thumbnail (#2976). */
onSnapshot?: (blob: Blob) => void;
}
// Spreadsheet-style column letters: 0 -> A, 25 -> Z, 26 -> AA, ...
function columnLabel(index: number): string {
let label = '';
let i = index;
while (i >= 0) {
label = String.fromCharCode(65 + (i % 26)) + label;
i = Math.floor(i / 26) - 1;
}
return label;
}
// Mini table rendered onto a canvas as the grid thumbnail. Dark background to
// match the STL thumbnails the grid already shows.
function drawSheetSnapshot(rows: string[][]): Promise<Blob | null> {
const size = 256;
const cols = 5;
const rowCount = 9;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return Promise.resolve(null);
const cellW = size / cols;
const cellH = size / rowCount;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = 'rgba(0, 174, 66, 0.25)';
ctx.fillRect(0, 0, size, cellH);
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
for (let c = 1; c < cols; c++) {
ctx.beginPath();
ctx.moveTo(c * cellW + 0.5, 0);
ctx.lineTo(c * cellW + 0.5, size);
ctx.stroke();
}
for (let r = 1; r < rowCount; r++) {
ctx.beginPath();
ctx.moveTo(0, r * cellH + 0.5);
ctx.lineTo(size, r * cellH + 0.5);
ctx.stroke();
}
ctx.fillStyle = '#d4d4d4';
ctx.font = '11px sans-serif';
ctx.textBaseline = 'middle';
for (let r = 0; r < rowCount; r++) {
for (let c = 0; c < cols; c++) {
const text = rows[r]?.[c];
if (!text) continue;
ctx.fillText(String(text), c * cellW + 4, r * cellH + cellH / 2, cellW - 8);
}
}
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
}
export function SpreadsheetPreviewModal({
libraryFileId,
filename,
fileType,
fileSize,
onClose,
onSnapshot,
}: SpreadsheetPreviewModalProps) {
const { t } = useTranslation();
const [sheets, setSheets] = useState<SheetGrid[] | null>(null);
const [activeSheet, setActiveSheet] = useState(0);
const [error, setError] = useState<string | null>(null);
const snapshotSentRef = useRef(false);
const onSnapshotRef = useRef(onSnapshot);
useEffect(() => {
onSnapshotRef.current = onSnapshot;
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
let cancelled = false;
setSheets(null);
setError(null);
setActiveSheet(0);
if (fileSize > SPREADSHEET_PREVIEW_MAX_BYTES) {
setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
return;
}
const headers: HeadersInit = {};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
(async () => {
const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = await res.arrayBuffer();
let parsed: SheetGrid[];
if (fileType === 'csv') {
// papaparse is loaded on demand so it stays out of the main bundle.
const Papa = (await import('papaparse')).default;
const text = new TextDecoder().decode(buffer);
const result = Papa.parse<string[]>(text, { skipEmptyLines: false });
const all = result.data.filter((row) => Array.isArray(row));
// A trailing newline parses as one empty row — drop trailing blanks.
while (all.length > 0 && all[all.length - 1].every((cell) => !cell)) {
all.pop();
}
const totalRows = all.length;
const totalCols = all.reduce((max, row) => Math.max(max, row.length), 0);
const rows = all.slice(0, MAX_ROWS).map((row) => row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
parsed = [{ name: filename, rows, totalRows, totalCols }];
} else {
// SheetJS handles both XLSX and ODS; loaded on demand like papaparse.
const XLSX = await import('xlsx');
const workbook = XLSX.read(buffer, { dense: true });
parsed = workbook.SheetNames.map((name) => {
const ws = workbook.Sheets[name];
const ref = ws?.['!ref'];
if (!ws || !ref) return { name, rows: [], totalRows: 0, totalCols: 0 };
const range = XLSX.utils.decode_range(ref);
const totalRows = range.e.r - range.s.r + 1;
const totalCols = range.e.c - range.s.c + 1;
// Cap the extracted range instead of slicing afterwards, so a huge
// sheet is never materialised in full.
const capped = {
s: range.s,
e: {
r: Math.min(range.e.r, range.s.r + MAX_ROWS - 1),
c: Math.min(range.e.c, range.s.c + MAX_COLS - 1),
},
};
const rows = XLSX.utils.sheet_to_json(ws, {
header: 1,
raw: false,
defval: '',
range: XLSX.utils.encode_range(capped),
}) as string[][];
return { name, rows, totalRows, totalCols };
});
}
if (cancelled) return;
setSheets(parsed);
if (onSnapshotRef.current && !snapshotSentRef.current) {
const first = parsed.find((sheet) => sheet.rows.length > 0);
if (first) {
snapshotSentRef.current = true;
const blob = await drawSheetSnapshot(first.rows);
if (blob && !cancelled) onSnapshotRef.current?.(blob);
}
}
})().catch(() => {
if (!cancelled) setError(t('fileManager.preview.error'));
});
return () => {
cancelled = true;
};
}, [libraryFileId, fileType, fileSize, filename, t]);
const sheet = sheets?.[activeSheet] ?? null;
const rowsTruncated = sheet != null && sheet.totalRows > sheet.rows.length;
const colsTruncated = sheet != null && sheet.totalCols > MAX_COLS;
const shownCols = sheet == null ? 0 : Math.min(sheet.totalCols, MAX_COLS);
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-6xl h-[85vh] border border-bambu-dark-tertiary flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2 min-w-0">
<FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />
<h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
aria-label={t('common.close')}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Sheet tabs */}
{sheets && sheets.length > 1 && (
<div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
{sheets.map((s, index) => (
<button
key={`${s.name}-${index}`}
onClick={() => setActiveSheet(index)}
className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
index === activeSheet
? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
}`}
>
{s.name}
</button>
))}
</div>
)}
{/* Content */}
<div className="flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg">
{error ? (
<div className="h-full flex items-center justify-center p-6">
<p className="text-bambu-gray text-center">{error}</p>
</div>
) : !sheets ? (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : !sheet || sheet.rows.length === 0 ? (
<div className="h-full flex items-center justify-center p-6">
<p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
</div>
) : (
<table className="border-collapse text-xs">
<thead>
<tr>
<th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
{Array.from({ length: shownCols }, (_, c) => (
<th
key={c}
className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
>
{columnLabel(c)}
</th>
))}
</tr>
</thead>
<tbody>
{sheet.rows.map((row, r) => (
<tr key={r}>
<td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
{r + 1}
</td>
{Array.from({ length: shownCols }, (_, c) => (
<td
key={c}
className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
title={row[c] || undefined}
>
{row[c] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Truncation notices */}
{(rowsTruncated || colsTruncated) && sheet && (
<div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
{rowsTruncated && (
<span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
)}
{colsTruncated && (
<span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
)}
</div>
)}
</div>
</div>
);
}