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:
commit
3567eb975b
14 changed files with 1546 additions and 0 deletions
14
.env.example
14
.env.example
|
|
@ -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"
|
||||
|
|
|
|||
128
src/app/api/carbets/[carbetId]/media/route.ts
Normal file
128
src/app/api/carbets/[carbetId]/media/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
104
src/app/espace-hote/carbets/[carbetId]/page.tsx
Normal file
104
src/app/espace-hote/carbets/[carbetId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
src/app/espace-hote/carbets/_components/carbet-form.tsx
Normal file
286
src/app/espace-hote/carbets/_components/carbet-form.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
199
src/app/espace-hote/carbets/_components/media-manager.tsx
Normal file
199
src/app/espace-hote/carbets/_components/media-manager.tsx
Normal 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'est pas configuré sur le serveur
|
||||
(variables S3_*). L'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>
|
||||
);
|
||||
}
|
||||
360
src/app/espace-hote/carbets/actions.ts
Normal file
360
src/app/espace-hote/carbets/actions.ts
Normal 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 };
|
||||
}
|
||||
7
src/app/espace-hote/carbets/form-types.ts
Normal file
7
src/app/espace-hote/carbets/form-types.ts
Normal 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: {} };
|
||||
36
src/app/espace-hote/carbets/nouveau/page.tsx
Normal file
36
src/app/espace-hote/carbets/nouveau/page.tsx
Normal 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'étape suivante.
|
||||
</p>
|
||||
|
||||
<div className="mt-8">
|
||||
<CarbetForm
|
||||
action={createCarbet}
|
||||
mode="create"
|
||||
submitLabel="Créer le carbet"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
142
src/app/espace-hote/carbets/page.tsx
Normal file
142
src/app/espace-hote/carbets/page.tsx
Normal 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'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
37
src/lib/amenities.ts
Normal 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
21
src/lib/carbet-access.ts
Normal 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
54
src/lib/media.ts
Normal 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
38
src/lib/slug.ts
Normal 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
120
src/lib/storage.ts
Normal 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 }),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue