Merge pull request 'CRUD Carbet (propriétaire) + pages connexion/espace-hote' (#7) from feat/owner-carbet-crud into main

Merge PR#7: CRUD Carbet + interface propriétaire
This commit is contained in:
tarzzan 2026-05-29 22:14:58 +00:00
commit 3567eb975b
14 changed files with 1546 additions and 0 deletions

View file

@ -5,3 +5,17 @@ DATABASE_URL="postgresql://user:password@localhost:5432/karbe?schema=public"
# Secret pour NextAuth (à générer, ex: `openssl rand -base64 32`).
NEXTAUTH_SECRET="changeme"
AUTH_SECRET="changeme"
# Stockage objet des médias (S3 ou MinIO). Compatible AWS S3 et MinIO.
# Pour MinIO en local : renseignez S3_ENDPOINT (ex: http://localhost:9000)
# et laissez S3_FORCE_PATH_STYLE à "true".
S3_ENDPOINT="http://localhost:9000"
S3_REGION="us-east-1"
S3_BUCKET="karbe-medias"
S3_ACCESS_KEY_ID="changeme"
S3_SECRET_ACCESS_KEY="changeme"
# URL publique de base servant les objets (CDN ou bucket public).
# Laissez vide pour dériver l'URL depuis S3_ENDPOINT + bucket.
S3_PUBLIC_URL=""
S3_FORCE_PATH_STYLE="true"

View file

@ -0,0 +1,128 @@
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "@/auth";
import { canManageCarbet } from "@/lib/carbet-access";
import {
buildMediaKey,
humanFileSize,
maxBytesForType,
mediaTypeForMime,
} from "@/lib/media";
import { prisma } from "@/lib/prisma";
import { isStorageConfigured, putObject } from "@/lib/storage";
import { UserRole } from "@/generated/prisma/enums";
export const runtime = "nodejs";
type UploadError = { name: string; error: string };
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ carbetId: string }> },
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
}
if (session.user.role !== UserRole.OWNER && session.user.role !== UserRole.ADMIN) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
const { carbetId } = await params;
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true },
});
if (!carbet) {
return NextResponse.json({ error: "Carbet introuvable." }, { status: 404 });
}
if (!canManageCarbet(session, carbet.ownerId)) {
return NextResponse.json({ error: "Accès refusé." }, { status: 403 });
}
if (!isStorageConfigured()) {
return NextResponse.json(
{
error:
"Le stockage des médias (S3/MinIO) n'est pas configuré sur le serveur.",
},
{ status: 503 },
);
}
const formData = await request.formData();
const files = formData
.getAll("files")
.filter((entry): entry is File => entry instanceof File);
if (files.length === 0) {
return NextResponse.json(
{ error: "Aucun fichier reçu." },
{ status: 400 },
);
}
const lastMedia = await prisma.media.findFirst({
where: { carbetId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
let nextOrder = (lastMedia?.sortOrder ?? -1) + 1;
const created: Array<{
id: string;
type: string;
s3Url: string;
sortOrder: number;
}> = [];
const errors: UploadError[] = [];
for (const file of files) {
const type = mediaTypeForMime(file.type);
if (!type) {
errors.push({ name: file.name, error: `Type non supporté (${file.type}).` });
continue;
}
const maxBytes = maxBytesForType(type);
if (file.size > maxBytes) {
errors.push({
name: file.name,
error: `Fichier trop volumineux (max ${humanFileSize(maxBytes)}).`,
});
continue;
}
try {
const key = buildMediaKey(carbetId, file.type);
const buffer = Buffer.from(await file.arrayBuffer());
const url = await putObject(key, buffer, file.type);
const media = await prisma.media.create({
data: {
carbetId,
type,
s3Key: key,
s3Url: url,
sortOrder: nextOrder,
},
select: { id: true, type: true, s3Url: true, sortOrder: true },
});
nextOrder += 1;
created.push(media);
} catch (error) {
console.error("Échec de l'upload média", error);
errors.push({ name: file.name, error: "Échec de l'envoi vers le stockage." });
}
}
if (created.length > 0) {
revalidatePath(`/espace-hote/carbets/${carbetId}`);
}
return NextResponse.json(
{ created, errors },
{ status: created.length > 0 ? 201 : 400 },
);
}

View file

@ -0,0 +1,104 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
import { prisma } from "@/lib/prisma";
import { isStorageConfigured } from "@/lib/storage";
import { updateCarbet } from "../actions";
import { CarbetForm } from "../_components/carbet-form";
import { MediaManager } from "../_components/media-manager";
export default async function EditCarbetPage({
params,
searchParams,
}: {
params: Promise<{ carbetId: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const session = await requireOwnerSession();
const { carbetId } = await params;
const { publishError } = await searchParams;
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: {
id: true,
ownerId: true,
title: true,
description: true,
river: true,
latitude: true,
longitude: true,
embarkPoint: true,
pirogueDurationMin: true,
capacity: true,
status: true,
media: {
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Url: true, sortOrder: true },
},
amenities: { select: { amenity: { select: { key: true } } } },
},
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
notFound();
}
const defaults = {
title: carbet.title,
description: carbet.description,
river: carbet.river,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
embarkPoint: carbet.embarkPoint,
pirogueDurationMin: String(carbet.pirogueDurationMin),
capacity: String(carbet.capacity),
status: carbet.status,
amenityKeys: carbet.amenities.map((entry) => entry.amenity.key),
};
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link
href="/espace-hote/carbets"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Mes carbets
</Link>
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
{carbet.title}
</h1>
{publishError ? (
<p className="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-800">
Ajoutez au moins un média avant de publier ce carbet.
</p>
) : null}
<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.
</p>
<MediaManager
carbetId={carbet.id}
media={carbet.media}
storageConfigured={isStorageConfigured()}
/>
</section>
<section className="mt-10 border-t border-zinc-200 pt-8">
<CarbetForm
action={updateCarbet}
mode="edit"
carbetId={carbet.id}
defaults={defaults}
submitLabel="Enregistrer les modifications"
/>
</section>
</main>
);
}

View file

@ -0,0 +1,286 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { AMENITY_CATALOG } from "@/lib/amenities";
import { CarbetStatus } from "@/generated/prisma/enums";
import { EMPTY_FORM_STATE, type CarbetFormState } from "../form-types";
export type CarbetFormDefaults = {
title: string;
description: string;
river: string;
latitude: string;
longitude: string;
embarkPoint: string;
pirogueDurationMin: string;
capacity: string;
status: CarbetStatus;
amenityKeys: string[];
};
type CarbetFormProps = {
action: (
state: CarbetFormState,
formData: FormData,
) => Promise<CarbetFormState>;
mode: "create" | "edit";
carbetId?: string;
defaults?: Partial<CarbetFormDefaults>;
submitLabel: string;
};
const inputClass =
"mt-1 block w-full rounded-md border border-zinc-300 px-3 py-2 text-sm shadow-sm focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500";
const labelClass = "block text-sm font-medium text-zinc-800";
const errorClass = "mt-1 text-xs text-red-600";
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className={errorClass}>{message}</p>;
}
export function CarbetForm({
action,
mode,
carbetId,
defaults = {},
submitLabel,
}: CarbetFormProps) {
const [state, formAction, pending] = useActionState<CarbetFormState, FormData>(
action,
EMPTY_FORM_STATE,
);
const selectedAmenities = new Set(defaults.amenityKeys ?? []);
return (
<form action={formAction} className="space-y-8">
{carbetId ? (
<input type="hidden" name="carbetId" value={carbetId} />
) : null}
{state.errors._global ? (
<p className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700">
{state.errors._global}
</p>
) : null}
{state.ok && state.message ? (
<p className="rounded-md bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{state.message}
</p>
) : null}
<section className="space-y-4">
<h2 className="text-lg font-semibold text-zinc-900">Présentation</h2>
<div>
<label className={labelClass} htmlFor="title">
Titre du carbet
</label>
<input
id="title"
name="title"
type="text"
defaultValue={defaults.title}
className={inputClass}
placeholder="Carbet du Haut-Maroni"
required
/>
<FieldError message={state.errors.title} />
</div>
<div>
<label className={labelClass} htmlFor="description">
Description
</label>
<textarea
id="description"
name="description"
defaultValue={defaults.description}
rows={6}
className={inputClass}
placeholder="Décrivez le carbet, son environnement, l'expérience proposée…"
required
/>
<FieldError message={state.errors.description} />
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold text-zinc-900">Localisation</h2>
<div>
<label className={labelClass} htmlFor="river">
Rivière / fleuve
</label>
<input
id="river"
name="river"
type="text"
defaultValue={defaults.river}
className={inputClass}
placeholder="Maroni, Approuague, Oyapock…"
required
/>
<FieldError message={state.errors.river} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass} htmlFor="latitude">
Latitude
</label>
<input
id="latitude"
name="latitude"
type="number"
step="any"
defaultValue={defaults.latitude}
className={inputClass}
placeholder="4.938"
required
/>
<FieldError message={state.errors.latitude} />
</div>
<div>
<label className={labelClass} htmlFor="longitude">
Longitude
</label>
<input
id="longitude"
name="longitude"
type="number"
step="any"
defaultValue={defaults.longitude}
className={inputClass}
placeholder="-52.330"
required
/>
<FieldError message={state.errors.longitude} />
</div>
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold text-zinc-900">Accès pirogue</h2>
<div>
<label className={labelClass} htmlFor="embarkPoint">
Point d&apos;embarquement
</label>
<input
id="embarkPoint"
name="embarkPoint"
type="text"
defaultValue={defaults.embarkPoint}
className={inputClass}
placeholder="Dégrad de Maripasoula"
required
/>
<FieldError message={state.errors.embarkPoint} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass} htmlFor="pirogueDurationMin">
Durée du trajet en pirogue (minutes)
</label>
<input
id="pirogueDurationMin"
name="pirogueDurationMin"
type="number"
min={0}
max={1440}
step={1}
defaultValue={defaults.pirogueDurationMin}
className={inputClass}
placeholder="45"
required
/>
<FieldError message={state.errors.pirogueDurationMin} />
</div>
<div>
<label className={labelClass} htmlFor="capacity">
Capacité (personnes)
</label>
<input
id="capacity"
name="capacity"
type="number"
min={1}
max={100}
step={1}
defaultValue={defaults.capacity}
className={inputClass}
placeholder="6"
required
/>
<FieldError message={state.errors.capacity} />
</div>
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold text-zinc-900">Commodités</h2>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{AMENITY_CATALOG.map((amenity) => (
<label
key={amenity.key}
className="flex items-center gap-2 rounded-md border border-zinc-200 px-3 py-2 text-sm"
>
<input
type="checkbox"
name="amenities"
value={amenity.key}
defaultChecked={selectedAmenities.has(amenity.key)}
className="h-4 w-4 rounded border-zinc-300 text-emerald-600 focus:ring-emerald-500"
/>
<span>{amenity.label}</span>
</label>
))}
</div>
</section>
{mode === "edit" ? (
<section className="space-y-2">
<h2 className="text-lg font-semibold text-zinc-900">Publication</h2>
<label className={labelClass} htmlFor="status">
Statut
</label>
<select
id="status"
name="status"
defaultValue={defaults.status ?? CarbetStatus.DRAFT}
className={inputClass}
>
<option value={CarbetStatus.DRAFT}>Brouillon</option>
<option value={CarbetStatus.PUBLISHED}>Publié</option>
<option value={CarbetStatus.ARCHIVED}>Archivé</option>
</select>
<FieldError message={state.errors.status} />
</section>
) : (
<>
<input type="hidden" name="status" value={CarbetStatus.DRAFT} />
<p className="text-sm text-zinc-500">
Le carbet sera créé en brouillon. Vous pourrez ajouter des médias
puis le publier.
</p>
<FieldError message={state.errors.status} />
</>
)}
<div className="flex items-center gap-4 border-t border-zinc-200 pt-6">
<button
type="submit"
disabled={pending}
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
<Link
href="/espace-hote/carbets"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Annuler
</Link>
</div>
</form>
);
}

View file

@ -0,0 +1,199 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ACCEPTED_MIME_TYPES } from "@/lib/media";
import { deleteMedia, reorderMedia } from "../actions";
export type MediaItem = {
id: string;
type: string;
s3Url: string;
sortOrder: number;
};
type UploadError = { name: string; error: string };
export function MediaManager({
carbetId,
media,
storageConfigured,
}: {
carbetId: string;
media: MediaItem[];
storageConfigured: boolean;
}) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [errors, setErrors] = useState<UploadError[]>([]);
const [isPending, startTransition] = useTransition();
const busy = uploading || isPending;
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const input = event.currentTarget;
const files = input.files;
if (!files || files.length === 0) return;
const formData = new FormData();
Array.from(files).forEach((file) => formData.append("files", file));
setUploading(true);
setErrors([]);
try {
const response = await fetch(`/api/carbets/${carbetId}/media`, {
method: "POST",
body: formData,
});
const data = await response.json().catch(() => ({}));
if (!response.ok && !(data.created?.length > 0)) {
setErrors(
data.errors?.length
? data.errors
: [{ name: "", error: data.error ?? "Échec de l'envoi." }],
);
} else if (data.errors?.length) {
setErrors(data.errors);
}
router.refresh();
} catch {
setErrors([{ name: "", error: "Erreur réseau lors de l'envoi." }]);
} finally {
setUploading(false);
input.value = "";
}
}
function handleMove(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= media.length) return;
const ids = media.map((m) => m.id);
[ids[index], ids[target]] = [ids[target], ids[index]];
startTransition(async () => {
await reorderMedia(carbetId, ids);
router.refresh();
});
}
function handleDelete(id: string) {
startTransition(async () => {
await deleteMedia(id);
router.refresh();
});
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={busy || !storageConfigured}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{uploading ? "Envoi en cours…" : "Ajouter des photos / vidéos"}
</button>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME_TYPES.join(",")}
multiple
hidden
onChange={handleUpload}
/>
<span className="text-xs text-zinc-500">
Images (JPEG, PNG, WebP, AVIF) et vidéos (MP4, WebM, MOV).
</span>
</div>
{!storageConfigured ? (
<p className="rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-800">
Le stockage des médias n&apos;est pas configuré sur le serveur
(variables S3_*). L&apos;envoi est désactivé.
</p>
) : null}
{errors.length > 0 ? (
<ul className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700">
{errors.map((err, index) => (
<li key={`${err.name}-${index}`}>
{err.name ? `${err.name} : ` : ""}
{err.error}
</li>
))}
</ul>
) : null}
{media.length === 0 ? (
<p className="rounded-md border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
Aucun média pour le moment. Ajoutez au moins une photo pour pouvoir
publier ce carbet.
</p>
) : (
<ol className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{media.map((item, index) => (
<li
key={item.id}
className="overflow-hidden rounded-lg border border-zinc-200 bg-white"
>
<div className="relative aspect-video bg-zinc-100">
{item.type === "VIDEO" ? (
<video
src={item.s3Url}
controls
className="h-full w-full object-cover"
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.s3Url}
alt=""
className="h-full w-full object-cover"
/>
)}
{index === 0 ? (
<span className="absolute left-2 top-2 rounded bg-emerald-600 px-2 py-0.5 text-xs font-medium text-white">
Couverture
</span>
) : null}
</div>
<div className="flex items-center justify-between gap-1 px-2 py-2">
<div className="flex gap-1">
<button
type="button"
onClick={() => handleMove(index, -1)}
disabled={busy || index === 0}
aria-label="Déplacer vers le haut"
className="rounded border border-zinc-300 px-2 py-1 text-xs disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => handleMove(index, 1)}
disabled={busy || index === media.length - 1}
aria-label="Déplacer vers le bas"
className="rounded border border-zinc-300 px-2 py-1 text-xs disabled:opacity-40"
>
</button>
</div>
<button
type="button"
onClick={() => handleDelete(item.id)}
disabled={busy}
className="rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:opacity-40"
>
Supprimer
</button>
</div>
</li>
))}
</ol>
)}
</div>
);
}

View file

@ -0,0 +1,360 @@
"use server";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import { amenityLabel, isKnownAmenityKey } from "@/lib/amenities";
import { canManageCarbet, requireOwnerSession } from "@/lib/carbet-access";
import { prisma } from "@/lib/prisma";
import { ensureUniqueCarbetSlug } from "@/lib/slug";
import { deleteObject } from "@/lib/storage";
import { Prisma } from "@/generated/prisma/client";
import { CarbetStatus } from "@/generated/prisma/enums";
import type { CarbetFormState } from "./form-types";
type ParsedCarbet = {
title: string;
description: string;
river: string;
latitude: string;
longitude: string;
embarkPoint: string;
pirogueDurationMin: number;
capacity: number;
status: CarbetStatus;
amenities: string[];
};
function isCarbetStatus(value: string): value is CarbetStatus {
return (Object.values(CarbetStatus) as string[]).includes(value);
}
function parseCarbetForm(formData: FormData): {
data: ParsedCarbet;
errors: Record<string, string>;
} {
const errors: Record<string, string> = {};
const title = String(formData.get("title") ?? "").trim();
const description = String(formData.get("description") ?? "").trim();
const river = String(formData.get("river") ?? "").trim();
const latitudeRaw = String(formData.get("latitude") ?? "").trim();
const longitudeRaw = String(formData.get("longitude") ?? "").trim();
const embarkPoint = String(formData.get("embarkPoint") ?? "").trim();
const pirogueRaw = String(formData.get("pirogueDurationMin") ?? "").trim();
const capacityRaw = String(formData.get("capacity") ?? "").trim();
const statusRaw = String(formData.get("status") ?? CarbetStatus.DRAFT).trim();
const amenities = formData
.getAll("amenities")
.map((value) => String(value))
.filter(isKnownAmenityKey);
if (title.length < 3) {
errors.title = "Le titre doit contenir au moins 3 caractères.";
} else if (title.length > 120) {
errors.title = "Le titre ne peut pas dépasser 120 caractères.";
}
if (description.length < 20) {
errors.description =
"La description doit contenir au moins 20 caractères.";
}
if (!river) {
errors.river = "Indiquez la rivière ou le fleuve.";
}
const latitude = Number(latitudeRaw);
if (!latitudeRaw || Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
errors.latitude = "Latitude invalide (entre -90 et 90).";
}
const longitude = Number(longitudeRaw);
if (
!longitudeRaw ||
Number.isNaN(longitude) ||
longitude < -180 ||
longitude > 180
) {
errors.longitude = "Longitude invalide (entre -180 et 180).";
}
if (!embarkPoint) {
errors.embarkPoint = "Indiquez le point d'embarquement en pirogue.";
}
const pirogueDurationMin = Number(pirogueRaw);
if (
!pirogueRaw ||
!Number.isInteger(pirogueDurationMin) ||
pirogueDurationMin < 0 ||
pirogueDurationMin > 1440
) {
errors.pirogueDurationMin =
"Durée du trajet pirogue invalide (0 à 1440 minutes).";
}
const capacity = Number(capacityRaw);
if (
!capacityRaw ||
!Number.isInteger(capacity) ||
capacity < 1 ||
capacity > 100
) {
errors.capacity = "Capacité invalide (1 à 100 personnes).";
}
const status = isCarbetStatus(statusRaw) ? statusRaw : CarbetStatus.DRAFT;
return {
data: {
title,
description,
river,
latitude: latitudeRaw,
longitude: longitudeRaw,
embarkPoint,
pirogueDurationMin,
capacity,
status,
amenities,
},
errors,
};
}
async function syncAmenities(
tx: Prisma.TransactionClient,
carbetId: string,
keys: string[],
): Promise<void> {
const uniqueKeys = Array.from(new Set(keys));
const amenityIds: string[] = [];
for (const key of uniqueKeys) {
const amenity = await tx.amenity.upsert({
where: { key },
update: {},
create: { key, label: amenityLabel(key) },
select: { id: true },
});
amenityIds.push(amenity.id);
}
await tx.carbetAmenity.deleteMany({ where: { carbetId } });
if (amenityIds.length > 0) {
await tx.carbetAmenity.createMany({
data: amenityIds.map((amenityId) => ({ carbetId, amenityId })),
skipDuplicates: true,
});
}
}
export async function createCarbet(
_prevState: CarbetFormState,
formData: FormData,
): Promise<CarbetFormState> {
const session = await requireOwnerSession();
const { data, errors } = parseCarbetForm(formData);
if (data.status === CarbetStatus.PUBLISHED) {
// A brand-new carbet has no media yet, so it cannot be published directly.
errors.status =
"Enregistrez d'abord le carbet, ajoutez des médias, puis publiez-le.";
}
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
const slug = await ensureUniqueCarbetSlug(data.title);
const carbet = await prisma.$transaction(async (tx) => {
const created = await tx.carbet.create({
data: {
ownerId: session.user.id,
title: data.title,
slug,
description: data.description,
river: data.river,
latitude: data.latitude,
longitude: data.longitude,
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
status: CarbetStatus.DRAFT,
},
select: { id: true },
});
await syncAmenities(tx, created.id, data.amenities);
return created;
});
revalidatePath("/espace-hote/carbets");
redirect(`/espace-hote/carbets/${carbet.id}`);
}
export async function updateCarbet(
_prevState: CarbetFormState,
formData: FormData,
): Promise<CarbetFormState> {
const session = await requireOwnerSession();
const carbetId = String(formData.get("carbetId") ?? "");
const existing = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, _count: { select: { media: true } } },
});
if (!existing || !canManageCarbet(session, existing.ownerId)) {
return {
ok: false,
errors: { _global: "Carbet introuvable ou accès refusé." },
};
}
const { data, errors } = parseCarbetForm(formData);
if (
data.status === CarbetStatus.PUBLISHED &&
existing._count.media === 0
) {
errors.status = "Ajoutez au moins un média avant de publier ce carbet.";
}
if (Object.keys(errors).length > 0) {
return { ok: false, errors };
}
await prisma.$transaction(async (tx) => {
await tx.carbet.update({
where: { id: carbetId },
data: {
title: data.title,
description: data.description,
river: data.river,
latitude: data.latitude,
longitude: data.longitude,
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
status: data.status,
},
});
await syncAmenities(tx, carbetId, data.amenities);
});
revalidatePath("/espace-hote/carbets");
revalidatePath(`/espace-hote/carbets/${carbetId}`);
return { ok: true, errors: {}, message: "Carbet enregistré." };
}
export async function setCarbetStatus(formData: FormData): Promise<void> {
const session = await requireOwnerSession();
const carbetId = String(formData.get("carbetId") ?? "");
const statusRaw = String(formData.get("status") ?? "");
if (!isCarbetStatus(statusRaw)) {
notFound();
}
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, _count: { select: { media: true } } },
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
notFound();
}
if (statusRaw === CarbetStatus.PUBLISHED && carbet._count.media === 0) {
redirect(`/espace-hote/carbets/${carbetId}?publishError=1`);
}
await prisma.carbet.update({
where: { id: carbetId },
data: { status: statusRaw },
});
revalidatePath("/espace-hote/carbets");
revalidatePath(`/espace-hote/carbets/${carbetId}`);
redirect("/espace-hote/carbets");
}
export async function deleteCarbet(formData: FormData): Promise<void> {
const session = await requireOwnerSession();
const carbetId = String(formData.get("carbetId") ?? "");
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, media: { select: { s3Key: true } } },
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
notFound();
}
await Promise.allSettled(
carbet.media.map((media) => deleteObject(media.s3Key)),
);
// Media and amenity joins cascade-delete via the schema relations.
await prisma.carbet.delete({ where: { id: carbetId } });
revalidatePath("/espace-hote/carbets");
redirect("/espace-hote/carbets");
}
export async function reorderMedia(
carbetId: string,
orderedMediaIds: string[],
): Promise<{ ok: boolean }> {
const session = await requireOwnerSession();
const carbet = await prisma.carbet.findUnique({
where: { id: carbetId },
select: { ownerId: true, media: { select: { id: true } } },
});
if (!carbet || !canManageCarbet(session, carbet.ownerId)) {
return { ok: false };
}
const validIds = new Set(carbet.media.map((m) => m.id));
const sanitized = orderedMediaIds.filter((id) => validIds.has(id));
await prisma.$transaction(
sanitized.map((id, index) =>
prisma.media.update({
where: { id },
data: { sortOrder: index },
}),
),
);
revalidatePath(`/espace-hote/carbets/${carbetId}`);
return { ok: true };
}
export async function deleteMedia(
mediaId: string,
): Promise<{ ok: boolean }> {
const session = await requireOwnerSession();
const media = await prisma.media.findUnique({
where: { id: mediaId },
select: { s3Key: true, carbetId: true, carbet: { select: { ownerId: true } } },
});
if (!media || !canManageCarbet(session, media.carbet.ownerId)) {
return { ok: false };
}
await deleteObject(media.s3Key);
await prisma.media.delete({ where: { id: mediaId } });
revalidatePath(`/espace-hote/carbets/${media.carbetId}`);
return { ok: true };
}

View file

@ -0,0 +1,7 @@
export type CarbetFormState = {
ok: boolean;
errors: Record<string, string>;
message?: string;
};
export const EMPTY_FORM_STATE: CarbetFormState = { ok: false, errors: {} };

View file

@ -0,0 +1,36 @@
import Link from "next/link";
import { requireOwnerSession } from "@/lib/carbet-access";
import { createCarbet } from "../actions";
import { CarbetForm } from "../_components/carbet-form";
export default async function NewCarbetPage() {
await requireOwnerSession();
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link
href="/espace-hote/carbets"
className="text-sm text-zinc-600 hover:text-zinc-900"
>
Mes carbets
</Link>
<h1 className="mt-2 text-3xl font-semibold text-zinc-900">
Nouveau carbet
</h1>
<p className="mt-1 text-sm text-zinc-600">
Renseignez les informations principales. Vous ajouterez les médias à
l&apos;étape suivante.
</p>
<div className="mt-8">
<CarbetForm
action={createCarbet}
mode="create"
submitLabel="Créer le carbet"
/>
</div>
</main>
);
}

View file

@ -0,0 +1,142 @@
import Link from "next/link";
import { requireOwnerSession } from "@/lib/carbet-access";
import { prisma } from "@/lib/prisma";
import { CarbetStatus } from "@/generated/prisma/enums";
import { deleteCarbet, setCarbetStatus } from "./actions";
const STATUS_LABELS: Record<CarbetStatus, string> = {
[CarbetStatus.DRAFT]: "Brouillon",
[CarbetStatus.PUBLISHED]: "Publié",
[CarbetStatus.ARCHIVED]: "Archivé",
};
const STATUS_STYLES: Record<CarbetStatus, string> = {
[CarbetStatus.DRAFT]: "bg-zinc-100 text-zinc-700",
[CarbetStatus.PUBLISHED]: "bg-emerald-100 text-emerald-800",
[CarbetStatus.ARCHIVED]: "bg-amber-100 text-amber-800",
};
export default async function CarbetsListPage() {
const session = await requireOwnerSession();
const carbets = await prisma.carbet.findMany({
where: { ownerId: session.user.id },
orderBy: { updatedAt: "desc" },
select: {
id: true,
title: true,
river: true,
status: true,
updatedAt: true,
_count: { select: { media: true } },
},
});
return (
<main className="mx-auto max-w-4xl px-6 py-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold text-zinc-900">Mes carbets</h1>
<p className="mt-1 text-sm text-zinc-600">
Gérez vos annonces, leurs médias et leur statut de publication.
</p>
</div>
<Link
href="/espace-hote/carbets/nouveau"
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Nouveau carbet
</Link>
</div>
{carbets.length === 0 ? (
<p className="mt-10 rounded-md border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Vous n&apos;avez pas encore de carbet. Créez votre première annonce
pour commencer.
</p>
) : (
<ul className="mt-8 space-y-4">
{carbets.map((carbet) => (
<li
key={carbet.id}
className="flex flex-col gap-4 rounded-lg border border-zinc-200 bg-white p-5 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex items-center gap-3">
<Link
href={`/espace-hote/carbets/${carbet.id}`}
className="truncate text-lg font-medium text-zinc-900 hover:text-emerald-700"
>
{carbet.title}
</Link>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_STYLES[carbet.status]}`}
>
{STATUS_LABELS[carbet.status]}
</span>
</div>
<p className="mt-1 text-sm text-zinc-500">
{carbet.river} · {carbet._count.media} média
{carbet._count.media > 1 ? "s" : ""}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Link
href={`/espace-hote/carbets/${carbet.id}`}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Éditer
</Link>
{carbet.status !== CarbetStatus.PUBLISHED ? (
<form action={setCarbetStatus}>
<input type="hidden" name="carbetId" value={carbet.id} />
<input
type="hidden"
name="status"
value={CarbetStatus.PUBLISHED}
/>
<button
type="submit"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
>
Publier
</button>
</form>
) : (
<form action={setCarbetStatus}>
<input type="hidden" name="carbetId" value={carbet.id} />
<input
type="hidden"
name="status"
value={CarbetStatus.DRAFT}
/>
<button
type="submit"
className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Dépublier
</button>
</form>
)}
<form action={deleteCarbet}>
<input type="hidden" name="carbetId" value={carbet.id} />
<button
type="submit"
className="rounded-md border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
>
Supprimer
</button>
</form>
</div>
</li>
))}
</ul>
)}
</main>
);
}

37
src/lib/amenities.ts Normal file
View file

@ -0,0 +1,37 @@
export type AmenityDefinition = {
key: string;
label: string;
description?: string;
};
// Catalogue de commodités adapté aux carbets fluviaux de Guyane.
// Les lignes Amenity sont créées à la demande (upsert par `key`) lors de
// l'enregistrement d'un carbet, ce qui évite un script de seed dédié.
export const AMENITY_CATALOG: AmenityDefinition[] = [
{ key: "hamac", label: "Hamacs fournis" },
{ key: "moustiquaire", label: "Moustiquaires" },
{ key: "eau_potable", label: "Eau potable" },
{ key: "electricite_solaire", label: "Électricité solaire" },
{ key: "groupe_electrogene", label: "Groupe électrogène" },
{ key: "toilettes_seches", label: "Toilettes sèches" },
{ key: "douche", label: "Douche" },
{ key: "cuisine_equipee", label: "Cuisine équipée" },
{ key: "rechaud_gaz", label: "Réchaud à gaz" },
{ key: "glaciere", label: "Glacière / réfrigérateur" },
{ key: "carbet_couvert", label: "Carbet couvert" },
{ key: "baignade_riviere", label: "Baignade en rivière" },
{ key: "materiel_peche", label: "Matériel de pêche" },
{ key: "kayak_canoe", label: "Kayak / canoë" },
{ key: "barbecue", label: "Barbecue / foyer" },
{ key: "guide_local", label: "Guide local disponible" },
];
const CATALOG_BY_KEY = new Map(AMENITY_CATALOG.map((a) => [a.key, a]));
export function isKnownAmenityKey(key: string): boolean {
return CATALOG_BY_KEY.has(key);
}
export function amenityLabel(key: string): string {
return CATALOG_BY_KEY.get(key)?.label ?? key;
}

21
src/lib/carbet-access.ts Normal file
View file

@ -0,0 +1,21 @@
import type { Session } from "next-auth";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
const MANAGER_ROLES: UserRole[] = [UserRole.OWNER, UserRole.ADMIN];
// Owner area (espace hôte) — accessible to carbet owners and admins.
export async function requireOwnerSession(): Promise<Session> {
return requireRole(MANAGER_ROLES);
}
// A user can manage a given carbet if they own it, or if they are an admin.
export function canManageCarbet(
session: Session,
carbetOwnerId: string,
): boolean {
return (
session.user.role === UserRole.ADMIN || session.user.id === carbetOwnerId
);
}

54
src/lib/media.ts Normal file
View file

@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto";
import { MediaType } from "@/generated/prisma/enums";
export const MAX_PHOTO_BYTES = 10 * 1024 * 1024; // 10 Mo
export const MAX_VIDEO_BYTES = 200 * 1024 * 1024; // 200 Mo
const PHOTO_MIME: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/avif": "avif",
};
const VIDEO_MIME: Record<string, string> = {
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
};
export const ACCEPTED_MIME_TYPES = [
...Object.keys(PHOTO_MIME),
...Object.keys(VIDEO_MIME),
];
export function mediaTypeForMime(mime: string): MediaType | null {
if (mime in PHOTO_MIME) return MediaType.PHOTO;
if (mime in VIDEO_MIME) return MediaType.VIDEO;
return null;
}
export function extensionForMime(mime: string): string {
return PHOTO_MIME[mime] ?? VIDEO_MIME[mime] ?? "bin";
}
export function maxBytesForType(type: MediaType): number {
return type === MediaType.VIDEO ? MAX_VIDEO_BYTES : MAX_PHOTO_BYTES;
}
export function buildMediaKey(carbetId: string, mime: string): string {
return `carbets/${carbetId}/${randomUUID()}.${extensionForMime(mime)}`;
}
export function humanFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
const units = ["Ko", "Mo", "Go"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}

38
src/lib/slug.ts Normal file
View file

@ -0,0 +1,38 @@
import { prisma } from "@/lib/prisma";
export function slugify(input: string): string {
const base = input
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return base || "carbet";
}
export async function ensureUniqueCarbetSlug(
source: string,
excludeId?: string,
): Promise<string> {
const root = slugify(source);
let candidate = root;
let suffix = 1;
// Loop until we find a slug not used by another carbet.
for (;;) {
const existing = await prisma.carbet.findFirst({
where: {
slug: candidate,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
select: { id: true },
});
if (!existing) {
return candidate;
}
suffix += 1;
candidate = `${root}-${suffix}`;
}
}

120
src/lib/storage.ts Normal file
View file

@ -0,0 +1,120 @@
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
type StorageConfig = {
endpoint?: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
publicUrl?: string;
forcePathStyle: boolean;
};
export class StorageNotConfiguredError extends Error {
constructor() {
super(
"Le stockage objet (S3/MinIO) n'est pas configuré. Renseignez les variables S3_* dans votre environnement.",
);
this.name = "StorageNotConfiguredError";
}
}
function readConfig(): StorageConfig | null {
const bucket = process.env.S3_BUCKET;
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
if (!bucket || !accessKeyId || !secretAccessKey) {
return null;
}
const endpoint = process.env.S3_ENDPOINT || undefined;
// MinIO and most S3-compatible servers require path-style addressing.
// Default to path-style whenever a custom endpoint is set, unless opted out.
const forcePathStyle = endpoint
? process.env.S3_FORCE_PATH_STYLE !== "false"
: process.env.S3_FORCE_PATH_STYLE === "true";
return {
endpoint,
region: process.env.S3_REGION || "us-east-1",
bucket,
accessKeyId,
secretAccessKey,
publicUrl: process.env.S3_PUBLIC_URL || undefined,
forcePathStyle,
};
}
let cachedClient: S3Client | null = null;
function getClient(config: StorageConfig): S3Client {
if (!cachedClient) {
cachedClient = new S3Client({
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
}
return cachedClient;
}
export function isStorageConfigured(): boolean {
return readConfig() !== null;
}
function buildPublicUrl(config: StorageConfig, key: string): string {
const trim = (value: string) => value.replace(/\/+$/, "");
if (config.publicUrl) {
return `${trim(config.publicUrl)}/${key}`;
}
if (config.endpoint) {
return config.forcePathStyle
? `${trim(config.endpoint)}/${config.bucket}/${key}`
: `${trim(config.endpoint).replace("://", `://${config.bucket}.`)}/${key}`;
}
return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
}
export async function putObject(
key: string,
body: Buffer | Uint8Array,
contentType: string,
): Promise<string> {
const config = readConfig();
if (!config) {
throw new StorageNotConfiguredError();
}
await getClient(config).send(
new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: body,
ContentType: contentType,
}),
);
return buildPublicUrl(config, key);
}
export async function deleteObject(key: string): Promise<void> {
const config = readConfig();
if (!config) {
// Nothing we can do without storage credentials; treat as a no-op so the
// database row can still be removed.
return;
}
await getClient(config).send(
new DeleteObjectCommand({ Bucket: config.bucket, Key: key }),
);
}