feat: Au fil de l'eau (Reels) + uploader pro + favoris #65
20 changed files with 1569 additions and 72 deletions
74
package-lock.json
generated
74
package-lock.json
generated
|
|
@ -10,6 +10,10 @@
|
|||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1056.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1058.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
|
|
@ -509,6 +513,23 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/s3-request-presigner": {
|
||||
"version": "3.1058.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1058.0.tgz",
|
||||
"integrity": "sha512-IRgNfn8U3zfsZ0JkpmwjS59R/XyHMHxpuwW6HVuJhik+FsbClhNkujEO0w1WqJvXrF4FX+7qIAwUrvlwNvaZ7Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.15",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.30",
|
||||
"@aws-sdk/types": "^3.973.9",
|
||||
"@smithy/core": "^3.24.5",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.30",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz",
|
||||
|
|
@ -839,6 +860,59 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electric-sql/pglite": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1056.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1058.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
|
|
|
|||
8
prisma/migrations/20260602100000_favorite/migration.sql
Normal file
8
prisma/migrations/20260602100000_favorite/migration.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE "Favorite" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"carbetId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Favorite_pkey" PRIMARY KEY ("userId", "carbetId")
|
||||
);
|
||||
CREATE INDEX "Favorite_userId_idx" ON "Favorite"("userId");
|
||||
CREATE INDEX "Favorite_carbetId_idx" ON "Favorite"("carbetId");
|
||||
|
|
@ -371,3 +371,13 @@ model PasswordResetToken {
|
|||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Favorite {
|
||||
userId String
|
||||
carbetId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([userId, carbetId])
|
||||
@@index([userId])
|
||||
@@index([carbetId])
|
||||
}
|
||||
|
|
|
|||
60
src/app/accueil/page.tsx
Normal file
60
src/app/accueil/page.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import Link from "next/link";
|
||||
import { IfPluginEnabled } from "@/components/IfPluginEnabled";
|
||||
import { HeroSection } from "@/components/landing/HeroSection";
|
||||
import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
|
||||
import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
|
||||
import { CESection } from "@/components/landing/CESection";
|
||||
import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
|
||||
import { LandingFooter } from "@/components/landing/Footer";
|
||||
|
||||
export const metadata = { title: "Accueil — Karbé" };
|
||||
|
||||
/**
|
||||
* Landing « marketing » historique (hero + sections + footer riche). Conservée
|
||||
* à /accueil après la promotion de /decouvrir comme nouvelle page d'index.
|
||||
*/
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<>
|
||||
<IfPluginEnabled
|
||||
plugin="landing-hero"
|
||||
fallback={
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-50 px-6 dark:bg-black">
|
||||
<main className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50">
|
||||
Karbé — carbets fluviaux de Guyane
|
||||
</h1>
|
||||
<p className="max-w-xl text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
La marketplace pour louer des carbets le long des fleuves de Guyane.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/decouvrir"
|
||||
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Au fil de l'eau
|
||||
</Link>
|
||||
<Link
|
||||
href="/carbets"
|
||||
className="rounded-md border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
|
||||
>
|
||||
Catalogue
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HeroSection />
|
||||
</IfPluginEnabled>
|
||||
|
||||
<IfPluginEnabled plugin="landing-sections">
|
||||
<ExperiencesSection />
|
||||
<HowItWorksSection />
|
||||
<CESection />
|
||||
<TestimonialsSection />
|
||||
<LandingFooter />
|
||||
</IfPluginEnabled>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/app/api/favorites/route.ts
Normal file
61
src/app/api/favorites/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
carbetId: z.string().min(1),
|
||||
});
|
||||
|
||||
async function requireSelf() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Unauth");
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const userId = await requireSelf();
|
||||
const rows = await prisma.favorite.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { carbetId: true },
|
||||
});
|
||||
return NextResponse.json({ ids: rows.map((r) => r.carbetId) });
|
||||
} catch {
|
||||
return NextResponse.json({ ids: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const userId = await requireSelf();
|
||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||
await prisma.favorite.upsert({
|
||||
where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } },
|
||||
create: { userId, carbetId: parsed.data.carbetId },
|
||||
update: {},
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const userId = await requireSelf();
|
||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: "Payload invalide" }, { status: 400 });
|
||||
await prisma.favorite
|
||||
.delete({ where: { userId_carbetId: { userId, carbetId: parsed.data.carbetId } } })
|
||||
.catch(() => null);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
41
src/app/api/media/[id]/route.ts
Normal file
41
src/app/api/media/[id]/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function requireOwnership(mediaId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Non authentifié");
|
||||
const m = await prisma.media.findUnique({
|
||||
where: { id: mediaId },
|
||||
select: { id: true, carbetId: true, carbet: { select: { ownerId: true } } },
|
||||
});
|
||||
if (!m) throw new Error("Média introuvable");
|
||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||
if (!isAdmin && m.carbet.ownerId !== session.user.id) throw new Error("Accès refusé");
|
||||
return { session, media: m };
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await ctx.params;
|
||||
try {
|
||||
const { session, media } = await requireOwnership(id);
|
||||
await prisma.media.delete({ where: { id } });
|
||||
await recordAudit({
|
||||
scope: "uploads",
|
||||
event: "media.delete",
|
||||
target: id,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { carbetId: media.carbetId },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const status = msg === "Non authentifié" ? 401 : msg === "Accès refusé" ? 403 : 404;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
}
|
||||
55
src/app/api/media/reorder/route.ts
Normal file
55
src/app/api/media/reorder/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
carbetId: 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 { carbetId, orderedIds } = parsed.data;
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: carbetId },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||
if (!isAdmin && carbet.ownerId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
const existing = await prisma.media.findMany({
|
||||
where: { carbetId, id: { in: orderedIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing.length !== orderedIds.length) {
|
||||
return NextResponse.json({ error: "Certains médias n'appartiennent pas au carbet." }, { status: 400 });
|
||||
}
|
||||
await prisma.$transaction(
|
||||
orderedIds.map((id, idx) =>
|
||||
prisma.media.update({ where: { id }, data: { sortOrder: idx } }),
|
||||
),
|
||||
);
|
||||
await recordAudit({
|
||||
scope: "uploads",
|
||||
event: "media.reorder",
|
||||
target: carbetId,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { count: orderedIds.length },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
66
src/app/api/uploads/finalize/route.ts
Normal file
66
src/app/api/uploads/finalize/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { MediaType, UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { classifyMime } from "@/lib/uploads";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
carbetId: 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 carbet = await prisma.carbet.findUnique({
|
||||
where: { id: parsed.data.carbetId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||
const isOwner = carbet.ownerId === session.user.id;
|
||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// S3Key doit appartenir au carbet — verrou pour éviter qu'un user finalise une key étrangère.
|
||||
if (!parsed.data.s3Key.startsWith(`carbets/${carbet.id}/`)) {
|
||||
return NextResponse.json({ error: "s3Key invalide pour ce carbet" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingCount = await prisma.media.count({ where: { carbetId: carbet.id } });
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
carbetId: carbet.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 },
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "uploads",
|
||||
event: "media.finalize",
|
||||
target: media.id,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { carbetId: carbet.id, kind },
|
||||
});
|
||||
return NextResponse.json({ media });
|
||||
}
|
||||
55
src/app/api/uploads/presign/route.ts
Normal file
55
src/app/api/uploads/presign/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { presignCarbetUpload } from "@/lib/uploads";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
carbetId: 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, "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 carbet = await prisma.carbet.findUnique({
|
||||
where: { id: parsed.data.carbetId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||
const isOwner = carbet.ownerId === session.user.id;
|
||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await presignCarbetUpload({
|
||||
carbetId: carbet.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);
|
||||
}
|
||||
256
src/app/decouvrir/_components/ReelSlide.tsx
Normal file
256
src/app/decouvrir/_components/ReelSlide.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { ReelCarbet } from "@/lib/reels";
|
||||
|
||||
type Props = {
|
||||
carbet: ReelCarbet;
|
||||
isActive: boolean;
|
||||
shouldPreload: boolean;
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: () => void;
|
||||
};
|
||||
|
||||
export function ReelSlide({ carbet, isActive, shouldPreload, isFavorite, onToggleFavorite }: Props) {
|
||||
const [mediaIndex, setMediaIndex] = useState(0);
|
||||
const [muted, setMuted] = useState(true);
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const current = carbet.media[mediaIndex];
|
||||
|
||||
const nextMedia = useCallback(() => {
|
||||
setMediaIndex((i) => (i + 1) % carbet.media.length);
|
||||
}, [carbet.media.length]);
|
||||
const prevMedia = useCallback(() => {
|
||||
setMediaIndex((i) => (i - 1 + carbet.media.length) % carbet.media.length);
|
||||
}, [carbet.media.length]);
|
||||
|
||||
// Auto-play/pause vidéos quand slide active
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
if (isActive && current?.type === "VIDEO") {
|
||||
videoRef.current.play().catch(() => {});
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}, [isActive, current?.type, mediaIndex]);
|
||||
|
||||
// Reset au changement de slide (différé pour éviter cascading renders)
|
||||
useEffect(() => {
|
||||
if (isActive) return;
|
||||
queueMicrotask(() => setMediaIndex(0));
|
||||
}, [isActive]);
|
||||
|
||||
// Navigation clavier ← →
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea") return;
|
||||
if (e.key === "ArrowRight" || e.key === "l") {
|
||||
e.preventDefault();
|
||||
nextMedia();
|
||||
} else if (e.key === "ArrowLeft" || e.key === "h") {
|
||||
e.preventDefault();
|
||||
prevMedia();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [isActive, nextMedia, prevMedia]);
|
||||
|
||||
function onTouchStart(e: React.TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
touchStart.current = { x: t.clientX, y: t.clientY };
|
||||
}
|
||||
function onTouchEnd(e: React.TouchEvent) {
|
||||
if (!touchStart.current) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - touchStart.current.x;
|
||||
const dy = t.clientY - touchStart.current.y;
|
||||
touchStart.current = null;
|
||||
// Seuil horizontal > vertical pour considérer un swipe horizontal
|
||||
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.2) {
|
||||
if (dx < 0) nextMedia();
|
||||
else prevMedia();
|
||||
}
|
||||
}
|
||||
|
||||
const share = useCallback(async () => {
|
||||
const url = `${window.location.origin}/carbets/${carbet.slug}`;
|
||||
const title = carbet.title;
|
||||
if (navigator.share) {
|
||||
navigator.share({ title, url }).catch(() => {});
|
||||
} else {
|
||||
navigator.clipboard?.writeText(url).catch(() => {});
|
||||
}
|
||||
}, [carbet.slug, carbet.title]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full bg-black"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Média */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{current.type === "VIDEO" ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={shouldPreload ? current.url : undefined}
|
||||
data-src={current.url}
|
||||
muted={muted}
|
||||
playsInline
|
||||
loop
|
||||
preload={shouldPreload ? "auto" : "none"}
|
||||
className="h-full w-full object-cover"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={shouldPreload ? current.url : undefined}
|
||||
data-src={current.url}
|
||||
alt={`${carbet.title} — média ${mediaIndex + 1}`}
|
||||
loading={shouldPreload ? "eager" : "lazy"}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Voile dégradé en bas pour lisibilité */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-black/85 via-black/30 to-transparent" />
|
||||
|
||||
{/* Indicateurs progression médias (sticks en haut) */}
|
||||
{carbet.media.length > 1 ? (
|
||||
<div className="pointer-events-none absolute left-3 right-3 top-12 flex gap-1">
|
||||
{carbet.media.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={
|
||||
"h-0.5 flex-1 rounded-full " +
|
||||
(i === mediaIndex ? "bg-white" : i < mediaIndex ? "bg-white/60" : "bg-white/30")
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Zones tap horizontales (50/50) sur desktop */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevMedia}
|
||||
className="absolute inset-y-0 left-0 z-10 hidden w-1/3 cursor-default md:block"
|
||||
aria-label="Média précédent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextMedia}
|
||||
className="absolute inset-y-0 right-0 z-10 hidden w-1/3 cursor-default md:block"
|
||||
aria-label="Média suivant"
|
||||
/>
|
||||
|
||||
{/* Sidebar boutons droite (mobile) */}
|
||||
<div className="absolute bottom-32 right-3 z-20 flex flex-col items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFavorite}
|
||||
className="flex flex-col items-center text-white"
|
||||
aria-label={isFavorite ? "Retirer des favoris" : "Ajouter aux favoris"}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"flex h-12 w-12 items-center justify-center rounded-full backdrop-blur transition " +
|
||||
(isFavorite ? "bg-rose-500/90" : "bg-white/10 hover:bg-white/20")
|
||||
}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill={isFavorite ? "white" : "none"} stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] font-semibold">Favori</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={share}
|
||||
className="flex flex-col items-center text-white"
|
||||
aria-label="Partager"
|
||||
>
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/10 backdrop-blur hover:bg-white/20">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<path d="M8.59 13.51 L15.42 17.49" />
|
||||
<path d="M15.41 6.51 L8.59 10.49" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] font-semibold">Partager</span>
|
||||
</button>
|
||||
|
||||
{current.type === "VIDEO" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
className="flex flex-col items-center text-white"
|
||||
aria-label={muted ? "Activer le son" : "Couper le son"}
|
||||
>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 backdrop-blur hover:bg-white/20">
|
||||
{muted ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bloc info bas + CTAs */}
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 p-4 pb-6 text-white">
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<h2 className="text-lg font-semibold">{carbet.title}</h2>
|
||||
{carbet.averageRating !== null ? (
|
||||
<span className="text-xs text-white/80">
|
||||
★ {carbet.averageRating.toFixed(1)} ({carbet.reviewCount})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/80">
|
||||
<span>📍 {carbet.river}</span>
|
||||
<span>·</span>
|
||||
<span>👥 jusqu'à {carbet.capacity}</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono font-semibold text-white">{Number(carbet.nightlyPrice).toFixed(0)} € / nuit</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/carbets/${carbet.slug}`}
|
||||
className="rounded-full bg-white/10 px-4 py-2 text-xs font-semibold backdrop-blur hover:bg-white/20"
|
||||
>
|
||||
Voir la fiche
|
||||
</Link>
|
||||
<Link
|
||||
href={`/carbets/${carbet.slug}#reserver`}
|
||||
className="rounded-full bg-emerald-500 px-4 py-2 text-xs font-semibold hover:bg-emerald-400"
|
||||
>
|
||||
Réserver
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/app/decouvrir/_components/ReelsViewer.tsx
Normal file
139
src/app/decouvrir/_components/ReelsViewer.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { ReelCarbet } from "@/lib/reels";
|
||||
|
||||
import { ReelSlide } from "./ReelSlide";
|
||||
|
||||
type Props = {
|
||||
carbets: ReelCarbet[];
|
||||
initialFavoriteIds: string[];
|
||||
isAuthenticated: boolean;
|
||||
};
|
||||
|
||||
export function ReelsViewer({ carbets, initialFavoriteIds, isAuthenticated }: Props) {
|
||||
const router = useRouter();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const slideRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set(initialFavoriteIds));
|
||||
|
||||
// Détection du carbet actif via IntersectionObserver
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter((e) => e.isIntersecting);
|
||||
if (visible.length === 0) return;
|
||||
const best = visible.reduce((a, b) => (a.intersectionRatio > b.intersectionRatio ? a : b));
|
||||
const idx = slideRefs.current.findIndex((el) => el === best.target);
|
||||
if (idx !== -1) setActiveIndex(idx);
|
||||
},
|
||||
{ root: containerRef.current, threshold: [0.55, 0.85] },
|
||||
);
|
||||
slideRefs.current.forEach((el) => el && observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, [carbets.length]);
|
||||
|
||||
// Navigation clavier ↑↓
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea") return;
|
||||
if (e.key === "ArrowDown" || e.key === "j") {
|
||||
e.preventDefault();
|
||||
const next = Math.min(activeIndex + 1, carbets.length - 1);
|
||||
slideRefs.current[next]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} else if (e.key === "ArrowUp" || e.key === "k") {
|
||||
e.preventDefault();
|
||||
const prev = Math.max(activeIndex - 1, 0);
|
||||
slideRefs.current[prev]?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [activeIndex, carbets.length]);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
async (carbetId: string) => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(`/connexion?next=${encodeURIComponent("/decouvrir")}`);
|
||||
return;
|
||||
}
|
||||
const isFav = favorites.has(carbetId);
|
||||
// Optimistic update
|
||||
setFavorites((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isFav) next.delete(carbetId);
|
||||
else next.add(carbetId);
|
||||
return next;
|
||||
});
|
||||
const method = isFav ? "DELETE" : "POST";
|
||||
const res = await fetch("/api/favorites", {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carbetId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Rollback
|
||||
setFavorites((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isFav) next.add(carbetId);
|
||||
else next.delete(carbetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[favorites, isAuthenticated, router],
|
||||
);
|
||||
|
||||
// Préchargement N+1 et N-1 médias (un peu d'AGGRESSIVE prefetch)
|
||||
const preloadIndexes = useMemo(
|
||||
() => [activeIndex - 1, activeIndex, activeIndex + 1].filter((i) => i >= 0 && i < carbets.length),
|
||||
[activeIndex, carbets.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 top-12 z-10 bg-black">
|
||||
{/* Bouton retour catalogue */}
|
||||
<Link
|
||||
href="/carbets"
|
||||
className="absolute right-3 top-3 z-20 rounded-full bg-white/10 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur hover:bg-white/20"
|
||||
>
|
||||
← Catalogue
|
||||
</Link>
|
||||
|
||||
{/* Compteur */}
|
||||
<div className="absolute left-3 top-3 z-20 rounded-full bg-white/10 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur">
|
||||
{activeIndex + 1} / {carbets.length}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full snap-y snap-mandatory overflow-y-scroll overscroll-contain"
|
||||
style={{ scrollSnapType: "y mandatory" }}
|
||||
>
|
||||
{carbets.map((c, idx) => (
|
||||
<div
|
||||
key={c.id}
|
||||
ref={(el) => {
|
||||
slideRefs.current[idx] = el;
|
||||
}}
|
||||
className="h-full snap-start snap-always"
|
||||
style={{ scrollSnapAlign: "start" }}
|
||||
>
|
||||
<ReelSlide
|
||||
carbet={c}
|
||||
isActive={idx === activeIndex}
|
||||
shouldPreload={preloadIndexes.includes(idx)}
|
||||
isFavorite={favorites.has(c.id)}
|
||||
onToggleFavorite={() => toggleFavorite(c.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/app/decouvrir/page.tsx
Normal file
50
src/app/decouvrir/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { listReelCarbets } from "@/lib/reels";
|
||||
|
||||
import { ReelsViewer } from "./_components/ReelsViewer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = {
|
||||
title: "Au fil de l'eau",
|
||||
description: "Découvrez les carbets de Guyane façon Reels — swipez pour explorer.",
|
||||
};
|
||||
|
||||
export default async function DecouvrirPage() {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id ?? null;
|
||||
const [carbets, favoriteIds] = await Promise.all([
|
||||
listReelCarbets({ take: 30 }),
|
||||
userId
|
||||
? prisma.favorite.findMany({ where: { userId }, select: { carbetId: true } }).then((r) => r.map((x) => x.carbetId))
|
||||
: Promise.resolve([] as string[]),
|
||||
]);
|
||||
|
||||
if (carbets.length === 0) {
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-6 py-20 text-center">
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Au fil de l'eau</h1>
|
||||
<p className="mt-3 text-sm text-zinc-600">
|
||||
Pas encore assez de carbets avec des photos pour démarrer le mode immersif.
|
||||
</p>
|
||||
<Link
|
||||
href="/carbets"
|
||||
className="mt-6 inline-block rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Voir le catalogue
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReelsViewer
|
||||
carbets={carbets}
|
||||
initialFavoriteIds={favoriteIds}
|
||||
isAuthenticated={Boolean(userId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,11 +3,10 @@ import { notFound } from "next/navigation";
|
|||
|
||||
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isStorageConfigured } from "@/lib/storage";
|
||||
import { MediaUploader } from "@/components/MediaUploader";
|
||||
|
||||
import { updateCarbet } from "../actions";
|
||||
import { CarbetForm } from "../_components/carbet-form";
|
||||
import { MediaManager } from "../_components/media-manager";
|
||||
|
||||
export default async function EditCarbetPage({
|
||||
params,
|
||||
|
|
@ -36,7 +35,7 @@ export default async function EditCarbetPage({
|
|||
status: true,
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Url: true, sortOrder: true },
|
||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||
},
|
||||
amenities: { select: { amenity: { select: { key: true } } } },
|
||||
},
|
||||
|
|
@ -80,14 +79,10 @@ export default async function EditCarbetPage({
|
|||
<section className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-zinc-900">Médias</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-zinc-600">
|
||||
Le premier média sert de photo de couverture. Réordonnez avec les
|
||||
flèches.
|
||||
Déposez photos et vidéos courtes, réorganisez par glisser-déposer.
|
||||
Le premier média sert de cover sur le catalogue et la home.
|
||||
</p>
|
||||
<MediaManager
|
||||
carbetId={carbet.id}
|
||||
media={carbet.media}
|
||||
storageConfigured={isStorageConfigured()}
|
||||
/>
|
||||
<MediaUploader carbetId={carbet.id} initialMedia={carbet.media} />
|
||||
</section>
|
||||
|
||||
<section className="mt-10 border-t border-zinc-200 pt-8">
|
||||
|
|
|
|||
63
src/app/mes-favoris/page.tsx
Normal file
63
src/app/mes-favoris/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { listFavoriteCarbets } from "@/lib/reels";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = { title: "Mes favoris" };
|
||||
|
||||
export default async function MyFavoritesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/connexion?next=/mes-favoris");
|
||||
|
||||
const carbets = await listFavoriteCarbets(session.user.id);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Mes favoris</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
{carbets.length === 0
|
||||
? "Aucun favori pour l'instant — ajoutez des carbets depuis le mode Au fil de l'eau ou les fiches."
|
||||
: `${carbets.length} carbet${carbets.length > 1 ? "s" : ""} sauvegardé${carbets.length > 1 ? "s" : ""}.`}
|
||||
</p>
|
||||
|
||||
{carbets.length === 0 ? (
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href="/decouvrir"
|
||||
className="inline-block rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Découvrir des carbets
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{carbets.map((c) => (
|
||||
<li key={c.id} className="overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm">
|
||||
<Link href={`/carbets/${c.slug}`} className="block">
|
||||
{c.media[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={c.media[0].url}
|
||||
alt={c.title}
|
||||
className="aspect-video w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video w-full bg-zinc-100" />
|
||||
)}
|
||||
<div className="p-3">
|
||||
<h2 className="font-semibold text-zinc-900">{c.title}</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{c.river} · {Number(c.nightlyPrice).toFixed(0)} € / nuit
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,63 +1,9 @@
|
|||
import Link from "next/link";
|
||||
import { IfPluginEnabled } from "@/components/IfPluginEnabled";
|
||||
import { HeroSection } from "@/components/landing/HeroSection";
|
||||
import { ExperiencesSection } from "@/components/landing/ExperiencesSection";
|
||||
import { HowItWorksSection } from "@/components/landing/HowItWorksSection";
|
||||
import { CESection } from "@/components/landing/CESection";
|
||||
import { TestimonialsSection } from "@/components/landing/TestimonialsSection";
|
||||
import { LandingFooter } from "@/components/landing/Footer";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Page d'accueil — la majorité du contenu est conditionnée par les plugins :
|
||||
* - `landing-hero` → hero plein écran
|
||||
* - `landing-sections` → 2 expériences + comment ça marche + CE + témoignages + footer riche
|
||||
*
|
||||
* Si aucun de ces plugins n'est activé, on retombe sur la home historique
|
||||
* minimaliste (fallback). Activable depuis /admin/plugins.
|
||||
* Home redirige vers le mode immersif « Au fil de l'eau » par défaut.
|
||||
* L'ancien hero/landing reste accessible via /accueil.
|
||||
*/
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<IfPluginEnabled
|
||||
plugin="landing-hero"
|
||||
fallback={
|
||||
// Fallback héro minimaliste — historique
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-50 px-6 dark:bg-black">
|
||||
<main className="flex w-full max-w-2xl flex-col items-center gap-6 text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-black sm:text-5xl dark:text-zinc-50">
|
||||
Karbé — carbets fluviaux de Guyane
|
||||
</h1>
|
||||
<p className="max-w-xl text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
La marketplace pour louer des carbets le long des fleuves de Guyane.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/carbets"
|
||||
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Découvrir les carbets
|
||||
</Link>
|
||||
<Link
|
||||
href="/espace-hote"
|
||||
className="rounded-md border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-900"
|
||||
>
|
||||
Espace hôte
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HeroSection />
|
||||
</IfPluginEnabled>
|
||||
|
||||
<IfPluginEnabled plugin="landing-sections">
|
||||
<ExperiencesSection />
|
||||
<HowItWorksSection />
|
||||
<CESection />
|
||||
<TestimonialsSection />
|
||||
<LandingFooter />
|
||||
</IfPluginEnabled>
|
||||
</>
|
||||
);
|
||||
redirect("/decouvrir");
|
||||
}
|
||||
|
|
|
|||
380
src/components/MediaUploader.tsx
Normal file
380
src/components/MediaUploader.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
KeyboardSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
export type MediaItem = {
|
||||
id: string;
|
||||
type: "PHOTO" | "VIDEO";
|
||||
s3Url: string;
|
||||
s3Key: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
carbetId: string;
|
||||
initialMedia: MediaItem[];
|
||||
};
|
||||
|
||||
type UploadEntry = {
|
||||
tempId: string;
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
mime: string;
|
||||
progress: number;
|
||||
error?: string;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
const MAX_PARALLEL = 3;
|
||||
|
||||
export function MediaUploader({ carbetId, initialMedia }: Props) {
|
||||
const [items, setItems] = useState<MediaItem[]>(
|
||||
[...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
);
|
||||
const [uploads, setUploads] = useState<UploadEntry[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputId = useId();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const queueRef = useRef<File[]>([]);
|
||||
const activeRef = useRef(0);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const allIds = useMemo(() => items.map((i) => i.id), [items]);
|
||||
|
||||
const reorderOnServer = useCallback(
|
||||
async (orderedIds: string[]) => {
|
||||
await fetch("/api/media/reorder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carbetId, orderedIds }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
[carbetId],
|
||||
);
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
setItems((prev) => {
|
||||
const oldIdx = prev.findIndex((p) => p.id === active.id);
|
||||
const newIdx = prev.findIndex((p) => p.id === over.id);
|
||||
if (oldIdx < 0 || newIdx < 0) return prev;
|
||||
const next = arrayMove(prev, oldIdx, newIdx);
|
||||
reorderOnServer(next.map((m) => m.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const setCover = useCallback(
|
||||
(id: string) => {
|
||||
setItems((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === id);
|
||||
if (idx <= 0) return prev;
|
||||
const next = arrayMove(prev, idx, 0);
|
||||
reorderOnServer(next.map((m) => m.id));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[reorderOnServer],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(async (id: string) => {
|
||||
if (!confirm("Supprimer ce média ?")) return;
|
||||
const res = await fetch(`/api/media/${id}`, { method: "DELETE" });
|
||||
if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
|
||||
const processFile = useCallback(async function processFile(file: File): Promise<void> {
|
||||
const tempId = crypto.randomUUID();
|
||||
setUploads((u) => [
|
||||
...u,
|
||||
{ tempId, name: file.name, sizeBytes: file.size, mime: file.type, progress: 0, done: false },
|
||||
]);
|
||||
try {
|
||||
const presignRes = await fetch("/api/uploads/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ carbetId, mime: file.type, sizeBytes: file.size }),
|
||||
});
|
||||
const presign = await presignRes.json();
|
||||
if (!presignRes.ok) throw new Error(presign?.error || "presign refusé");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (ev) => {
|
||||
if (!ev.lengthComputable) return;
|
||||
const pct = Math.round((ev.loaded / ev.total) * 100);
|
||||
setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: pct } : x)));
|
||||
});
|
||||
xhr.addEventListener("load", () =>
|
||||
xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)),
|
||||
);
|
||||
xhr.addEventListener("error", () => reject(new Error("Réseau coupé")));
|
||||
xhr.open("PUT", presign.uploadUrl);
|
||||
xhr.setRequestHeader("Content-Type", file.type);
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
const finalizeRes = await fetch("/api/uploads/finalize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
carbetId,
|
||||
s3Key: presign.s3Key,
|
||||
s3Url: presign.publicUrl,
|
||||
mime: file.type,
|
||||
}),
|
||||
});
|
||||
const finalize = await finalizeRes.json();
|
||||
if (!finalizeRes.ok) throw new Error(finalize?.error || "finalize refusé");
|
||||
setItems((prev) => [...prev, finalize.media]);
|
||||
setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: 100, done: true } : x)));
|
||||
// Cleanup après 2s
|
||||
setTimeout(() => {
|
||||
setUploads((u) => u.filter((x) => x.tempId !== tempId));
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, error: msg } : x)));
|
||||
}
|
||||
}, [carbetId]);
|
||||
|
||||
const popQueueRef = useRef<() => void>(() => {});
|
||||
const popQueue = useCallback(() => {
|
||||
while (activeRef.current < MAX_PARALLEL && queueRef.current.length > 0) {
|
||||
const file = queueRef.current.shift()!;
|
||||
activeRef.current++;
|
||||
processFile(file).finally(() => {
|
||||
activeRef.current--;
|
||||
popQueueRef.current();
|
||||
});
|
||||
}
|
||||
}, [processFile]);
|
||||
useEffect(() => {
|
||||
popQueueRef.current = popQueue;
|
||||
}, [popQueue]);
|
||||
|
||||
function addFiles(files: FileList | File[]) {
|
||||
const arr = Array.from(files);
|
||||
queueRef.current.push(...arr);
|
||||
popQueue();
|
||||
}
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (e.target.files) addFiles(e.target.files);
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files) addFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
// Permet le coller depuis presse-papier
|
||||
useEffect(() => {
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
if (!e.clipboardData?.files?.length) return;
|
||||
addFiles(e.clipboardData.files);
|
||||
}
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={
|
||||
"rounded-lg border-2 border-dashed p-4 text-center transition " +
|
||||
(dragging
|
||||
? "border-emerald-500 bg-emerald-50"
|
||||
: "border-zinc-300 bg-zinc-50 hover:border-zinc-400")
|
||||
}
|
||||
>
|
||||
<label htmlFor={inputId} className="block cursor-pointer">
|
||||
<div className="text-sm text-zinc-700">
|
||||
<strong>Déposez vos photos ou vidéos</strong> ici, ou cliquez pour parcourir
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
JPG / PNG / WebP / AVIF (max 10 Mo) · MP4 / MOV / WebM (max 200 Mo) · plusieurs fichiers OK
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/avif,video/mp4,video/quicktime,video/webm"
|
||||
multiple
|
||||
capture="environment"
|
||||
onChange={onChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploads.length > 0 ? (
|
||||
<ul className="space-y-1.5">
|
||||
{uploads.map((u) => (
|
||||
<li key={u.tempId} className="rounded border border-zinc-200 bg-white px-3 py-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate font-medium text-zinc-700">{u.name}</span>
|
||||
<span className="ml-2 text-zinc-500">
|
||||
{u.error
|
||||
? "❌"
|
||||
: u.done
|
||||
? "✓"
|
||||
: `${Math.round(u.sizeBytes / 1000)} ko · ${u.progress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-zinc-200">
|
||||
<div
|
||||
className={
|
||||
"h-full transition-all " +
|
||||
(u.error ? "bg-rose-500" : u.done ? "bg-emerald-500" : "bg-emerald-600")
|
||||
}
|
||||
style={{ width: `${u.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{u.error ? <div className="mt-1 text-rose-700">{u.error}</div> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={allIds} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
||||
{items.map((item, idx) => (
|
||||
<SortableTile
|
||||
key={item.id}
|
||||
item={item}
|
||||
isCover={idx === 0}
|
||||
onSetCover={() => setCover(item.id)}
|
||||
onDelete={() => removeItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<p className="rounded border border-dashed border-zinc-200 px-3 py-6 text-center text-xs text-zinc-500">
|
||||
Pas encore de média. Ajoutez votre premier ci-dessus.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
Glissez-déposez pour réordonner · Étoile = cover (image principale sur le catalogue)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTile({
|
||||
item,
|
||||
isCover,
|
||||
onSetCover,
|
||||
onDelete,
|
||||
}: {
|
||||
item: MediaItem;
|
||||
isCover: boolean;
|
||||
onSetCover: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: item.id,
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={
|
||||
"group relative overflow-hidden rounded-md border bg-zinc-100 " +
|
||||
(isCover ? "border-emerald-500 ring-2 ring-emerald-300" : "border-zinc-200")
|
||||
}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="aspect-square w-full cursor-grab touch-none active:cursor-grabbing">
|
||||
{item.type === "VIDEO" ? (
|
||||
<video
|
||||
src={item.s3Url}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="h-full w-full bg-black object-cover"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.s3Url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isCover ? (
|
||||
<span className="absolute left-1 top-1 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
|
||||
Cover
|
||||
</span>
|
||||
) : null}
|
||||
<span className="pointer-events-none absolute right-1 top-1 rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
|
||||
{item.type}
|
||||
</span>
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-gradient-to-t from-black/70 to-transparent p-1.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{!isCover ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSetCover}
|
||||
className="rounded bg-white/90 px-2 py-0.5 text-[10px] font-semibold text-zinc-900 hover:bg-white"
|
||||
title="Définir comme cover"
|
||||
>
|
||||
★ Cover
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded bg-rose-600 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-rose-700"
|
||||
title="Supprimer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,20 +27,23 @@ export async function SiteHeader() {
|
|||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-5 text-sm text-zinc-700 sm:flex">
|
||||
<Link href="/decouvrir" className="hover:text-zinc-900">
|
||||
Au fil de l'eau
|
||||
</Link>
|
||||
<Link href="/carbets" className="hover:text-zinc-900">
|
||||
Carbets
|
||||
Catalogue
|
||||
</Link>
|
||||
<Link href="/comment-ca-marche" className="hover:text-zinc-900">
|
||||
Comment ça marche
|
||||
</Link>
|
||||
<Link href="/pour-comites-entreprise" className="hover:text-zinc-900">
|
||||
Comités d'entreprise
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{u ? (
|
||||
<>
|
||||
<Link href="/mes-favoris" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Favoris
|
||||
</Link>
|
||||
<Link href="/mes-reservations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Mes réservations
|
||||
</Link>
|
||||
|
|
|
|||
127
src/lib/reels.ts
Normal file
127
src/lib/reels.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import "server-only";
|
||||
|
||||
import { CarbetStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type ReelMedia = {
|
||||
id: string;
|
||||
type: "PHOTO" | "VIDEO";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ReelCarbet = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
river: string;
|
||||
embarkPoint: string;
|
||||
capacity: number;
|
||||
nightlyPrice: string;
|
||||
ownerFirstName: string;
|
||||
averageRating: number | null;
|
||||
reviewCount: number;
|
||||
media: ReelMedia[];
|
||||
};
|
||||
|
||||
export async function listReelCarbets(opts: { take?: number } = {}): Promise<ReelCarbet[]> {
|
||||
const take = opts.take ?? 30;
|
||||
const rows = await prisma.carbet.findMany({
|
||||
where: {
|
||||
status: CarbetStatus.PUBLISHED,
|
||||
media: { some: {} }, // au moins 1 média
|
||||
},
|
||||
orderBy: [{ lastBookedAt: { sort: "desc", nulls: "last" } }, { updatedAt: "desc" }],
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
river: true,
|
||||
embarkPoint: true,
|
||||
capacity: true,
|
||||
nightlyPrice: true,
|
||||
owner: { select: { firstName: true } },
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Url: true },
|
||||
},
|
||||
reviews: { select: { rating: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return rows.map((c) => {
|
||||
const ratings = c.reviews.map((r) => r.rating);
|
||||
const avg = ratings.length === 0 ? null : ratings.reduce((a, b) => a + b, 0) / ratings.length;
|
||||
return {
|
||||
id: c.id,
|
||||
slug: c.slug,
|
||||
title: c.title,
|
||||
river: c.river,
|
||||
embarkPoint: c.embarkPoint,
|
||||
capacity: c.capacity,
|
||||
nightlyPrice: c.nightlyPrice.toString(),
|
||||
ownerFirstName: c.owner.firstName,
|
||||
averageRating: avg,
|
||||
reviewCount: ratings.length,
|
||||
media: c.media.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type as "PHOTO" | "VIDEO",
|
||||
url: m.s3Url,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function listFavoriteCarbets(userId: string): Promise<ReelCarbet[]> {
|
||||
const favs = await prisma.favorite.findMany({
|
||||
where: { userId },
|
||||
select: { carbetId: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (favs.length === 0) return [];
|
||||
const ids = favs.map((f) => f.carbetId);
|
||||
const rows = await prisma.carbet.findMany({
|
||||
where: { id: { in: ids }, status: CarbetStatus.PUBLISHED },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
river: true,
|
||||
embarkPoint: true,
|
||||
capacity: true,
|
||||
nightlyPrice: true,
|
||||
owner: { select: { firstName: true } },
|
||||
media: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, type: true, s3Url: true },
|
||||
},
|
||||
reviews: { select: { rating: true } },
|
||||
},
|
||||
});
|
||||
// Respecter l'ordre des favoris (le plus récent en premier)
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
return ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter((r): r is NonNullable<typeof r> => Boolean(r))
|
||||
.map((c) => {
|
||||
const ratings = c.reviews.map((r) => r.rating);
|
||||
const avg = ratings.length === 0 ? null : ratings.reduce((a, b) => a + b, 0) / ratings.length;
|
||||
return {
|
||||
id: c.id,
|
||||
slug: c.slug,
|
||||
title: c.title,
|
||||
river: c.river,
|
||||
embarkPoint: c.embarkPoint,
|
||||
capacity: c.capacity,
|
||||
nightlyPrice: c.nightlyPrice.toString(),
|
||||
ownerFirstName: c.owner.firstName,
|
||||
averageRating: avg,
|
||||
reviewCount: ratings.length,
|
||||
media: c.media.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type as "PHOTO" | "VIDEO",
|
||||
url: m.s3Url,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
104
src/lib/uploads.ts
Normal file
104
src/lib/uploads.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import "server-only";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
const ENDPOINT = process.env.S3_ENDPOINT ?? "";
|
||||
const PUBLIC_BASE = process.env.S3_PUBLIC_URL ?? "";
|
||||
const BUCKET = process.env.S3_BUCKET ?? "";
|
||||
const REGION = process.env.S3_REGION ?? "us-east-1";
|
||||
const ACCESS_KEY = process.env.MINIO_ROOT_USER ?? process.env.S3_ACCESS_KEY ?? "";
|
||||
const SECRET_KEY = process.env.MINIO_ROOT_PASSWORD ?? process.env.S3_SECRET_KEY ?? "";
|
||||
|
||||
const PUBLIC_BASE_EXTERNAL =
|
||||
process.env.S3_PUBLIC_URL_EXTERNAL ?? PUBLIC_BASE;
|
||||
const ENDPOINT_EXTERNAL = process.env.S3_ENDPOINT_EXTERNAL ?? ENDPOINT;
|
||||
|
||||
const s3Internal = new S3Client({
|
||||
endpoint: ENDPOINT,
|
||||
region: REGION,
|
||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
|
||||
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||||
});
|
||||
|
||||
const s3Presign = new S3Client({
|
||||
endpoint: ENDPOINT_EXTERNAL,
|
||||
region: REGION,
|
||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? "false") === "true",
|
||||
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||||
});
|
||||
|
||||
export type PresignResult = {
|
||||
s3Key: string;
|
||||
uploadUrl: string;
|
||||
publicUrl: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
const ALLOWED_PHOTO_MIMES = new Set(["image/jpeg", "image/png", "image/webp", "image/avif"]);
|
||||
const ALLOWED_VIDEO_MIMES = new Set(["video/mp4", "video/quicktime", "video/webm"]);
|
||||
|
||||
export type UploadKind = "photo" | "video";
|
||||
|
||||
export function classifyMime(mime: string): UploadKind | null {
|
||||
if (ALLOWED_PHOTO_MIMES.has(mime)) return "photo";
|
||||
if (ALLOWED_VIDEO_MIMES.has(mime)) return "video";
|
||||
return null;
|
||||
}
|
||||
|
||||
const MAX_PHOTO = 10 * 1024 * 1024;
|
||||
const MAX_VIDEO = 200 * 1024 * 1024;
|
||||
|
||||
export function maxBytesFor(kind: UploadKind): number {
|
||||
return kind === "photo" ? MAX_PHOTO : MAX_VIDEO;
|
||||
}
|
||||
|
||||
export function extensionFor(mime: string): string {
|
||||
switch (mime) {
|
||||
case "image/jpeg":
|
||||
return "jpg";
|
||||
case "image/png":
|
||||
return "png";
|
||||
case "image/webp":
|
||||
return "webp";
|
||||
case "image/avif":
|
||||
return "avif";
|
||||
case "video/mp4":
|
||||
return "mp4";
|
||||
case "video/quicktime":
|
||||
return "mov";
|
||||
case "video/webm":
|
||||
return "webm";
|
||||
default:
|
||||
return "bin";
|
||||
}
|
||||
}
|
||||
|
||||
export async function presignCarbetUpload(opts: {
|
||||
carbetId: 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 = `carbets/${opts.carbetId}/${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