first commit

This commit is contained in:
mstfyldz
2026-03-24 15:46:27 +03:00
parent 095d830279
commit 34b6a46604
33 changed files with 5212 additions and 81 deletions

View 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>
);
}