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:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
label="Varsayılan OG görseli"
|
||||
name="default_og_image"
|
||||
type="url"
|
||||
defaultValue={settings?.default_og_image}
|
||||
/>
|
||||
<div>
|
||||
<MediaPicker
|
||||
label="Varsayılan OG görseli"
|
||||
name="default_og_image"
|
||||
defaultValue={settings?.default_og_image}
|
||||
help="Sosyal paylaşımlarda (1200×630 ideal)."
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Twitter handle"
|
||||
name="twitter_handle"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user