Files
bambuddy/frontend/src/components/EditArchiveModal.tsx
T

492 lines
19 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
import { api } from '../api/client';
import type { Archive } from '../api/client';
import { Button } from './Button';
// Keys for failure reasons - translated at render time
const FAILURE_REASON_KEYS = [
'adhesionFailure',
'spaghettiDetached',
'layerShift',
'cloggedNozzle',
'filamentRunout',
'warping',
'stringing',
'underExtrusion',
'powerFailure',
'userCancelled',
'other',
] as const;
// Keys for archive statuses - translated at render time
const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;
interface EditArchiveModalProps {
archive: Archive;
onClose: () => void;
existingTags?: string[];
}
export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
const { t } = useTranslation();
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const queryClient = useQueryClient();
const [printName, setPrintName] = useState(archive.print_name || '');
const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
const [notes, setNotes] = useState(archive.notes || '');
const [tags, setTags] = useState(archive.tags || '');
const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
const [status, setStatus] = useState(archive.status);
const [quantity, setQuantity] = useState(archive.quantity ?? 1);
const [photos, setPhotos] = useState<string[]>(archive.photos || []);
const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
const tagInputRef = useRef<HTMLInputElement>(null);
const photoInputRef = useRef<HTMLInputElement>(null);
const blurTimeoutRef = useRef<number | null>(null);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: () => api.getProjects(),
});
// Fetch all tags using the dedicated API
const { data: tagsData } = useQuery({
queryKey: ['tags'],
queryFn: api.getTags,
enabled: existingTags.length === 0,
});
// Use existing tags prop if provided, otherwise use fetched tags
const allTags = existingTags.length > 0
? existingTags
: (tagsData?.map(t => t.name) || []);
// Get current tags as array
const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
// Get the text being typed after the last comma (for autocomplete filtering)
const currentInput = tags.includes(',')
? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
: tags.trim().toLowerCase();
// Filter suggestions: not already added AND matches current input (if any)
const tagSuggestions = allTags.filter(t =>
!currentTags.includes(t) &&
(currentInput === '' || t.toLowerCase().includes(currentInput))
);
// Add a tag (replaces any partial input with the selected tag)
const addTag = (tag: string) => {
// If there's partial input being typed, replace it with the selected tag
// Otherwise, just append the tag
let baseTags: string[];
if (currentInput && !allTags.includes(currentInput)) {
// User is typing a partial tag - replace it with the selected one
baseTags = tags.includes(',')
? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
: [];
} else {
// No partial input or input is already a complete tag - append
baseTags = currentTags;
}
if (!baseTags.includes(tag)) {
const newTags = [...baseTags, tag].join(', ');
setTags(newTags);
}
// Clear any pending blur timeout to prevent hiding suggestions
if (blurTimeoutRef.current !== null) {
clearTimeout(blurTimeoutRef.current);
}
tagInputRef.current?.focus();
};
// Remove a tag
const removeTag = (tagToRemove: string) => {
const newTags = currentTags.filter(t => t !== tagToRemove).join(', ');
setTags(newTags);
};
const updateMutation = useMutation({
mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
api.updateArchive(archive.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
onClose();
},
});
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingPhoto(true);
try {
const result = await api.uploadArchivePhoto(archive.id, file);
setPhotos(result.photos);
queryClient.invalidateQueries({ queryKey: ['archives'] });
} catch (error) {
console.error('Failed to upload photo:', error);
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) {
photoInputRef.current.value = '';
}
}
};
const handlePhotoDelete = async (filename: string) => {
try {
const result = await api.deleteArchivePhoto(archive.id, filename);
setPhotos(result.photos || []);
queryClient.invalidateQueries({ queryKey: ['archives'] });
} catch (error) {
console.error('Failed to delete photo:', error);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Build update data
const updateData: Parameters<typeof api.updateArchive>[1] = {
print_name: printName || undefined,
printer_id: printerId,
project_id: projectId,
notes: notes || undefined,
tags: tags || undefined,
quantity: quantity,
external_url: externalUrl || null,
};
// Only include status if changed
if (status !== archive.status) {
updateData.status = status;
}
// Handle failure_reason based on status
if (status === 'failed' || status === 'aborted') {
updateData.failure_reason = failureReason || undefined;
} else if (archive.status === 'failed' || archive.status === 'aborted') {
// Clear failure_reason when changing from failed/aborted to another status
updateData.failure_reason = null;
}
updateMutation.mutate(updateData);
};
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-md max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">{t('editArchive.title')}</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
{/* Print Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
<input
type="text"
value={printName}
onChange={(e) => setPrintName(e.target.value)}
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"
placeholder={t('editArchive.namePlaceholder')}
/>
</div>
{/* Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.printer')}</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
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"
>
<option value="">{t('editArchive.noPrinter')}</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* Project */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<FolderKanban className="w-4 h-4 inline mr-1" />
{t('editArchive.project')}
</label>
<select
value={projectId ?? ''}
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
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"
>
<option value="">{t('editArchive.noProject')}</option>
{projects?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* Quantity - number of items printed */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Hash className="w-4 h-4 inline mr-1" />
{t('editArchive.itemsPrinted')}
</label>
<input
type="number"
min={1}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
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"
placeholder="1"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('editArchive.itemsPrintedHelp')}
</p>
</div>
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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"
placeholder={t('editArchive.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('editArchive.externalLink')}
</label>
<input
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
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"
placeholder="https://printables.com/model/..."
/>
<p className="text-xs text-bambu-gray mt-1">
{t('editArchive.externalLinkHelp')}
</p>
</div>
{/* Tags */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.tags')}</label>
{/* Current tags as chips */}
{currentTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white"
>
<Tag className="w-3 h-3" />
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-bambu-gray hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Tag input with suggestions */}
<div className="relative">
<input
ref={tagInputRef}
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
onFocus={() => {
if (blurTimeoutRef.current !== null) {
clearTimeout(blurTimeoutRef.current);
}
setShowTagSuggestions(true);
}}
onBlur={() => {
blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
}}
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"
placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}
/>
{/* Suggestions dropdown */}
{showTagSuggestions && tagSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
{currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}
</div>
<div className="p-2 flex flex-wrap gap-1.5">
{tagSuggestions.map((tag) => (
<button
key={tag}
type="button"
onClick={() => addTag(tag)}
className="px-2 py-0.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded text-sm text-bambu-gray hover:text-white transition-colors"
>
{tag}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Status */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.status')}</label>
<select
value={status}
onChange={(e) => {
setStatus(e.target.value);
// Clear failure reason when changing to completed
if (e.target.value === 'completed') {
setFailureReason('');
}
}}
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"
>
{ARCHIVE_STATUS_KEYS.map((statusKey) => (
<option key={statusKey} value={statusKey}>
{t(`editArchive.statuses.${statusKey}`)}
</option>
))}
</select>
</div>
{/* Failure Reason - only show for failed/aborted prints */}
{(status === 'failed' || status === 'aborted') && (
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
<select
value={failureReason}
onChange={(e) => setFailureReason(e.target.value)}
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"
>
<option value="">{t('editArchive.selectReason')}</option>
{FAILURE_REASON_KEYS.map((reasonKey) => (
<option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>
{t(`editArchive.failureReasons.${reasonKey}`)}
</option>
))}
</select>
</div>
)}
{/* Photos */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Camera className="w-4 h-4 inline mr-1" />
{t('editArchive.photos')}
</label>
{/* Photo grid */}
<div className="flex flex-wrap gap-2 mb-2">
{photos.map((filename) => (
<div key={filename} className="relative group">
<img
src={api.getArchivePhotoUrl(archive.id, filename)}
alt={t('editArchive.printResult')}
className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
/>
<button
type="button"
onClick={() => handlePhotoDelete(filename)}
className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-3 h-3 text-white" />
</button>
</div>
))}
{/* Upload button */}
<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">
<input
ref={photoInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handlePhotoUpload}
className="hidden"
disabled={uploadingPhoto}
/>
{uploadingPhoto ? (
<Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
) : (
<Plus className="w-6 h-6 text-bambu-gray" />
)}
</label>
</div>
<p className="text-xs text-bambu-gray">{t('editArchive.photosHelp')}</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
{t('common.cancel')}
</Button>
<Button
type="submit"
disabled={updateMutation.isPending}
className="flex-1"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? t('common.saving') : t('common.save')}
</Button>
</div>
</form>
</div>
</div>
);
}