Files
app-admin/app/apps/[id]/store/ScreenshotManager.tsx
2026-03-24 15:46:27 +03:00

267 lines
10 KiB
TypeScript
Raw Permalink 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 { 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>
);
}