first commit
This commit is contained in:
266
app/apps/[id]/store/ScreenshotManager.tsx
Normal file
266
app/apps/[id]/store/ScreenshotManager.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user