Før implementering av flere omtaler i badger i kart
This commit is contained in:
parent
5a1944b216
commit
cf0f049ea6
4 changed files with 348 additions and 2 deletions
BIN
2026-04-20 09.00.46 teeoff.no d8d72ce0bea9.jpg
Normal file
BIN
2026-04-20 09.00.46 teeoff.no d8d72ce0bea9.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 på 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue