feat(digital-signatures): drag-and-drop image upload (base64) + tags chip input (task 1.06)

This commit is contained in:
AI Assistant
2026-02-19 01:27:41 +02:00
parent 41036db659
commit 0fe53a566b

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef } from 'react';
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle } from 'lucide-react'; import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle, Upload, X } from 'lucide-react';
import { Button } from '@/shared/components/ui/button'; import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input'; import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label'; import { Label } from '@/shared/components/ui/label';
@@ -206,6 +206,46 @@ export function DigitalSignaturesModule() {
); );
} }
function ImageUploadField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = (file: File) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (e) => onChange(e.target?.result as string);
reader.readAsDataURL(file);
};
return (
<div className="space-y-2">
<div
className="flex min-h-[100px] cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-3 text-sm text-muted-foreground transition-colors hover:border-primary/50"
onClick={() => fileRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) handleFile(f); }}
>
{value ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={value} alt="preview" className="max-h-24 max-w-full object-contain" />
) : (
<>
<Upload className="h-6 w-6" />
<span>Trage imaginea aici sau apasă pentru a selecta</span>
</>
)}
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
{value && (
<Button type="button" variant="ghost" size="sm" className="text-xs text-muted-foreground"
onClick={() => onChange('')}>
<X className="mr-1 h-3 w-3" /> Elimină imaginea
</Button>
)}
</div>
);
}
function AddVersionForm({ onSubmit, onCancel, history }: { function AddVersionForm({ onSubmit, onCancel, history }: {
onSubmit: (imageUrl: string, notes: string) => void; onSubmit: (imageUrl: string, notes: string) => void;
onCancel: () => void; onCancel: () => void;
@@ -228,8 +268,8 @@ function AddVersionForm({ onSubmit, onCancel, history }: {
</div> </div>
)} )}
<div> <div>
<Label>URL imagine nouă</Label> <Label>Imagine nouă</Label>
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." required /> <div className="mt-1"><ImageUploadField value={imageUrl} onChange={setImageUrl} /></div>
</div> </div>
<div> <div>
<Label>Note versiune</Label> <Label>Note versiune</Label>
@@ -256,6 +296,19 @@ function AssetForm({ initial, onSubmit, onCancel }: {
const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? ''); const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? '');
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? ''); const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? '');
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? ''); const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? '');
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [tagInput, setTagInput] = useState('');
const addTag = (raw: string) => {
const t = raw.trim().toLowerCase();
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
setTagInput('');
};
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag(tagInput); }
if (e.key === 'Backspace' && tagInput === '' && tags.length > 0) setTags((prev) => prev.slice(0, -1));
};
return ( return (
<form onSubmit={(e) => { <form onSubmit={(e) => {
@@ -265,7 +318,7 @@ function AssetForm({ initial, onSubmit, onCancel }: {
expirationDate: expirationDate || undefined, expirationDate: expirationDate || undefined,
legalStatus, usageNotes, legalStatus, usageNotes,
versions: initial?.versions ?? [], versions: initial?.versions ?? [],
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all', tags, visibility: initial?.visibility ?? 'all',
}); });
}} className="space-y-4"> }} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@@ -296,15 +349,35 @@ function AssetForm({ initial, onSubmit, onCancel }: {
</div> </div>
</div> </div>
<div> <div>
<Label>URL imagine</Label> <Label>Imagine</Label>
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." /> <div className="mt-1"><ImageUploadField value={imageUrl} onChange={setImageUrl} /></div>
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Data expirare</Label><Input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} className="mt-1" /></div> <div><Label>Data expirare</Label><Input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} className="mt-1" /></div>
<div><Label>Status legal</Label><Input value={legalStatus} onChange={(e) => setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." /></div> <div><Label>Status legal</Label><Input value={legalStatus} onChange={(e) => setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." /></div>
<div><Label>Note utilizare</Label><Input value={usageNotes} onChange={(e) => setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." /></div> <div><Label>Note utilizare</Label><Input value={usageNotes} onChange={(e) => setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." /></div>
</div> </div>
<div>
<Label>Etichete</Label>
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-md border bg-background px-2 py-1.5 focus-within:ring-1 focus-within:ring-ring">
{tags.map((tag) => (
<span key={tag} className="flex items-center gap-0.5 rounded-full border bg-muted px-2 py-0.5 text-xs">
{tag}
<button type="button" onClick={() => setTags((t) => t.filter((x) => x !== tag))} className="ml-0.5 opacity-60 hover:opacity-100">
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={() => { if (tagInput.trim()) addTag(tagInput); }}
placeholder={tags.length === 0 ? 'Adaugă etichete (Enter sau virgulă)...' : ''}
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> <Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>