diff --git a/2026-04-20 09.00.46 teeoff.no d8d72ce0bea9.jpg b/2026-04-20 09.00.46 teeoff.no d8d72ce0bea9.jpg new file mode 100644 index 0000000..2d40e7a Binary files /dev/null and b/2026-04-20 09.00.46 teeoff.no d8d72ce0bea9.jpg differ diff --git a/backend/main.py b/backend/main.py index d2f8504..b462aaf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2776,6 +2776,7 @@ async def update_facility_full(facility_id: int, request: Request): 'address', 'zipcode', 'city', 'county', 'lat', 'lng', 'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url', 'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url', + 'image_url', 'logo_url', 'front_image_url', 'gallery', 'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee', 'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data', 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', diff --git a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx index b459cc9..549c3ae 100644 --- a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx +++ b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState } from 'react'; +import { useRef, useState, type ChangeEvent } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { adminFetch } from "@/config/adminFetch"; @@ -377,12 +377,38 @@ const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any ); }; +const normalizeStringList = (value: any): string[] => { + if (Array.isArray(value)) { + return Array.from(new Set(value.map((entry) => String(entry || "").trim()).filter(Boolean))); + } + + if (typeof value === 'string') { + try { + return normalizeStringList(JSON.parse(value)); + } catch { + return []; + } + } + + return []; +}; + +const getMediaFieldLabel = (field: string) => { + if (field === 'image_url') return 'hovedbildet'; + if (field === 'logo_url') return 'logoen'; + return 'bildet'; +}; export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) { const router = useRouter(); const [formData, setFormData] = useState(initialData); const [activeTab, setActiveTab] = useState('generelt'); const [saving, setSaving] = useState(false); + const [mediaFeedback, setMediaFeedback] = useState(""); + const [uploadingTarget, setUploadingTarget] = useState(null); + const mainImageInputRef = useRef(null); + const logoImageInputRef = useRef(null); + const galleryInputRef = useRef(null); // Trekk ut unike arkitekter fra alle anlegg const uniqueArchitects = Array.from(new Set(allFacilities.map(f => f.architect).filter(Boolean))).sort(); @@ -397,6 +423,98 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini setFormData((prev: any) => ({ ...prev, [field]: value })); }; + const galleryImages = normalizeStringList(formData.gallery); + + const setGalleryImages = (images: string[]) => { + handleChange('gallery', Array.from(new Set(images.map((entry) => String(entry || "").trim()).filter(Boolean)))); + }; + + const updateGalleryImage = (index: number, value: string) => { + const nextGallery = [...galleryImages]; + nextGallery[index] = value; + setGalleryImages(nextGallery); + }; + + const removeGalleryImage = (index: number) => { + setGalleryImages(galleryImages.filter((_, currentIndex) => currentIndex !== index)); + }; + + const moveGalleryImage = (index: number, direction: -1 | 1) => { + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= galleryImages.length) return; + + const nextGallery = [...galleryImages]; + const [item] = nextGallery.splice(index, 1); + nextGallery.splice(nextIndex, 0, item); + setGalleryImages(nextGallery); + }; + + const uploadFacilityImage = async (file: File) => { + const payload = new FormData(); + payload.append("file", file); + payload.append("folder", "facilities"); + + const response = await adminFetch("/api/admin/uploads/images", { + method: "POST", + body: payload, + credentials: "include", + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Kunne ikke laste opp bildet." })); + throw new Error(error.detail || "Kunne ikke laste opp bildet."); + } + + const result = (await response.json()) as { url?: string }; + if (!result.url) { + throw new Error("Uploaden returnerte ingen bildeadresse."); + } + + return result.url; + }; + + const handleSingleImageUpload = async (field: 'image_url' | 'logo_url', event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + + if (!file) return; + + setUploadingTarget(field); + setMediaFeedback(""); + + try { + const url = await uploadFacilityImage(file); + handleChange(field, url); + setMediaFeedback(`Lastet opp ${getMediaFieldLabel(field)}.`); + } catch (error) { + setMediaFeedback(error instanceof Error ? error.message : "Kunne ikke laste opp bildet."); + } finally { + setUploadingTarget(null); + } + }; + + const handleGalleryUpload = async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + event.target.value = ""; + + if (files.length === 0) return; + + setUploadingTarget('gallery'); + setMediaFeedback(""); + + try { + const uploadedUrls = await Promise.all(files.map((file) => uploadFacilityImage(file))); + setGalleryImages([...galleryImages, ...uploadedUrls]); + setMediaFeedback(`Lastet opp ${uploadedUrls.length} galleribilde${uploadedUrls.length === 1 ? "" : "r"}.`); + } catch (error) { + setMediaFeedback(error instanceof Error ? error.message : "Kunne ikke laste opp galleribilder."); + } finally { + setUploadingTarget(null); + } + }; + const handleSave = async () => { setSaving(true); try { @@ -556,6 +674,227 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini {activeTab === 'linker' && (
+ handleSingleImageUpload('image_url', event)} + /> + handleSingleImageUpload('logo_url', event)} + /> + + +
+
+
+

Anleggsbilder

+

+ Last opp AVIF-optimaliserte bilder direkte fra admin. Du kan også fjerne koblingen til et bilde uten å slette selve filen fra serveren. +

+
+ {mediaFeedback ? ( +
+ {mediaFeedback} +
+ ) : null} +
+ +
+
+
+
+

Hovedbilde

+

Brukes som hovedbilde på baneprofilen og som fallback i galleri.

+
+
+ + +
+
+
+
+ {getValue('image_url', 'text') ? ( + {`${initialData.name} + ) : ( +
+ Ingen hovedbilde valgt +
+ )} +
+
+
+ + handleChange('image_url', e.target.value)} + /> +
+
+ +
+
+
+

Logo

+

Vises i baneprofilen når klubben har egen logo.

+
+
+ + +
+
+
+
+ {getValue('logo_url', 'text') ? ( + {`${initialData.name} + ) : ( +
+ Ingen logo valgt +
+ )} +
+
+
+ + handleChange('logo_url', e.target.value)} + /> +
+
+
+ +
+
+
+

Galleri

+

Bildene roteres i toppen av baneprofilen. Du kan endre rekkefølge, fjerne dem eller bruke et galleribilde som hovedbilde.

+
+ +
+ + {galleryImages.length === 0 ? ( +
+ Ingen galleribilder lagt til ennå. +
+ ) : ( +
+ {galleryImages.map((url, index) => ( +
+
+
+
+ {`${initialData.name} +
+
+
+
+
+

Galleribilde {index + 1}

+ {getValue('image_url', 'text') === url ? ( +

Brukes også som hovedbilde

+ ) : null} +
+
+ + + + +
+
+ + +
+
+
+ ))} +
+ )} +
+
+
handleChange('website_url', e.target.value)} />
handleChange('golfbox_booking_url', e.target.value)} />
handleChange('golfbox_tournament_url', e.target.value)} />
diff --git a/frontend/src/app/api/admin/uploads/images/route.ts b/frontend/src/app/api/admin/uploads/images/route.ts index 41c5817..7475e46 100644 --- a/frontend/src/app/api/admin/uploads/images/route.ts +++ b/frontend/src/app/api/admin/uploads/images/route.ts @@ -17,6 +17,11 @@ const ALLOWED_MIME_TYPES = new Set([ export const runtime = "nodejs"; +function resolveUploadFolder(value: FormDataEntryValue | null) { + const normalized = String(value || "articles").trim().toLowerCase(); + return normalized === "facilities" ? "facilities" : "articles"; +} + function sanitizeFilenameStem(filename: string) { const stem = path.parse(filename).name; const normalized = stem @@ -38,6 +43,7 @@ export async function POST(request: Request) { try { const formData = await request.formData(); const file = formData.get("file"); + const uploadFolder = resolveUploadFolder(formData.get("folder")); if (!(file instanceof File)) { return NextResponse.json({ detail: "Fant ingen bildefil i requesten." }, { status: 400 }); @@ -68,7 +74,7 @@ export async function POST(request: Request) { const dateSegment = new Date().toISOString().slice(0, 10); const safeName = sanitizeFilenameStem(file.name); const filename = `${safeName}-${randomUUID()}.avif`; - const relativeDirectory = path.join("uploads", "articles", dateSegment); + const relativeDirectory = path.join("uploads", uploadFolder, dateSegment); const absoluteDirectory = path.join(process.cwd(), "public", relativeDirectory); const absolutePath = path.join(absoluteDirectory, filename);