feat(rental): Sprint F — photos & vidéos items rental
All checks were successful
CI / test (push) Successful in 2m39s
All checks were successful
CI / test (push) Successful in 2m39s
This commit is contained in:
commit
d24e3b4af7
15 changed files with 521 additions and 25 deletions
|
|
@ -0,0 +1,22 @@
|
|||
-- Sprint F : RentalItemMedia (photos & vidéos pour items rental).
|
||||
-- Mêmes conventions que Media (carbet) : MediaType enum existant, s3Key/s3Url,
|
||||
-- sortOrder pour cover (0). Cascade sur RentalItem.
|
||||
|
||||
CREATE TABLE "RentalItemMedia" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"type" "MediaType" NOT NULL,
|
||||
"s3Key" TEXT NOT NULL,
|
||||
"s3Url" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RentalItemMedia_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "RentalItemMedia_itemId_sortOrder_idx"
|
||||
ON "RentalItemMedia"("itemId", "sortOrder");
|
||||
|
||||
ALTER TABLE "RentalItemMedia"
|
||||
ADD CONSTRAINT "RentalItemMedia_itemId_fkey"
|
||||
FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -466,11 +466,26 @@ model RentalItem {
|
|||
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||||
availabilities RentalItemAvailability[]
|
||||
lines RentalLine[]
|
||||
media RentalItemMedia[]
|
||||
|
||||
@@index([providerId])
|
||||
@@index([category, active])
|
||||
}
|
||||
|
||||
model RentalItemMedia {
|
||||
id String @id @default(cuid())
|
||||
itemId String
|
||||
type MediaType
|
||||
s3Key String
|
||||
s3Url String
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([itemId, sortOrder])
|
||||
}
|
||||
|
||||
model RentalItemAvailability {
|
||||
id String @id @default(cuid())
|
||||
itemId String
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||
import Link from "next/link";
|
||||
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { MediaUploader } from "@/components/MediaUploader";
|
||||
import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
|
||||
|
||||
import { ItemForm } from "../_components/ItemForm";
|
||||
|
|
@ -56,6 +57,14 @@ export default async function EditRentalItemPage({ params }: PageProps) {
|
|||
/>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-base font-semibold text-zinc-900">Photos & vidéos</h2>
|
||||
<MediaUploader
|
||||
scope={{ kind: "rental-item", itemId: item.id }}
|
||||
initialMedia={item.media}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<ItemForm
|
||||
providers={providers}
|
||||
|
|
|
|||
38
src/app/api/rental-media/[id]/route.ts
Normal file
38
src/app/api/rental-media/[id]/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params;
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
const media = await prisma.rentalItemMedia.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, itemId: true, item: { select: { providerId: true } } },
|
||||
});
|
||||
if (!media) return NextResponse.json({ error: "Média introuvable" }, { status: 404 });
|
||||
|
||||
const allowed = await canManageRentalProvider(
|
||||
session.user.id,
|
||||
session.user.role,
|
||||
media.item.providerId,
|
||||
);
|
||||
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
|
||||
await prisma.rentalItemMedia.delete({ where: { id } });
|
||||
await recordAudit({
|
||||
scope: "uploads",
|
||||
event: "rental.media.delete",
|
||||
target: id,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { itemId: media.itemId },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
74
src/app/api/rental-media/reorder/route.ts
Normal file
74
src/app/api/rental-media/reorder/route.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
itemId: z.string().min(1),
|
||||
orderedIds: z.array(z.string()).min(1).max(50),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||
}
|
||||
const { itemId, orderedIds } = parsed.data;
|
||||
|
||||
const item = await prisma.rentalItem.findUnique({
|
||||
where: { id: itemId },
|
||||
select: { providerId: true },
|
||||
});
|
||||
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
||||
|
||||
const allowed = await canManageRentalProvider(
|
||||
session.user.id,
|
||||
session.user.role,
|
||||
item.providerId,
|
||||
);
|
||||
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
|
||||
const existing = await prisma.rentalItemMedia.findMany({
|
||||
where: { itemId, id: { in: orderedIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing.length !== orderedIds.length) {
|
||||
return NextResponse.json({ error: "Certains médias n'appartiennent pas à l'item." }, { status: 400 });
|
||||
}
|
||||
await prisma.$transaction(
|
||||
orderedIds.map((id, idx) =>
|
||||
prisma.rentalItemMedia.update({ where: { id }, data: { sortOrder: idx } }),
|
||||
),
|
||||
);
|
||||
|
||||
// Cover = sortOrder 0 → hydrate imageUrl pour rétro-compat listings
|
||||
const firstId = orderedIds[0];
|
||||
const firstMedia = await prisma.rentalItemMedia.findUnique({
|
||||
where: { id: firstId },
|
||||
select: { s3Url: true, type: true },
|
||||
});
|
||||
if (firstMedia && firstMedia.type === "PHOTO") {
|
||||
await prisma.rentalItem.update({
|
||||
where: { id: itemId },
|
||||
data: { imageUrl: firstMedia.s3Url },
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit({
|
||||
scope: "uploads",
|
||||
event: "rental.media.reorder",
|
||||
target: itemId,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { count: orderedIds.length },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
104
src/app/api/uploads/rental-finalize/route.ts
Normal file
104
src/app/api/uploads/rental-finalize/route.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { MediaType } from "@/generated/prisma/enums";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||
import { classifyMime } from "@/lib/uploads";
|
||||
import { generateImageVariants } from "@/lib/variants-server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
itemId: z.string().min(1),
|
||||
s3Key: z.string().min(5).max(500),
|
||||
s3Url: z.string().url(),
|
||||
mime: z.string().min(3).max(100),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Payload invalide" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const kind = classifyMime(parsed.data.mime);
|
||||
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
|
||||
|
||||
const item = await prisma.rentalItem.findUnique({
|
||||
where: { id: parsed.data.itemId },
|
||||
select: { id: true, providerId: true },
|
||||
});
|
||||
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
||||
|
||||
const allowed = await canManageRentalProvider(
|
||||
session.user.id,
|
||||
session.user.role,
|
||||
item.providerId,
|
||||
);
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!parsed.data.s3Key.startsWith(`rental-items/${item.id}/`)) {
|
||||
return NextResponse.json({ error: "s3Key invalide pour cet item" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingCount = await prisma.rentalItemMedia.count({ where: { itemId: item.id } });
|
||||
const media = await prisma.rentalItemMedia.create({
|
||||
data: {
|
||||
itemId: item.id,
|
||||
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
|
||||
s3Key: parsed.data.s3Key,
|
||||
s3Url: parsed.data.s3Url,
|
||||
sortOrder: existingCount,
|
||||
},
|
||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||
});
|
||||
|
||||
// Si c'est la première photo de l'item, hydrate imageUrl pour rétro-compat
|
||||
// avec les listings (RentalItemCard, /carbets/[slug] panel).
|
||||
if (existingCount === 0 && kind === "photo") {
|
||||
await prisma.rentalItem.update({
|
||||
where: { id: item.id },
|
||||
data: { imageUrl: parsed.data.s3Url },
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit({
|
||||
scope: "uploads",
|
||||
event: "rental.media.finalize",
|
||||
target: media.id,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { itemId: item.id, kind },
|
||||
});
|
||||
|
||||
try {
|
||||
const variants = await generateImageVariants({
|
||||
originalS3Key: parsed.data.s3Key,
|
||||
mime: parsed.data.mime,
|
||||
});
|
||||
if (!variants.skipped) {
|
||||
const okCount = variants.results.filter((r) => r.ok).length;
|
||||
await recordAudit({
|
||||
scope: "uploads",
|
||||
event: "rental.media.variants",
|
||||
target: media.id,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { generated: okCount, total: variants.results.length },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[rental-uploads] variants generation error:", e);
|
||||
}
|
||||
|
||||
return NextResponse.json({ media });
|
||||
}
|
||||
62
src/app/api/uploads/rental-presign/route.ts
Normal file
62
src/app/api/uploads/rental-presign/route.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { canManageRentalProvider } from "@/lib/rental-access";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
import { presignRentalItemUpload } from "@/lib/uploads";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
itemId: z.string().min(1),
|
||||
mime: z.string().min(3).max(100),
|
||||
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const rl = rateLimitRequest(req, "rental-presign", 60_000, 60);
|
||||
if (!rl.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Payload invalide" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const item = await prisma.rentalItem.findUnique({
|
||||
where: { id: parsed.data.itemId },
|
||||
select: { id: true, providerId: true },
|
||||
});
|
||||
if (!item) return NextResponse.json({ error: "Item introuvable" }, { status: 404 });
|
||||
|
||||
const allowed = await canManageRentalProvider(
|
||||
session.user.id,
|
||||
session.user.role,
|
||||
item.providerId,
|
||||
);
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await presignRentalItemUpload({
|
||||
itemId: item.id,
|
||||
mime: parsed.data.mime,
|
||||
sizeBytes: parsed.data.sizeBytes,
|
||||
});
|
||||
if ("error" in result) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { MediaUploader } from "@/components/MediaUploader";
|
||||
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
|
||||
import { getHostItem } from "@/lib/rental-host";
|
||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||
|
|
@ -59,6 +60,16 @@ export default async function EditHostItemPage({ params }: PageProps) {
|
|||
<ItemInlineDelete deleteAction={deleteThis} canDelete={item._count.lines === 0} />
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Photos & vidéos
|
||||
</h2>
|
||||
<MediaUploader
|
||||
scope={{ kind: "rental-item", itemId: item.id }}
|
||||
initialMedia={item.media}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<HostItemForm
|
||||
action={updateThis}
|
||||
|
|
|
|||
74
src/app/materiel/[itemId]/_components/ItemGallery.tsx
Normal file
74
src/app/materiel/[itemId]/_components/ItemGallery.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Media = { id: string; type: "PHOTO" | "VIDEO"; s3Url: string };
|
||||
|
||||
export function ItemGallery({
|
||||
media,
|
||||
fallbackEmoji,
|
||||
alt,
|
||||
}: {
|
||||
media: Media[];
|
||||
fallbackEmoji: string;
|
||||
alt: string;
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
if (media.length === 0) {
|
||||
return (
|
||||
<div className="flex aspect-[4/3] w-full items-center justify-center rounded-lg bg-zinc-100 text-7xl text-zinc-300">
|
||||
{fallbackEmoji}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = media[idx];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-hidden rounded-lg bg-zinc-100">
|
||||
{current.type === "VIDEO" ? (
|
||||
<video
|
||||
src={current.s3Url}
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={current.s3Url}
|
||||
alt={alt}
|
||||
className="aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{media.length > 1 ? (
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{media.map((m, i) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setIdx(i)}
|
||||
className={
|
||||
"aspect-square overflow-hidden rounded-md border transition " +
|
||||
(i === idx
|
||||
? "border-emerald-500 ring-2 ring-emerald-200"
|
||||
: "border-zinc-200 hover:border-zinc-400 opacity-70 hover:opacity-100")
|
||||
}
|
||||
aria-label={`Photo ${i + 1}`}
|
||||
>
|
||||
{m.type === "VIDEO" ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-zinc-900 text-white">▶</div>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={m.s3Url} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
|||
|
||||
import { AddToCart } from "./_components/AddToCart";
|
||||
import { AvailabilityPreview } from "./_components/AvailabilityPreview";
|
||||
import { ItemGallery } from "./_components/ItemGallery";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -58,19 +59,18 @@ export default async function RentalItemDetailPage({ params }: PageProps) {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-5 overflow-hidden rounded-lg bg-zinc-100">
|
||||
{item.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.name}
|
||||
className="aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] w-full items-center justify-center text-7xl text-zinc-300">
|
||||
{categoryEmoji}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5">
|
||||
<ItemGallery
|
||||
media={
|
||||
item.media.length > 0
|
||||
? item.media
|
||||
: item.imageUrl
|
||||
? [{ id: "legacy", type: "PHOTO", s3Url: item.imageUrl }]
|
||||
: []
|
||||
}
|
||||
alt={item.name}
|
||||
fallbackEmoji={categoryEmoji}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.description ? (
|
||||
|
|
|
|||
|
|
@ -28,11 +28,53 @@ export type MediaItem = {
|
|||
sortOrder: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Le composant gère deux périmètres : carbet (par défaut) et item de location.
|
||||
* Les endpoints sont alors `/api/uploads/{rental-}{presign,finalize}`,
|
||||
* `/api/{rental-}media/{id}` et `/api/{rental-}media/reorder` ; la clé de
|
||||
* scope dans les payloads passe de `carbetId` à `itemId`.
|
||||
*/
|
||||
export type UploaderScope =
|
||||
| { kind: "carbet"; carbetId: string }
|
||||
| { kind: "rental-item"; itemId: string };
|
||||
|
||||
type Props = {
|
||||
carbetId: string;
|
||||
scope?: UploaderScope;
|
||||
/** @deprecated — passer `scope={{kind:"carbet", carbetId}}` à la place. */
|
||||
carbetId?: string;
|
||||
initialMedia: MediaItem[];
|
||||
};
|
||||
|
||||
type Endpoints = {
|
||||
presign: string;
|
||||
finalize: string;
|
||||
reorder: string;
|
||||
remove: (mediaId: string) => string;
|
||||
idKey: "carbetId" | "itemId";
|
||||
idValue: string;
|
||||
};
|
||||
|
||||
function endpointsFor(scope: UploaderScope): Endpoints {
|
||||
if (scope.kind === "carbet") {
|
||||
return {
|
||||
presign: "/api/uploads/presign",
|
||||
finalize: "/api/uploads/finalize",
|
||||
reorder: "/api/media/reorder",
|
||||
remove: (id) => `/api/media/${id}`,
|
||||
idKey: "carbetId",
|
||||
idValue: scope.carbetId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
presign: "/api/uploads/rental-presign",
|
||||
finalize: "/api/uploads/rental-finalize",
|
||||
reorder: "/api/rental-media/reorder",
|
||||
remove: (id) => `/api/rental-media/${id}`,
|
||||
idKey: "itemId",
|
||||
idValue: scope.itemId,
|
||||
};
|
||||
}
|
||||
|
||||
type UploadEntry = {
|
||||
tempId: string;
|
||||
name: string;
|
||||
|
|
@ -45,7 +87,11 @@ type UploadEntry = {
|
|||
|
||||
const MAX_PARALLEL = 3;
|
||||
|
||||
export function MediaUploader({ carbetId, initialMedia }: Props) {
|
||||
export function MediaUploader({ scope, carbetId, initialMedia }: Props) {
|
||||
const endpoints = useMemo(
|
||||
() => endpointsFor(scope ?? { kind: "carbet", carbetId: carbetId ?? "" }),
|
||||
[scope, carbetId],
|
||||
);
|
||||
const [items, setItems] = useState<MediaItem[]>(
|
||||
[...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
);
|
||||
|
|
@ -66,13 +112,13 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
|
|||
|
||||
const reorderOnServer = useCallback(
|
||||
async (orderedIds: string[]) => {
|
||||
await fetch("/api/media/reorder", {
|
||||
await fetch(endpoints.reorder, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carbetId, orderedIds }),
|
||||
body: JSON.stringify({ [endpoints.idKey]: endpoints.idValue, orderedIds }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
[carbetId],
|
||||
[endpoints],
|
||||
);
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
|
|
@ -103,9 +149,9 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
|
|||
|
||||
const removeItem = useCallback(async (id: string) => {
|
||||
if (!confirm("Supprimer ce média ?")) return;
|
||||
const res = await fetch(`/api/media/${id}`, { method: "DELETE" });
|
||||
const res = await fetch(endpoints.remove(id), { method: "DELETE" });
|
||||
if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
}, [endpoints]);
|
||||
|
||||
const processFile = useCallback(async function processFile(file: File): Promise<void> {
|
||||
const tempId = crypto.randomUUID();
|
||||
|
|
@ -114,10 +160,14 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
|
|||
{ tempId, name: file.name, sizeBytes: file.size, mime: file.type, progress: 0, done: false },
|
||||
]);
|
||||
try {
|
||||
const presignRes = await fetch("/api/uploads/presign", {
|
||||
const presignRes = await fetch(endpoints.presign, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carbetId, mime: file.type, sizeBytes: file.size }),
|
||||
body: JSON.stringify({
|
||||
[endpoints.idKey]: endpoints.idValue,
|
||||
mime: file.type,
|
||||
sizeBytes: file.size,
|
||||
}),
|
||||
});
|
||||
const presign = await presignRes.json();
|
||||
if (!presignRes.ok) throw new Error(presign?.error || "presign refusé");
|
||||
|
|
@ -138,11 +188,11 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
|
|||
xhr.send(file);
|
||||
});
|
||||
|
||||
const finalizeRes = await fetch("/api/uploads/finalize", {
|
||||
const finalizeRes = await fetch(endpoints.finalize, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
carbetId,
|
||||
[endpoints.idKey]: endpoints.idValue,
|
||||
s3Key: presign.s3Key,
|
||||
s3Url: presign.publicUrl,
|
||||
mime: file.type,
|
||||
|
|
@ -160,7 +210,7 @@ export function MediaUploader({ carbetId, initialMedia }: Props) {
|
|||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, error: msg } : x)));
|
||||
}
|
||||
}, [carbetId]);
|
||||
}, [endpoints]);
|
||||
|
||||
const popQueueRef = useRef<() => void>(() => {});
|
||||
const popQueue = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,10 @@ export async function getRentalItemForAdmin(id: string) {
|
|||
where: { id },
|
||||
include: {
|
||||
provider: { select: { id: true, name: true, isSystemD: true } },
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,10 @@ export async function getHostItem(providerId: string, itemId: string) {
|
|||
include: {
|
||||
availabilities: { orderBy: { startDate: "asc" } },
|
||||
_count: { select: { lines: true } },
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ export async function getPublicRentalItem(id: string) {
|
|||
contactPhone: true,
|
||||
},
|
||||
},
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Url: true, sortOrder: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,5 +100,30 @@ export async function presignCarbetUpload(opts: {
|
|||
return { s3Key, uploadUrl, publicUrl, expiresIn: 600 };
|
||||
}
|
||||
|
||||
export async function presignRentalItemUpload(opts: {
|
||||
itemId: string;
|
||||
mime: string;
|
||||
sizeBytes: number;
|
||||
}): Promise<PresignResult | { error: string }> {
|
||||
const kind = classifyMime(opts.mime);
|
||||
if (!kind) return { error: `Type non supporté : ${opts.mime}` };
|
||||
const max = maxBytesFor(kind);
|
||||
if (opts.sizeBytes > max) {
|
||||
return { error: `Fichier trop volumineux (${Math.round(opts.sizeBytes / 1_000_000)} Mo, max ${Math.round(max / 1_000_000)} Mo).` };
|
||||
}
|
||||
const id = crypto.randomBytes(12).toString("hex");
|
||||
const ext = extensionFor(opts.mime);
|
||||
const s3Key = `rental-items/${opts.itemId}/${Date.now()}-${id}.${ext}`;
|
||||
|
||||
const cmd = new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: s3Key,
|
||||
ContentType: opts.mime,
|
||||
});
|
||||
const uploadUrl = await getSignedUrl(s3Presign, cmd, { expiresIn: 600 });
|
||||
const publicUrl = `${PUBLIC_BASE_EXTERNAL.replace(/\/$/, "")}/${s3Key}`;
|
||||
return { s3Key, uploadUrl, publicUrl, expiresIn: 600 };
|
||||
}
|
||||
|
||||
export { s3Internal };
|
||||
export { BUCKET as UPLOAD_BUCKET };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue