8a9f5ddc7d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
5.3 KiB
TypeScript
169 lines
5.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|