feat(digital-signatures): drag-and-drop image upload (base64) + tags chip input (task 1.06)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user