feat(ui): ek ekleme alanı, carousel görüntüleyici, ekstre sayfası ve mutabakat penceresi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Paperclip, X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import type { ActionResult } from "@/lib/action-result";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
acceptAttribute,
|
||||
MAX_ATTACHMENTS_PER_RECORD,
|
||||
type AttachmentKind,
|
||||
} from "@/shared/attachments";
|
||||
import type { AttachmentInfo } from "../service";
|
||||
import { AttachmentThumb } from "./attachment-strip";
|
||||
import { AttachmentViewer } from "./attachment-viewer";
|
||||
|
||||
const t = tr.attachments;
|
||||
|
||||
/** Tek dosyayı /api/files'a yükler. */
|
||||
async function uploadFile(file: File): Promise<ActionResult<AttachmentInfo>> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
try {
|
||||
const res = await fetch("/api/files", { method: "POST", body });
|
||||
return (await res.json()) as ActionResult<AttachmentInfo>;
|
||||
} catch {
|
||||
return { ok: false, error: t.errors.upload };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formdaki küçük "Ek ekle" alanı (Bölüm 6.3): seçilen dosyalar hemen yüklenir (bekleyen ek),
|
||||
* küçük önizleme ve sayaç gösterilir; kayıt kaydedilince ekler kayda bağlanır.
|
||||
*/
|
||||
export function AttachmentPicker({
|
||||
value,
|
||||
onChange,
|
||||
allowedKinds,
|
||||
onBusyChange,
|
||||
}: {
|
||||
value: AttachmentInfo[];
|
||||
onChange: (items: AttachmentInfo[]) => void;
|
||||
allowedKinds: readonly AttachmentKind[];
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(0);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [viewer, setViewer] = useState<number | null>(null);
|
||||
const invalid = value.filter((a) => !allowedKinds.includes(a.kind));
|
||||
const room = MAX_ATTACHMENTS_PER_RECORD - value.length;
|
||||
|
||||
async function onFiles(files: FileList | null) {
|
||||
if (!files?.length) return;
|
||||
const list = [...files];
|
||||
const accepted = list.slice(0, Math.max(0, room));
|
||||
const nextErrors: string[] = [];
|
||||
if (list.length > accepted.length)
|
||||
nextErrors.push(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD));
|
||||
setUploading(accepted.length);
|
||||
onBusyChange?.(true);
|
||||
let items = value;
|
||||
for (const file of accepted) {
|
||||
const result = await uploadFile(file);
|
||||
if (result.ok) {
|
||||
items = [...items, result.data];
|
||||
onChange(items);
|
||||
} else {
|
||||
nextErrors.push(`${file.name}: ${result.error}`);
|
||||
}
|
||||
setUploading((n) => n - 1);
|
||||
}
|
||||
setErrors(nextErrors);
|
||||
onBusyChange?.(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
onChange(value.filter((a) => a.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={room <= 0 || uploading > 0}
|
||||
>
|
||||
{uploading > 0 ? <Loader2 className="animate-spin" /> : <Paperclip />}
|
||||
{uploading > 0 ? t.uploading : t.add}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{value.length
|
||||
? t.count(value.length)
|
||||
: allowedKinds.includes("pdf")
|
||||
? t.addHint.imagePdf
|
||||
: t.addHint.image}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={acceptAttribute(allowedKinds)}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-label={t.add}
|
||||
onChange={(e) => onFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{value.length || uploading ? (
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{value.map((item, i) => (
|
||||
<li key={item.id} className="relative">
|
||||
<AttachmentThumb
|
||||
item={item}
|
||||
className={cn(
|
||||
"size-10",
|
||||
!allowedKinds.includes(item.kind) && "border-destructive ring-1 ring-destructive",
|
||||
)}
|
||||
onClick={() => setViewer(i)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(item.id)}
|
||||
className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm hover:text-foreground"
|
||||
aria-label={`${t.remove}: ${item.name}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{Array.from({ length: uploading }, (_, i) => (
|
||||
<li
|
||||
key={`up-${i}`}
|
||||
className="flex size-10 items-center justify-center rounded-md border border-dashed"
|
||||
aria-label={t.uploading}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{invalid.length ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{t.errors.kindNotAllowed}
|
||||
</p>
|
||||
) : null}
|
||||
{errors.map((e) => (
|
||||
<p key={e} className="text-xs text-destructive" role="alert">
|
||||
{e}
|
||||
</p>
|
||||
))}
|
||||
|
||||
<AttachmentViewer
|
||||
items={value}
|
||||
startIndex={viewer ?? 0}
|
||||
open={viewer !== null}
|
||||
onOpenChange={(open) => !open && setViewer(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user