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
+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>
);
}