Før implementering av flere omtaler i badger i kart

This commit is contained in:
Erol Haagenrud 2026-04-20 09:05:33 +02:00
parent 5a1944b216
commit cf0f049ea6
4 changed files with 348 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

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

View file

@ -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<string | null>(null);
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
const logoImageInputRef = useRef<HTMLInputElement | null>(null);
const galleryInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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' && (
<div className="flex flex-col">
<input
ref={mainImageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => handleSingleImageUpload('image_url', event)}
/>
<input
ref={logoImageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => handleSingleImageUpload('logo_url', event)}
/>
<input
ref={galleryInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleGalleryUpload}
/>
<div className="mb-8 rounded-[2rem] border border-[#8bc34a]/30 bg-[#8bc34a]/10 p-6 md:p-8">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-sm font-black uppercase tracking-widest text-[#11280f]">Anleggsbilder</h3>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#435340]">
Last opp AVIF-optimaliserte bilder direkte fra admin. Du kan også fjerne koblingen til et bilde uten å slette selve filen fra serveren.
</p>
</div>
{mediaFeedback ? (
<div className="rounded-2xl bg-white/90 px-4 py-3 text-sm font-bold text-[#11280f] shadow-sm">
{mediaFeedback}
</div>
) : null}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-2">
<div className="rounded-[1.75rem] border border-[#11280f]/10 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Hovedbilde</p>
<p className="mt-1 text-sm text-[#536256]">Brukes som hovedbilde baneprofilen og som fallback i galleri.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => mainImageInputRef.current?.click()}
disabled={uploadingTarget === 'image_url'}
className="btn btn-sm btn-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{uploadingTarget === 'image_url' ? 'Laster opp...' : 'Last opp'}
</button>
<button
type="button"
onClick={() => handleChange('image_url', '')}
disabled={!getValue('image_url', 'text')}
className="btn btn-sm btn-danger disabled:cursor-not-allowed disabled:opacity-50"
>
Fjern
</button>
</div>
</div>
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]">
<div className="aspect-[16/10]">
{getValue('image_url', 'text') ? (
<img src={getValue('image_url', 'text')} alt={`${initialData.name} hovedbilde`} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-white/70">
Ingen hovedbilde valgt
</div>
)}
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Bilde-URL</label>
<input
className="rounded-2xl border-2 border-gray-300 bg-white p-4 text-base font-bold text-black shadow-sm outline-none focus:border-[#8bc34a]"
value={getValue('image_url', 'text')}
onChange={e => handleChange('image_url', e.target.value)}
/>
</div>
</div>
<div className="rounded-[1.75rem] border border-[#11280f]/10 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Logo</p>
<p className="mt-1 text-sm text-[#536256]">Vises i baneprofilen når klubben har egen logo.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => logoImageInputRef.current?.click()}
disabled={uploadingTarget === 'logo_url'}
className="btn btn-sm btn-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{uploadingTarget === 'logo_url' ? 'Laster opp...' : 'Last opp'}
</button>
<button
type="button"
onClick={() => handleChange('logo_url', '')}
disabled={!getValue('logo_url', 'text')}
className="btn btn-sm btn-danger disabled:cursor-not-allowed disabled:opacity-50"
>
Fjern
</button>
</div>
</div>
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#f6f7f3]">
<div className="aspect-square max-w-[240px]">
{getValue('logo_url', 'text') ? (
<img src={getValue('logo_url', 'text')} alt={`${initialData.name} logo`} className="h-full w-full object-contain p-4" />
) : (
<div className="flex h-full items-center justify-center px-6 text-center text-sm font-black uppercase tracking-[0.14em] text-[#11280f]/45">
Ingen logo valgt
</div>
)}
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Logo-URL</label>
<input
className="rounded-2xl border-2 border-gray-300 bg-white p-4 text-base font-bold text-black shadow-sm outline-none focus:border-[#8bc34a]"
value={getValue('logo_url', 'text')}
onChange={e => handleChange('logo_url', e.target.value)}
/>
</div>
</div>
</div>
<div className="mt-6 rounded-[1.75rem] border border-[#11280f]/10 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Galleri</p>
<p className="mt-1 text-sm text-[#536256]">Bildene roteres i toppen av baneprofilen. Du kan endre rekkefølge, fjerne dem eller bruke et galleribilde som hovedbilde.</p>
</div>
<button
type="button"
onClick={() => galleryInputRef.current?.click()}
disabled={uploadingTarget === 'gallery'}
className="btn btn-sm btn-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{uploadingTarget === 'gallery' ? 'Laster opp...' : 'Last opp til galleri'}
</button>
</div>
{galleryImages.length === 0 ? (
<div className="mt-4 rounded-[1.5rem] border border-dashed border-[#11280f]/12 bg-[#F7F9F2] px-4 py-5 text-sm font-bold text-[#536256]">
Ingen galleribilder lagt til ennå.
</div>
) : (
<div className="mt-4 grid gap-4">
{galleryImages.map((url, index) => (
<div key={`${url}-${index}`} className="rounded-[1.5rem] border border-[#112015]/8 bg-[#FCFDF9] p-4">
<div className="grid gap-4 lg:grid-cols-[220px,minmax(0,1fr)]">
<div className="overflow-hidden rounded-[1.25rem] border border-[#112015]/8 bg-[#112015]">
<div className="aspect-[4/3]">
<img src={url} alt={`${initialData.name} galleri ${index + 1}`} className="h-full w-full object-cover" />
</div>
</div>
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Galleribilde {index + 1}</p>
{getValue('image_url', 'text') === url ? (
<p className="mt-1 text-sm font-black text-[#FF5722]">Brukes også som hovedbilde</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleChange('image_url', url)}
className="btn btn-sm btn-secondary"
>
Bruk som hovedbilde
</button>
<button
type="button"
onClick={() => moveGalleryImage(index, -1)}
disabled={index === 0}
className="btn btn-sm btn-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
Opp
</button>
<button
type="button"
onClick={() => moveGalleryImage(index, 1)}
disabled={index === galleryImages.length - 1}
className="btn btn-sm btn-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
Ned
</button>
<button
type="button"
onClick={() => removeGalleryImage(index)}
className="btn btn-sm btn-danger"
>
Fjern
</button>
</div>
</div>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">URL</span>
<input
value={url}
onChange={(event) => updateGalleryImage(index, event.target.value)}
className="rounded-[1.1rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
</label>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Nettside URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('website_url', 'text')} onChange={e => handleChange('website_url', e.target.value)} /></div>
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Golfbox Booking URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('golfbox_booking_url', 'text')} onChange={e => handleChange('golfbox_booking_url', e.target.value)} /></div>
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Golfbox Turnering URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('golfbox_tournament_url', 'text')} onChange={e => handleChange('golfbox_tournament_url', e.target.value)} /></div>

View file

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