allow users to upload build images. No backend, just local previews. need to build backend
This commit is contained in:
@@ -19,6 +19,21 @@ type BuildDto = {
|
|||||||
productName?: string | null;
|
productName?: string | null;
|
||||||
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
|
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
|
||||||
}>;
|
}>;
|
||||||
|
photos?: BuildPhotoDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildPhotoDto = {
|
||||||
|
uuid: string;
|
||||||
|
url: string;
|
||||||
|
caption?: string | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocalPreview = {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
url: string; // object URL
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmt(d?: string | null) {
|
function fmt(d?: string | null) {
|
||||||
@@ -44,6 +59,17 @@ export default function VaultBuildEditPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ✅ photo upload UI state
|
||||||
|
const [previews, setPreviews] = useState<LocalPreview[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
// ✅ avoid object URL leaks
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
previews.forEach((p) => URL.revokeObjectURL(p.url));
|
||||||
|
};
|
||||||
|
}, [previews]);
|
||||||
|
|
||||||
const authed = !!token;
|
const authed = !!token;
|
||||||
|
|
||||||
// Load build once auth is ready + token exists
|
// Load build once auth is ready + token exists
|
||||||
@@ -137,6 +163,52 @@ export default function VaultBuildEditPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isImage(file: File) {
|
||||||
|
return file.type.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileToUpload(file: File, buildUuid: string) {
|
||||||
|
// 1) ask backend for presigned upload URL
|
||||||
|
const res = await api.post(
|
||||||
|
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos/upload-url`,
|
||||||
|
{
|
||||||
|
fileName: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(await res.text().catch(() => "Failed to get upload URL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uploadUrl, publicUrl } = await res.json();
|
||||||
|
|
||||||
|
// 2) upload directly to storage
|
||||||
|
const put = await fetch(uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!put.ok) throw new Error("Upload failed");
|
||||||
|
|
||||||
|
// 3) confirm + create photo record
|
||||||
|
const create = await api.post(
|
||||||
|
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos`,
|
||||||
|
{
|
||||||
|
url: publicUrl,
|
||||||
|
caption: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!create.ok) {
|
||||||
|
throw new Error(await create.text().catch(() => "Failed to save photo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await create.json()) as BuildPhotoDto;
|
||||||
|
}
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!uuid) return;
|
if (!uuid) return;
|
||||||
|
|
||||||
@@ -346,6 +418,170 @@ export default function VaultBuildEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Build Photos */}
|
||||||
|
<div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold">Build Photos</div>
|
||||||
|
<div className="text-xs opacity-70">
|
||||||
|
Select photos, remove any you don’t want, then upload.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Select (no upload) */}
|
||||||
|
<label
|
||||||
|
className={`cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs hover:bg-white/10 ${
|
||||||
|
uploading ? "opacity-60 pointer-events-none" : ""
|
||||||
|
}`}
|
||||||
|
title={uploading ? "Uploading…" : "Select photos"}
|
||||||
|
>
|
||||||
|
Select Photos
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = Array.from(e.target.files ?? []).filter(
|
||||||
|
isImage
|
||||||
|
);
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
const local: LocalPreview[] = files.map((f) => ({
|
||||||
|
id:
|
||||||
|
typeof crypto !== "undefined" &&
|
||||||
|
"randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random()}`,
|
||||||
|
file: f,
|
||||||
|
url: URL.createObjectURL(f),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setPreviews((prev) => [...local, ...prev]);
|
||||||
|
e.target.value = ""; // reset input so re-selecting same file works
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Upload (real upload) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading || previews.length === 0 || !build?.uuid}
|
||||||
|
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!build?.uuid || previews.length === 0) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
// snapshot so user can keep selecting more while current batch uploads
|
||||||
|
const batch = [...previews];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const p of batch) {
|
||||||
|
// if user removed it before we got to it, skip
|
||||||
|
const stillThere = previews.some((x) => x.id === p.id);
|
||||||
|
if (!stillThere) continue;
|
||||||
|
|
||||||
|
const created = await fileToUpload(p.file, build.uuid);
|
||||||
|
|
||||||
|
setBuild((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
photos: [created, ...(prev.photos ?? [])],
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
|
||||||
|
// remove preview after success
|
||||||
|
setPreviews((prev) => {
|
||||||
|
const hit = prev.find((x) => x.id === p.id);
|
||||||
|
if (hit) URL.revokeObjectURL(hit.url);
|
||||||
|
return prev.filter((x) => x.id !== p.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message ?? "Photo upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading
|
||||||
|
? "Uploading…"
|
||||||
|
: `Upload${previews.length ? ` (${previews.length})` : ""}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gallery */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{/* Local previews (pending upload) */}
|
||||||
|
{previews.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="relative overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||||||
|
>
|
||||||
|
{/* remove X */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove"
|
||||||
|
className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80"
|
||||||
|
onClick={() => {
|
||||||
|
setPreviews((prev) => {
|
||||||
|
const hit = prev.find((x) => x.id === p.id);
|
||||||
|
if (hit) URL.revokeObjectURL(hit.url);
|
||||||
|
return prev.filter((x) => x.id !== p.id);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={uploading}
|
||||||
|
title={
|
||||||
|
uploading
|
||||||
|
? "Wait for upload to finish"
|
||||||
|
: "Remove from upload list"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={p.url}
|
||||||
|
alt="Pending upload"
|
||||||
|
className="h-32 w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="p-2 text-xs opacity-80">
|
||||||
|
{uploading ? "Uploading…" : "Pending upload"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Uploaded photos */}
|
||||||
|
{(build?.photos ?? []).map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.uuid}
|
||||||
|
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={p.url}
|
||||||
|
alt={p.caption ?? "Build photo"}
|
||||||
|
className="h-32 w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="p-2 text-xs opacity-80 line-clamp-2">
|
||||||
|
{p.caption ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{previews.length === 0 && (build?.photos?.length ?? 0) === 0 && (
|
||||||
|
<div className="col-span-full text-xs opacity-70">
|
||||||
|
No photos yet. Select a few and upload when ready.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* danger zone */}
|
{/* danger zone */}
|
||||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
||||||
<div className="text-sm font-semibold text-red-200">
|
<div className="text-sm font-semibold text-red-200">
|
||||||
|
|||||||
+2
-2
@@ -226,7 +226,7 @@ export default function VaultPage() {
|
|||||||
}`}
|
}`}
|
||||||
title={valid ? "Load this build into the builder" : "Invalid build id"}
|
title={valid ? "Load this build into the builder" : "Invalid build id"}
|
||||||
>
|
>
|
||||||
Open
|
View in Builder
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@@ -240,7 +240,7 @@ export default function VaultPage() {
|
|||||||
}`}
|
}`}
|
||||||
title={valid ? "Edit details / publish later" : "Invalid build id"}
|
title={valid ? "Edit details / publish later" : "Invalid build id"}
|
||||||
>
|
>
|
||||||
Edit
|
Edit Title
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user