diff --git a/app/api/admin/media/list/route.ts b/app/api/admin/media/list/route.ts
new file mode 100644
index 0000000..99c008e
--- /dev/null
+++ b/app/api/admin/media/list/route.ts
@@ -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 });
+ }
+}
diff --git a/app/api/admin/media/upload/route.ts b/app/api/admin/media/upload/route.ts
new file mode 100644
index 0000000..3316041
--- /dev/null
+++ b/app/api/admin/media/upload/route.ts
@@ -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 });
+ }
+}
diff --git a/components/admin/media-picker.tsx b/components/admin/media-picker.tsx
new file mode 100644
index 0000000..815f7d4
--- /dev/null
+++ b/components/admin/media-picker.tsx
@@ -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
(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(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 (
+
+
+
+
+
+
+
+
+ {/* Drop zone */}
+
{
+ 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)]"
+ }`}
+ >
+
{
+ handleFiles(e.target.files);
+ e.target.value = "";
+ }}
+ className="hidden"
+ />
+
+
+
+ {mode === "multiple"
+ ? "Sürükle-bırak veya dosyaları seç"
+ : "Sürükle-bırak veya dosya seç"}
+
+
+ PNG, JPG, WEBP, SVG • max {props.maxSizeMB ?? 10} MB
+
+
+
+
+ {/* Active uploads */}
+ {activeUploads.length > 0 && (
+
+ {activeUploads.map((u) => (
+
+
+
+ {u.error ? (
+
+ ) : u.progress === 100 ? (
+
+ ) : (
+
+ )}
+
+ {u.name}
+
+
+
+ {u.error ? "Hata" : `${u.progress}%`}
+
+
+ {u.error ? (
+
{u.error}
+ ) : (
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Selected previews */}
+ {values.length > 0 && (
+
+ {values.map((url, i) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ {mode === "multiple" && values.length > 1 && (
+
+ {i > 0 && (
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+ {props.help && (
+
{props.help}
+ )}
+
+ {showLibrary && (
+
setShowLibrary(false)}
+ onPick={(url) => {
+ addUrl(url);
+ if (mode === "single") setShowLibrary(false);
+ }}
+ />
+ )}
+
+ );
+}
+
+function uploadWithProgress(
+ fd: FormData,
+ onProgress: (pct: number) => void,
+): Promise {
+ 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(null);
+ const [error, setError] = useState(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 (
+
+
e.stopPropagation()}
+ >
+
+
+
+ Medya kütüphanesi
+
+
+ {mode === "multiple"
+ ? "Birden fazla görsel seçebilirsiniz"
+ : "Bir görsel seçin"}
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {!files && !error && (
+
+
+
+ )}
+ {files && files.length === 0 && (
+
+
+ Kütüphanede görsel yok. Önce yükleyin.
+
+ )}
+ {files && files.length > 0 && (
+
+ {files.map((f) => {
+ const isSelected = selected.includes(f.url);
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+
+ );
+}