feat: Au fil de l'eau (Reels) + uploader pro + favoris #65

Merged
tarzzan merged 1 commit from feat/au-fil-de-leau into main 2026-06-02 00:27:19 +00:00
20 changed files with 1569 additions and 72 deletions

74
package-lock.json generated
View file

@ -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",

View file

@ -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",

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

View file

@ -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
View 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&apos;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>
</>
);
}

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

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

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

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

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

View 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&apos;à {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>
);
}

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

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

View file

@ -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">

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

View file

@ -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");
}

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

View file

@ -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&apos;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&apos;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
View 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
View 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 };