267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef, useTransition, useCallback } from "react";
|
||
import { Image as ImageIcon, Upload, Trash2, Loader2, Plus, AlertCircle } from "lucide-react";
|
||
|
||
interface Screenshot {
|
||
id: string;
|
||
attributes: {
|
||
fileName: string;
|
||
fileSize: number;
|
||
uploadOperations: UploadOperation[] | null;
|
||
assetDeliveryState: {
|
||
state: "COMPLETE" | "FAILED" | "UPLOAD_COMPLETE" | "AWAITING_UPLOAD";
|
||
errors?: { code: string; description: string }[];
|
||
} | null;
|
||
templateUrl: string | null;
|
||
};
|
||
}
|
||
|
||
interface UploadOperation {
|
||
method: string;
|
||
url: string;
|
||
length: number;
|
||
offset: number;
|
||
requestHeaders: { name: string; value: string }[];
|
||
}
|
||
|
||
interface ScreenshotManagerProps {
|
||
versionId: string;
|
||
versionString: string;
|
||
}
|
||
|
||
export default function ScreenshotManager({ versionId, versionString }: ScreenshotManagerProps) {
|
||
const [localizationId, setLocalizationId] = useState<string | null>(null);
|
||
const [screenshots, setScreenshots] = useState<Screenshot[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||
const [dragging, setDragging] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [, startTransition] = useTransition();
|
||
|
||
const fetchScreenshots = useCallback(async (locId: string) => {
|
||
setError(null);
|
||
const res = await fetch(`/api/asc/screenshots?localizationId=${locId}`);
|
||
const d = await res.json();
|
||
if (!res.ok) { setError(d.error ?? "Ekran görüntüleri yüklenemedi"); return; }
|
||
setScreenshots(d.screenshots ?? []);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
// Step 1: resolve the first localization id for this version
|
||
fetch(`/api/asc/localizations?versionId=${versionId}`)
|
||
.then((r) => r.json())
|
||
.then(async (d) => {
|
||
const locs = d.data ?? [];
|
||
if (locs.length === 0) { setLoading(false); return; }
|
||
const firstId: string = locs[0].id;
|
||
setLocalizationId(firstId);
|
||
await fetchScreenshots(firstId);
|
||
})
|
||
.catch((e) => setError(e.message))
|
||
.finally(() => setLoading(false));
|
||
}, [versionId, fetchScreenshots]);
|
||
|
||
async function uploadFile(file: File) {
|
||
if (!localizationId) return;
|
||
setUploading(true);
|
||
setError(null);
|
||
try {
|
||
// Step 1: Reserve
|
||
const reserveRes = await fetch(`/api/asc/screenshots`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ localizationId, fileName: file.name, fileSize: file.size }),
|
||
});
|
||
const reserveData = await reserveRes.json();
|
||
if (!reserveRes.ok) { setError(reserveData.error ?? "Yükleme başlatılamadı"); return; }
|
||
|
||
const screenshotId: string = reserveData.data?.id;
|
||
const uploadOps: UploadOperation[] = reserveData.data?.attributes?.uploadOperations ?? [];
|
||
|
||
// Step 2: Upload each part
|
||
for (const op of uploadOps) {
|
||
const slice = file.slice(op.offset, op.offset + op.length);
|
||
const headers: Record<string, string> = {};
|
||
for (const h of op.requestHeaders) headers[h.name] = h.value;
|
||
await fetch(op.url, { method: op.method, headers, body: slice });
|
||
}
|
||
|
||
// Step 3: Commit
|
||
const commitRes = await fetch(`/api/asc/screenshots/commit`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ screenshotId }),
|
||
});
|
||
if (!commitRes.ok) {
|
||
const d = await commitRes.json();
|
||
setError(d.error ?? "Yükleme tamamlanamadı");
|
||
return;
|
||
}
|
||
|
||
await fetchScreenshots(localizationId);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Bilinmeyen hata");
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}
|
||
|
||
function handleFiles(files: FileList | null) {
|
||
if (!files || files.length === 0) return;
|
||
startTransition(() => {
|
||
Array.from(files).forEach((f) => uploadFile(f));
|
||
});
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
setDeletingId(id);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(`/api/asc/screenshots?screenshotId=${id}`, { method: "DELETE" });
|
||
if (!res.ok) {
|
||
const d = await res.json();
|
||
setError(d.error ?? "Silinemedi");
|
||
} else {
|
||
setScreenshots((prev) => prev.filter((s) => s.id !== id));
|
||
}
|
||
} finally {
|
||
setDeletingId(null);
|
||
}
|
||
}
|
||
|
||
function onDragOver(e: React.DragEvent) { e.preventDefault(); setDragging(true); }
|
||
function onDragLeave() { setDragging(false); }
|
||
function onDrop(e: React.DragEvent) {
|
||
e.preventDefault();
|
||
setDragging(false);
|
||
handleFiles(e.dataTransfer.files);
|
||
}
|
||
|
||
return (
|
||
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
|
||
<h2 className="font-bold text-sm flex items-center gap-2">
|
||
<ImageIcon size={15} className="text-sky-400" />
|
||
Ekran Görüntüleri
|
||
<span className="text-[10px] text-slate-500 font-normal">iPhone 6.5″ · v{versionString}</span>
|
||
<span className="text-[10px] bg-sky-500/10 text-sky-400 border border-sky-500/20 rounded-full px-2 py-0.5">
|
||
{screenshots.length}/10
|
||
</span>
|
||
</h2>
|
||
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={uploading || screenshots.length >= 10 || !localizationId}
|
||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-sky-600/20 hover:bg-sky-600/30 border border-sky-500/20 text-xs font-semibold text-sky-300 hover:text-sky-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
|
||
Ekle
|
||
</button>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg"
|
||
multiple
|
||
className="hidden"
|
||
onChange={(e) => handleFiles(e.target.files)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="p-6">
|
||
{error && (
|
||
<div className="flex items-center gap-2 text-xs text-red-400 bg-red-400/10 rounded-lg px-4 py-2 mb-4">
|
||
<AlertCircle size={13} />
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 size={22} className="animate-spin text-slate-500" />
|
||
</div>
|
||
) : !localizationId ? (
|
||
<p className="text-sm text-slate-500 text-center py-8">
|
||
Bu sürüm için lokalizasyon bulunamadı.
|
||
</p>
|
||
) : (
|
||
<>
|
||
{screenshots.length > 0 && (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mb-4">
|
||
{screenshots.map((s) => {
|
||
const src = s.attributes.templateUrl;
|
||
const state = s.attributes.assetDeliveryState?.state;
|
||
return (
|
||
<div
|
||
key={s.id}
|
||
className="relative group rounded-xl overflow-hidden border border-white/8 bg-black/30 aspect-[9/19.5]"
|
||
>
|
||
{src ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={src} alt={s.attributes.fileName} className="w-full h-full object-cover" />
|
||
) : (
|
||
<div className="w-full h-full flex flex-col items-center justify-center gap-1 text-slate-600">
|
||
<ImageIcon size={20} />
|
||
<span className="text-[9px]">
|
||
{state === "AWAITING_UPLOAD" ? "Yükleniyor…" : state ?? "İşleniyor"}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||
<button
|
||
onClick={() => handleDelete(s.id)}
|
||
disabled={deletingId === s.id}
|
||
className="p-2 rounded-lg bg-red-500/20 hover:bg-red-500/40 text-red-400 hover:text-red-300 transition-colors"
|
||
>
|
||
{deletingId === s.id
|
||
? <Loader2 size={14} className="animate-spin" />
|
||
: <Trash2 size={14} />}
|
||
</button>
|
||
</div>
|
||
|
||
{state && state !== "COMPLETE" && (
|
||
<div className="absolute bottom-1 left-1 right-1">
|
||
<span className="block text-center text-[9px] bg-black/70 text-amber-400 rounded px-1 py-0.5">
|
||
{state === "UPLOAD_COMPLETE" ? "İşleniyor…" : state}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{screenshots.length < 10 && (
|
||
<div
|
||
onDragOver={onDragOver}
|
||
onDragLeave={onDragLeave}
|
||
onDrop={onDrop}
|
||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||
className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center gap-3 cursor-pointer transition-all ${
|
||
dragging ? "border-sky-500 bg-sky-500/10" : "border-white/10 hover:border-white/20 hover:bg-white/[0.02]"
|
||
} ${uploading ? "pointer-events-none opacity-60" : ""}`}
|
||
>
|
||
{uploading ? <Loader2 size={24} className="animate-spin text-sky-400" /> : <Upload size={24} className="text-slate-500" />}
|
||
<div className="text-center">
|
||
<p className="text-sm text-slate-400 font-medium">
|
||
{uploading ? "Yükleniyor…" : "PNG / JPEG sürükle veya tıkla"}
|
||
</p>
|
||
<p className="text-[11px] text-slate-600 mt-0.5">iPhone 6.5″ · Önerilen: 1242 × 2688 px</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|