Files
kovakyazilim/components/admin/media-picker.tsx
T
Ege Can Komur dbc55e7527 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).
2026-05-20 04:11:41 +03:00

493 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}