feat: MediaPicker — sürükle-bırak + progress bar + kütüphane modal

Mevcut sorun:
- Her görsel için medya sayfasına git, yükle, URL kopyala, forma yapıştır → 4 adım
- Sürükle-bırak yok, progress yok, hangi dosyanın yüklendiği belirsiz

Çözüm: MediaPicker component (tek/çoklu mode)

API route'ları:
- POST /api/admin/media/upload — session auth + Appwrite Storage upload
- GET /api/admin/media/list — kütüphane modal için dosya listesi

Component özellikleri:
- Sürükle-bırak drop zone (hover state ile)
- Multiple file upload (çoklu mode)
- XHR ile gerçek progress bar (%) — Server Action ile alınamazdı
- Görsel preview (single: aspect-video, multiple: aspect-square grid)
- Hover'da × ile kaldırma
- Multiple mode'da sırasını değiştirme
- 'Kütüphaneden seç' modal — daha önce yüklenmiş görselleri grid'de göster, tıklayınca seç
- Error handling (dosya boyutu, ağ hatası vb.)
- Başarılı yüklemeyi 2 saniye gösterip kaybetme

Form alanları → MediaPicker (URL field'ları kaldırıldı):
- Blog: cover_image, seo_image
- Hizmet: hero_image
- Proje: image_url (kapak), gallery (çoklu)
- Referans: image_url
- Sektör: hero_image
- Ekip: photo_url
- SEO sayfa: og_image
- SEO global: default_og_image
- Site Settings: client_logos (çoklu)

Backward compat: form data formatı aynı kalıyor — hidden input ile URL satır satır.
admin-actions değişmedi. URL elle yapıştırmak hala mümkün (kütüphaneden URL kopyala).
This commit is contained in:
Ege Can Komur
2026-05-20 04:11:41 +03:00
parent cf46e30a7e
commit dbc55e7527
12 changed files with 627 additions and 52 deletions
+9 -11
View File
@@ -8,6 +8,7 @@ import {
Select,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { saveBlogPost } from "@/lib/admin-actions";
import type { BlogPostRow } from "@/lib/types";
import { Save } from "lucide-react";
@@ -81,19 +82,16 @@ export function BlogForm({ post }: { post?: BlogPostRow }) {
rows={14}
placeholder={"# Başlık\n\nMarkdown desteklenir…"}
/>
<Field
label="Kapak görseli URL"
<MediaPicker
label="Kapak görseli"
name="cover_image"
type="url"
defaultValue={post?.cover_image}
placeholder="https://…"
help="Medya kütüphanesinden bir görselin view URL'ini yapıştırın."
help="Yazı listesinde ve detay sayfasının üst kısmında gösterilir."
/>
<Field
label="Kapak file_id"
<input
type="hidden"
name="cover_file_id"
defaultValue={post?.cover_file_id}
help="(opsiyonel) Appwrite storage file ID'si"
defaultValue={post?.cover_file_id ?? ""}
/>
</div>
@@ -107,11 +105,11 @@ export function BlogForm({ post }: { post?: BlogPostRow }) {
defaultValue={post?.seo_title}
help="Boş bırakırsanız yazı başlığı kullanılır."
/>
<Field
<MediaPicker
label="SEO OG görseli"
name="seo_image"
type="url"
defaultValue={post?.seo_image}
help="Sosyal medyada paylaşılınca görünecek görsel (1200×630 ideal)."
/>
</div>
<div className="mt-5">
+11 -7
View File
@@ -8,6 +8,7 @@ import {
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { saveTeamMember } from "@/lib/admin-actions";
import type { TeamMemberRow } from "@/lib/types";
@@ -29,13 +30,6 @@ export function TeamMemberForm({ row }: { row?: TeamMemberRow }) {
defaultValue={row?.role}
placeholder="Kurucu / Geliştirici / Tasarımcı"
/>
<Field
label="Fotoğraf URL"
name="photo_url"
type="url"
defaultValue={row?.photo_url}
help="Medya kütüphanesinden URL kopyalayın."
/>
<Field
label="LinkedIn URL"
name="linkedin_url"
@@ -49,6 +43,16 @@ export function TeamMemberForm({ row }: { row?: TeamMemberRow }) {
defaultValue={row?.order ?? 0}
/>
</div>
<div className="mt-5">
<MediaPicker
label="Fotoğraf"
name="photo_url"
defaultValue={row?.photo_url}
maxSizeMB={5}
help="Kare format ideal. Hakkımızda sayfasında gösterilir."
/>
</div>
<div className="mt-5">
<Textarea
label="Biyografi"
+4 -4
View File
@@ -9,6 +9,7 @@ import {
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { saveService } from "@/lib/admin-actions";
import type { FaqItem, ServiceRow } from "@/lib/types";
@@ -71,12 +72,11 @@ export function ServiceForm({ service }: { service?: ServiceRow }) {
))}
</select>
</label>
<Field
label="Hero görsel URL"
<MediaPicker
label="Hero görsel"
name="hero_image"
type="url"
defaultValue={service?.hero_image}
help="Detay sayfası başında gösterilir (opsiyonel)."
help="Detay sayfasının üst kısmında gösterilir (opsiyonel)."
/>
</div>
+12 -13
View File
@@ -9,6 +9,7 @@ import {
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { saveProject } from "@/lib/admin-actions";
import { listServices } from "@/lib/data";
import type { ProjectRow } from "@/lib/types";
@@ -63,12 +64,6 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
type="number"
defaultValue={project?.year ?? new Date().getFullYear()}
/>
<Field
label="Kapak görseli URL"
name="image_url"
type="url"
defaultValue={project?.image_url}
/>
<Field
label="Canlı URL"
name="live_url"
@@ -85,6 +80,13 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
</div>
<div className="mt-5 space-y-5">
<MediaPicker
label="Kapak görseli"
name="image_url"
defaultValue={project?.image_url}
help="Liste ve detay sayfasının üstündeki ana görsel."
/>
<Textarea
label="Kısa açıklama (kart için)"
name="description"
@@ -104,15 +106,12 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
help="Proje detay sayfasında uzun anlatım olarak gösterilir."
/>
<Textarea
<MediaPicker
label="Galeri görselleri"
name="gallery"
defaultValue={project?.gallery?.join("\n")}
rows={5}
placeholder={
"https://example.com/image1.jpg\nhttps://example.com/image2.jpg"
}
help="Her satıra bir URL. Medya kütüphanesinden URL'leri kopyalayın."
multiple
defaultValue={project?.gallery ?? []}
help="Detay sayfasında lightbox galeri olarak gösterilir. Birden fazla görsel yükleyebilirsiniz."
/>
<Textarea
+8 -1
View File
@@ -9,6 +9,7 @@ import {
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { saveTestimonial } from "@/lib/admin-actions";
import type { TestimonialRow } from "@/lib/types";
@@ -24,7 +25,13 @@ export function TestimonialForm({ row }: { row?: TestimonialRow }) {
<FormShell>
<div className="grid gap-5 md:grid-cols-2">
<Field label="İsim" name="name" required defaultValue={row?.name} />
<Field label="Görsel URL" name="image_url" type="url" defaultValue={row?.image_url} />
<MediaPicker
label="Profil görseli"
name="image_url"
defaultValue={row?.image_url}
maxSizeMB={5}
help="Müşteri profil fotoğrafı (kare format ideal)."
/>
<Field label="Pozisyon" name="role" defaultValue={row?.role} />
<Field label="Firma" name="company" defaultValue={row?.company} />
<Field
+4 -3
View File
@@ -9,6 +9,7 @@ import {
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { saveIndustry } from "@/lib/admin-actions";
import type { FaqItem, IndustryRow } from "@/lib/types";
@@ -51,11 +52,11 @@ export function IndustryForm({ row }: { row?: IndustryRow }) {
defaultValue={row?.slug}
placeholder="avukat-web-tasarimi"
/>
<Field
label="Hero görsel URL"
<MediaPicker
label="Hero görsel"
name="hero_image"
type="url"
defaultValue={row?.hero_image}
help="Sektör sayfasının üst kısmında gösterilir."
/>
<Field
label="Sıra"
+3 -2
View File
@@ -9,6 +9,7 @@ import {
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { saveSeoPage } from "@/lib/admin-actions";
import type { SeoPageRow } from "@/lib/types";
@@ -39,11 +40,11 @@ export function SeoPageForm({ row }: { row?: SeoPageRow }) {
rows={3}
defaultValue={row?.description}
/>
<Field
<MediaPicker
label="OG görseli"
name="og_image"
type="url"
defaultValue={row?.og_image}
help="Sosyal medya paylaşım görseli (1200×630 ideal)."
/>
<Field
label="Canonical URL"
+5 -2
View File
@@ -8,6 +8,7 @@ import {
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { DeleteButton } from "@/components/admin/delete-button";
import { getSeoSettings, listSeoPages } from "@/lib/data";
import { deleteSeoPage, saveSeoSettings } from "@/lib/admin-actions";
@@ -42,12 +43,14 @@ export default async function SeoAdminPage() {
defaultValue={settings?.site_name}
placeholder="Kovak Yazılım"
/>
<Field
<div>
<MediaPicker
label="Varsayılan OG görseli"
name="default_og_image"
type="url"
defaultValue={settings?.default_og_image}
help="Sosyal paylaşımlarda (1200×630 ideal)."
/>
</div>
<Field
label="Twitter handle"
name="twitter_handle"
+6 -5
View File
@@ -10,6 +10,7 @@ import {
} from "@/components/admin/form";
import { getSiteSettings } from "@/lib/data";
import { saveSiteSettings } from "@/lib/admin-actions";
import { MediaPicker } from "@/components/admin/media-picker";
import type {
FaqItem,
ProcessStep,
@@ -407,13 +408,13 @@ export default async function SiteSettingsPage() {
help='Her satır: "İkonAdı | Değer | Etiket" (örn. Star | 4.9 | Google yıldızı)'
/>
<Textarea
<MediaPicker
label="Müşteri logoları"
name="client_logos"
rows={4}
defaultValue={s?.client_logos?.join("\n")}
placeholder={"https://example.com/logo1.png\nhttps://example.com/logo2.png"}
help="Her satıra bir URL. Logoların grayscale + opaque versiyonu gösterilir."
multiple
defaultValue={s?.client_logos ?? []}
maxSizeMB={3}
help="Anasayfada grayscale logo şeridi olarak gösterilir. Tercihen şeffaf PNG."
/>
<div className="grid gap-5 md:grid-cols-3">
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import {
MEDIA_BUCKET_ID,
Q,
storage,
} from "@/lib/appwrite-rest";
import { requireSessionSecret } from "@/lib/auth";
export const runtime = "nodejs";
export async function GET() {
const secret = await requireSessionSecret();
try {
const res = await storage.listFiles(
MEDIA_BUCKET_ID,
[Q.orderDesc("$createdAt"), Q.limit(200)],
secret,
);
return NextResponse.json({
files: res.files.map((f) => ({
id: f.$id,
name: f.name,
size: f.sizeOriginal,
mimeType: f.mimeType,
url: storage.fileViewUrl(MEDIA_BUCKET_ID, f.$id),
createdAt: f.$createdAt,
})),
});
} catch (err) {
const msg = err instanceof Error ? err.message : "Liste alınamadı";
return NextResponse.json({ error: msg, files: [] }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import {
ID,
MEDIA_BUCKET_ID,
storage,
} from "@/lib/appwrite-rest";
import { requireSessionSecret } from "@/lib/auth";
export const runtime = "nodejs";
export async function POST(req: Request) {
const secret = await requireSessionSecret();
const formData = await req.formData();
const file = formData.get("file");
if (!(file instanceof File) || file.size === 0) {
return NextResponse.json({ error: "Dosya seçilmedi" }, { status: 400 });
}
try {
const created = await storage.createFile(
MEDIA_BUCKET_ID,
ID.unique(),
file,
secret,
);
return NextResponse.json({
id: created.$id,
name: created.name,
size: created.sizeOriginal,
mimeType: created.mimeType,
url: storage.fileViewUrl(MEDIA_BUCKET_ID, created.$id),
});
} catch (err) {
const msg = err instanceof Error ? err.message : "Yükleme başarısız";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+492
View File
@@ -0,0 +1,492 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import {
Upload,
Image as ImageIcon,
X,
Loader2,
Check,
AlertCircle,
Library,
GripVertical,
} from "lucide-react";
interface MediaFile {
id: string;
name: string;
size: number;
url: string;
mimeType?: string;
createdAt?: string;
}
type Mode = "single" | "multiple";
interface BaseProps {
name: string;
label: string;
help?: string;
accept?: string;
maxSizeMB?: number;
}
interface SingleProps extends BaseProps {
multiple?: false;
defaultValue?: string | null;
}
interface MultiProps extends BaseProps {
multiple: true;
defaultValue?: string[] | null;
}
export function MediaPicker(props: SingleProps | MultiProps) {
const mode: Mode = props.multiple ? "multiple" : "single";
const id = useId();
const initial: string[] =
mode === "multiple"
? (props as MultiProps).defaultValue ?? []
: (props as SingleProps).defaultValue
? [(props as SingleProps).defaultValue as string]
: [];
const [values, setValues] = useState<string[]>(initial);
const [showLibrary, setShowLibrary] = useState(false);
const [activeUploads, setActiveUploads] = useState<
Array<{ id: string; name: string; progress: number; error?: string }>
>([]);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// For multiple mode the hidden value is one URL per line (matches existing
// textarea-based admin actions). For single mode it's just the URL.
const hiddenValue =
mode === "multiple"
? values.join("\n")
: values[0] ?? "";
function addUrl(url: string) {
if (mode === "single") setValues([url]);
else setValues((v) => (v.includes(url) ? v : [...v, url]));
}
function removeAt(i: number) {
setValues((v) => v.filter((_, idx) => idx !== i));
}
function moveItem(from: number, to: number) {
setValues((v) => {
const copy = [...v];
const [moved] = copy.splice(from, 1);
copy.splice(to, 0, moved);
return copy;
});
}
async function uploadFile(file: File) {
const maxBytes = (props.maxSizeMB ?? 10) * 1024 * 1024;
if (file.size > maxBytes) {
setActiveUploads((u) => [
...u,
{
id: Math.random().toString(36),
name: file.name,
progress: 0,
error: `Dosya çok büyük (max ${props.maxSizeMB ?? 10} MB)`,
},
]);
return;
}
const uploadId = Math.random().toString(36);
setActiveUploads((u) => [
...u,
{ id: uploadId, name: file.name, progress: 0 },
]);
const fd = new FormData();
fd.append("file", file);
try {
const url = await uploadWithProgress(fd, (pct) => {
setActiveUploads((u) =>
u.map((up) => (up.id === uploadId ? { ...up, progress: pct } : up)),
);
});
addUrl(url);
setActiveUploads((u) =>
u.map((up) =>
up.id === uploadId ? { ...up, progress: 100 } : up,
),
);
// 2 saniye sonra başarılı upload'ı listeden kaldır
setTimeout(() => {
setActiveUploads((u) => u.filter((up) => up.id !== uploadId));
}, 2000);
} catch (err) {
const msg = err instanceof Error ? err.message : "Yükleme başarısız";
setActiveUploads((u) =>
u.map((up) =>
up.id === uploadId ? { ...up, progress: 0, error: msg } : up,
),
);
}
}
function handleFiles(files: FileList | null) {
if (!files) return;
const arr = Array.from(files);
if (mode === "single") {
uploadFile(arr[0]);
} else {
arr.forEach(uploadFile);
}
}
return (
<div>
<div className="flex items-center justify-between">
<label htmlFor={id} className="text-sm font-medium text-[var(--navy)]">
{props.label}
</label>
<button
type="button"
onClick={() => setShowLibrary(true)}
className="inline-flex items-center gap-1 text-xs font-medium text-[var(--sky-600)] hover:text-[var(--navy)]"
>
<Library className="size-3.5" />
Kütüphaneden seç
</button>
</div>
<input type="hidden" name={props.name} value={hiddenValue} />
{/* Drop zone */}
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
handleFiles(e.dataTransfer.files);
}}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
className={`mt-1.5 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed bg-white p-6 text-center transition ${
dragOver
? "border-[var(--sky)] bg-[var(--sky-50)]"
: "border-[var(--border)] hover:border-[var(--sky)]"
}`}
>
<input
id={id}
ref={inputRef}
type="file"
accept={props.accept ?? "image/*"}
multiple={mode === "multiple"}
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = "";
}}
className="hidden"
/>
<Upload className="size-7 text-[var(--sky-600)]" />
<div>
<p className="text-sm font-medium text-[var(--navy)]">
{mode === "multiple"
? "Sürükle-bırak veya dosyaları seç"
: "Sürükle-bırak veya dosya seç"}
</p>
<p className="mt-0.5 text-xs text-[var(--muted)]">
PNG, JPG, WEBP, SVG max {props.maxSizeMB ?? 10} MB
</p>
</div>
</div>
{/* Active uploads */}
{activeUploads.length > 0 && (
<div className="mt-3 space-y-2">
{activeUploads.map((u) => (
<div
key={u.id}
className="rounded-lg border border-[var(--border)] bg-white p-3"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{u.error ? (
<AlertCircle className="size-4 shrink-0 text-red-500" />
) : u.progress === 100 ? (
<Check className="size-4 shrink-0 text-green-600" />
) : (
<Loader2 className="size-4 shrink-0 animate-spin text-[var(--sky-600)]" />
)}
<span className="truncate text-xs font-medium text-[var(--navy)]">
{u.name}
</span>
</div>
<span
className={`shrink-0 text-xs ${
u.error
? "text-red-600"
: u.progress === 100
? "text-green-600"
: "text-[var(--muted)]"
}`}
>
{u.error ? "Hata" : `${u.progress}%`}
</span>
</div>
{u.error ? (
<p className="mt-1 text-xs text-red-600">{u.error}</p>
) : (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--navy-50)]">
<div
className={`h-full rounded-full transition-all ${
u.progress === 100
? "bg-green-500"
: "bg-[var(--sky)]"
}`}
style={{ width: `${u.progress}%` }}
/>
</div>
)}
</div>
))}
</div>
)}
{/* Selected previews */}
{values.length > 0 && (
<div
className={`mt-3 grid gap-3 ${
mode === "multiple" ? "grid-cols-3 sm:grid-cols-4" : "grid-cols-1"
}`}
>
{values.map((url, i) => (
<div
key={url + i}
className={`group relative overflow-hidden rounded-xl border border-[var(--border)] bg-white ${
mode === "multiple" ? "aspect-square" : "aspect-video max-w-md"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt=""
className="size-full object-cover"
loading="lazy"
/>
<button
type="button"
aria-label="Kaldır"
onClick={(e) => {
e.stopPropagation();
removeAt(i);
}}
className="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition group-hover:opacity-100"
>
<X className="size-3.5" />
</button>
{mode === "multiple" && values.length > 1 && (
<div className="absolute left-1.5 top-1.5 flex gap-1 opacity-0 transition group-hover:opacity-100">
{i > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
moveItem(i, i - 1);
}}
className="flex size-6 items-center justify-center rounded-full bg-black/60 text-white"
aria-label="Sola taşı"
>
<GripVertical className="size-3.5 rotate-90" />
</button>
)}
</div>
)}
</div>
))}
</div>
)}
{props.help && (
<p className="mt-2 text-xs text-[var(--muted)]">{props.help}</p>
)}
{showLibrary && (
<MediaLibraryModal
mode={mode}
selected={values}
onClose={() => setShowLibrary(false)}
onPick={(url) => {
addUrl(url);
if (mode === "single") setShowLibrary(false);
}}
/>
)}
</div>
);
}
function uploadWithProgress(
fd: FormData,
onProgress: (pct: number) => void,
): Promise<string> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/admin/media/upload");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.onload = () => {
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && data.url) {
resolve(data.url);
} else {
reject(new Error(data.error || `HTTP ${xhr.status}`));
}
} catch {
reject(new Error("Geçersiz sunucu yanıtı"));
}
};
xhr.onerror = () => reject(new Error("Ağ hatası"));
xhr.send(fd);
});
}
function MediaLibraryModal({
mode,
selected,
onClose,
onPick,
}: {
mode: Mode;
selected: string[];
onClose: () => void;
onPick: (url: string) => void;
}) {
const [files, setFiles] = useState<MediaFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/admin/media/list")
.then((r) => r.json())
.then((data) => {
if (data.error) setError(data.error);
else setFiles(data.files || []);
})
.catch(() => setError("Liste yüklenemedi"));
}, []);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
className="flex max-h-[80vh] w-full max-w-4xl flex-col rounded-2xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--border)] p-5">
<div>
<h3 className="text-base font-semibold text-[var(--navy)]">
Medya kütüphanesi
</h3>
<p className="mt-1 text-xs text-[var(--muted)]">
{mode === "multiple"
? "Birden fazla görsel seçebilirsiniz"
: "Bir görsel seçin"}
</p>
</div>
<button
type="button"
aria-label="Kapat"
onClick={onClose}
className="rounded-md p-1 text-[var(--muted)] hover:bg-[var(--navy-50)] hover:text-[var(--navy)]"
>
<X className="size-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
{error && (
<p className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
{error}
</p>
)}
{!files && !error && (
<div className="flex items-center justify-center py-12 text-[var(--muted)]">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{files && files.length === 0 && (
<div className="rounded-xl border border-dashed border-[var(--border)] p-12 text-center text-sm text-[var(--muted)]">
<ImageIcon className="mx-auto mb-3 size-8 opacity-40" />
Kütüphanede görsel yok. Önce yükleyin.
</div>
)}
{files && files.length > 0 && (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 lg:grid-cols-5">
{files.map((f) => {
const isSelected = selected.includes(f.url);
return (
<button
key={f.id}
type="button"
onClick={() => onPick(f.url)}
className={`group relative aspect-square overflow-hidden rounded-xl border-2 bg-white transition ${
isSelected
? "border-[var(--navy)] ring-2 ring-[var(--sky)]/30"
: "border-[var(--border)] hover:border-[var(--sky)]"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={f.url}
alt={f.name}
className="size-full object-cover"
loading="lazy"
/>
{isSelected && (
<div className="absolute right-1.5 top-1.5 flex size-5 items-center justify-center rounded-full bg-[var(--navy)] text-white">
<Check className="size-3" />
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-1.5 opacity-0 transition group-hover:opacity-100">
<p className="truncate text-[10px] text-white">{f.name}</p>
</div>
</button>
);
})}
</div>
)}
</div>
<div className="border-t border-[var(--border)] bg-[var(--navy-50)]/40 px-5 py-3 text-right">
<button
type="button"
onClick={onClose}
className="rounded-full bg-[var(--navy)] px-5 py-2 text-xs font-medium text-white hover:bg-[var(--navy-700)]"
>
Tamam
</button>
</div>
</div>
</div>
);
}