dbc55e7527
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).
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|