mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 21:21:54 +02:00
@smandon retested the original #1152 fix on the latest daily and surfaced two distinct holes: 1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's default ``Plate_1.gcode.3mf`` exports landed in the archive UI as ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected. 2. The pending-uploads review card always showed the raw FTP filename, while the eventual ``PrintArchive.print_name`` resolved from the 3MF's embedded title (or, with the toggle on ``filename``, the stripped stem). Net effect: same upload showed two different names depending on which view you were looking at, with no way for the toggle to flip both views in lockstep. Three changes: - ``resolve_display_stem`` helper in ``services/archive.py`` strips ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for every flow that produces a ``PrintArchive`` row. - ``PendingUpload.metadata_print_name`` (new nullable column) is populated at FTP-receive time by peeking at the 3MF's embedded title via the existing ``ThreeMFParser``. Read happens once per upload — the list endpoint then doesn't have to reopen each 3MF on every render. Parser failures are swallowed and the column stays NULL; the response model gracefully falls back to the stripped filename. - ``PendingUploadResponse.display_name`` is a computed field that mirrors ``archive_print``'s exact precedence — ``filename`` toggle → stripped stem; ``metadata`` toggle (default) → cached title or stripped stem. The frontend's review card reads it (with ``upload.filename`` as a defensive fallback) and surfaces the raw FTP filename via tooltip so users can still inspect what arrived. Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration rows have NULL and degrade to filename-stem behaviour without any operator action. Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the canonical normalisation rules (Bambu Studio default name, mixed case, dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path inputs); 6 integration tests in ``test_pending_upload_display_name.py`` pinning the response contract (default toggle uses metadata title when present, falls back to stripped stem when absent, ``filename`` toggle overrides metadata, ``filename`` toggle still strips the double suffix, ``GET /{id}`` exposes the same field, whitespace-only metadata behaves like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx`` pinning the review card's render path (resolved name shown, fallback to filename when display_name is empty, raw filename available via tooltip). Full backend suite: 3598 passed; frontend build clean; no regressions in any flow that previously processed ``.3mf`` / ``.gcode`` / non-3D filenames.
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Loader2, Archive, Trash2, FileBox, Clock, Upload, ChevronDown, ChevronUp } from 'lucide-react';
|
|
import { pendingUploadsApi } from '../api/client';
|
|
import type { PendingUpload, ProjectListItem } from '../api/client';
|
|
import { api } from '../api/client';
|
|
import { Card, CardContent, CardHeader } from './Card';
|
|
import { Button } from './Button';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
import { formatFileSize } from '../utils/file';
|
|
|
|
function formatTimeAgo(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
return `${diffDays}d ago`;
|
|
}
|
|
|
|
interface PendingUploadItemProps {
|
|
upload: PendingUpload;
|
|
projects: ProjectListItem[];
|
|
onArchive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) => void;
|
|
onDiscard: (id: number) => void;
|
|
isArchiving: boolean;
|
|
isDiscarding: boolean;
|
|
}
|
|
|
|
function PendingUploadItem({
|
|
upload,
|
|
projects,
|
|
onArchive,
|
|
onDiscard,
|
|
isArchiving,
|
|
isDiscarding,
|
|
}: PendingUploadItemProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [tags, setTags] = useState(upload.tags || '');
|
|
const [notes, setNotes] = useState(upload.notes || '');
|
|
const [projectId, setProjectId] = useState<number | null>(upload.project_id);
|
|
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<FileBox className="w-8 h-8 text-bambu-green flex-shrink-0" />
|
|
<div>
|
|
<p className="text-white font-medium" title={upload.filename}>{upload.display_name || upload.filename}</p>
|
|
<div className="flex items-center gap-2 text-xs text-bambu-gray">
|
|
<span>{formatFileSize(upload.file_size)}</span>
|
|
<span>·</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{formatTimeAgo(upload.uploaded_at)}
|
|
</span>
|
|
{upload.source_ip && (
|
|
<>
|
|
<span>·</span>
|
|
<span>from {upload.source_ip}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="p-1 text-bambu-gray hover:text-white transition-colors"
|
|
>
|
|
{expanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
|
</button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => onArchive(upload.id, { tags, notes, project_id: projectId || undefined })}
|
|
disabled={isArchiving}
|
|
>
|
|
{isArchiving ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Archive className="w-4 h-4" />
|
|
Archive
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowDiscardConfirm(true)}
|
|
disabled={isDiscarding}
|
|
>
|
|
{isDiscarding ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4 text-red-400" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Discard Confirmation Modal */}
|
|
{showDiscardConfirm && (
|
|
<ConfirmModal
|
|
title="Discard Upload"
|
|
message={`Are you sure you want to discard "${upload.filename}"? This cannot be undone.`}
|
|
confirmText="Discard"
|
|
variant="danger"
|
|
onConfirm={() => {
|
|
onDiscard(upload.id);
|
|
setShowDiscardConfirm(false);
|
|
}}
|
|
onCancel={() => setShowDiscardConfirm(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Expanded details for adding tags/notes/project */}
|
|
{expanded && (
|
|
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary space-y-3">
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">Tags</label>
|
|
<input
|
|
type="text"
|
|
value={tags}
|
|
onChange={(e) => setTags(e.target.value)}
|
|
placeholder="e.g., functional, prototype, gift"
|
|
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">Notes</label>
|
|
<textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="Add notes about this print..."
|
|
rows={2}
|
|
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray text-sm resize-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">Project</label>
|
|
<select
|
|
value={projectId || ''}
|
|
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
|
|
className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white text-sm"
|
|
>
|
|
<option value="">No project</option>
|
|
{projects.map((project) => (
|
|
<option key={project.id} value={project.id}>
|
|
{project.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function PendingUploadsPanel() {
|
|
const queryClient = useQueryClient();
|
|
const { showToast } = useToast();
|
|
const [showArchiveAllConfirm, setShowArchiveAllConfirm] = useState(false);
|
|
const [showDiscardAllConfirm, setShowDiscardAllConfirm] = useState(false);
|
|
const [archivingIds, setArchivingIds] = useState<Set<number>>(new Set());
|
|
const [discardingIds, setDiscardingIds] = useState<Set<number>>(new Set());
|
|
|
|
// Fetch pending uploads
|
|
const { data: uploads, isLoading: uploadsLoading } = useQuery({
|
|
queryKey: ['pending-uploads'],
|
|
queryFn: pendingUploadsApi.list,
|
|
refetchInterval: 10000, // Refresh every 10 seconds
|
|
});
|
|
|
|
// Fetch projects for dropdown
|
|
const { data: projects } = useQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: () => api.getProjects(),
|
|
select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
|
|
});
|
|
|
|
// Archive mutation
|
|
const archiveMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data?: { tags?: string; notes?: string; project_id?: number } }) =>
|
|
pendingUploadsApi.archive(id, data),
|
|
onMutate: ({ id }) => {
|
|
setArchivingIds((prev) => new Set(prev).add(id));
|
|
},
|
|
onSettled: (_, __, { id }) => {
|
|
setArchivingIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
|
|
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
|
showToast(`Archived: ${data.print_name}`);
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || 'Failed to archive', 'error');
|
|
},
|
|
});
|
|
|
|
// Discard mutation
|
|
const discardMutation = useMutation({
|
|
mutationFn: (id: number) => pendingUploadsApi.discard(id),
|
|
onMutate: (id) => {
|
|
setDiscardingIds((prev) => new Set(prev).add(id));
|
|
},
|
|
onSettled: (_, __, id) => {
|
|
setDiscardingIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
|
|
showToast('Upload discarded');
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || 'Failed to discard', 'error');
|
|
},
|
|
});
|
|
|
|
// Archive all mutation
|
|
const archiveAllMutation = useMutation({
|
|
mutationFn: pendingUploadsApi.archiveAll,
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
|
|
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
|
showToast(`Archived ${data.archived} files${data.failed > 0 ? `, ${data.failed} failed` : ''}`);
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || 'Failed to archive all', 'error');
|
|
},
|
|
});
|
|
|
|
// Discard all mutation
|
|
const discardAllMutation = useMutation({
|
|
mutationFn: pendingUploadsApi.discardAll,
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['pending-uploads'] });
|
|
showToast(`Discarded ${data.discarded} files`);
|
|
},
|
|
onError: (error: Error) => {
|
|
showToast(error.message || 'Failed to discard all', 'error');
|
|
},
|
|
});
|
|
|
|
if (uploadsLoading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-8 flex justify-center">
|
|
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (!uploads || uploads.length === 0) {
|
|
return null; // Don't render if no pending uploads
|
|
}
|
|
|
|
return (
|
|
<div className="mb-6">
|
|
<Card className="border-l-4 border-l-yellow-500">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Upload className="w-5 h-5 text-yellow-500" />
|
|
<h2 className="text-lg font-semibold text-white">
|
|
Pending Uploads ({uploads.length})
|
|
</h2>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => setShowArchiveAllConfirm(true)}
|
|
disabled={archiveAllMutation.isPending}
|
|
>
|
|
{archiveAllMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Archive className="w-4 h-4" />
|
|
Archive All
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowDiscardAllConfirm(true)}
|
|
disabled={discardAllMutation.isPending}
|
|
>
|
|
{discardAllMutation.isPending ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Trash2 className="w-4 h-4" />
|
|
Discard All
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-bambu-gray mb-4">
|
|
These files were uploaded via the virtual printer. Review and archive them to add to your collection.
|
|
</p>
|
|
<div className="space-y-3">
|
|
{uploads.map((upload) => (
|
|
<PendingUploadItem
|
|
key={upload.id}
|
|
upload={upload}
|
|
projects={projects || []}
|
|
onArchive={(id, data) => archiveMutation.mutate({ id, data })}
|
|
onDiscard={(id) => discardMutation.mutate(id)}
|
|
isArchiving={archivingIds.has(upload.id)}
|
|
isDiscarding={discardingIds.has(upload.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Archive All Confirmation */}
|
|
{showArchiveAllConfirm && (
|
|
<ConfirmModal
|
|
title="Archive All Uploads"
|
|
message={`Are you sure you want to archive all ${uploads.length} pending uploads?`}
|
|
confirmText="Archive All"
|
|
onConfirm={() => {
|
|
archiveAllMutation.mutate();
|
|
setShowArchiveAllConfirm(false);
|
|
}}
|
|
onCancel={() => setShowArchiveAllConfirm(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Discard All Confirmation */}
|
|
{showDiscardAllConfirm && (
|
|
<ConfirmModal
|
|
title="Discard All Uploads"
|
|
message={`Are you sure you want to discard all ${uploads.length} pending uploads? This cannot be undone.`}
|
|
confirmText="Discard All"
|
|
variant="danger"
|
|
onConfirm={() => {
|
|
discardAllMutation.mutate();
|
|
setShowDiscardAllConfirm(false);
|
|
}}
|
|
onCancel={() => setShowDiscardAllConfirm(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|