diff --git a/.env.example b/.env.example index bd44558..5e08691 100644 --- a/.env.example +++ b/.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" diff --git a/src/app/api/carbets/[carbetId]/media/route.ts b/src/app/api/carbets/[carbetId]/media/route.ts new file mode 100644 index 0000000..9661048 --- /dev/null +++ b/src/app/api/carbets/[carbetId]/media/route.ts @@ -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 }, + ); +} diff --git a/src/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx new file mode 100644 index 0000000..93768b1 --- /dev/null +++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx @@ -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 ( +
+ + ← Mes carbets + +

+ {carbet.title} +

+ + {publishError ? ( +

+ Ajoutez au moins un média avant de publier ce carbet. +

+ ) : null} + +
+

Médias

+

+ Le premier média sert de photo de couverture. Réordonnez avec les + flèches. +

+ +
+ +
+ +
+
+ ); +} diff --git a/src/app/espace-hote/carbets/_components/carbet-form.tsx b/src/app/espace-hote/carbets/_components/carbet-form.tsx new file mode 100644 index 0000000..ac2d234 --- /dev/null +++ b/src/app/espace-hote/carbets/_components/carbet-form.tsx @@ -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; + mode: "create" | "edit"; + carbetId?: string; + defaults?: Partial; + 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

{message}

; +} + +export function CarbetForm({ + action, + mode, + carbetId, + defaults = {}, + submitLabel, +}: CarbetFormProps) { + const [state, formAction, pending] = useActionState( + action, + EMPTY_FORM_STATE, + ); + const selectedAmenities = new Set(defaults.amenityKeys ?? []); + + return ( +
+ {carbetId ? ( + + ) : null} + + {state.errors._global ? ( +

+ {state.errors._global} +

+ ) : null} + {state.ok && state.message ? ( +

+ {state.message} +

+ ) : null} + +
+

Présentation

+
+ + + +
+
+ +