| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- 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">{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(),
- });
- // 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>
- );
- }
|