mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
File Manager: external link, notes and photos on library files (#3128)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user