Før redigering av baner
This commit is contained in:
parent
137328d795
commit
d9dca0ce6a
3 changed files with 311 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
/**
|
/**
|
||||||
* TEE OFF ADMIN DASHBOARD v1.8 - RESPONSIVT MED AI-HVISKER, KILL SWITCH & FILTER
|
* TEE OFF ADMIN DASHBOARD v1.9 - RESPONSIVT MED AI-HVISKER, KILL SWITCH, FILTER & FULL EDIT
|
||||||
* ---------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------
|
||||||
* PLASSERING: frontend/src/app/admin/page.tsx
|
* PLASSERING: frontend/src/app/admin/page.tsx
|
||||||
* FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering.
|
* FUNKSJON: Live-oppdatering, massevalg, redigering, meny og smart-filtrering.
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
import ScrapeMethodSelect from "@/components/ScrapeMethodSelect";
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const [facilities, setFacilities] = useState<any[]>([]);
|
const [facilities, setFacilities] = useState<any[]>([]);
|
||||||
|
|
@ -418,12 +419,20 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-6 text-right pr-4">
|
<td className="py-6 text-right pr-4">
|
||||||
|
<div className="flex flex-col gap-2 items-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(f)}
|
onClick={() => openEditModal(f)}
|
||||||
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all whitespace-nowrap"
|
className="bg-gray-100 px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-[#11280f] hover:bg-gray-200 transition-all whitespace-nowrap"
|
||||||
>
|
>
|
||||||
Rediger
|
Skraper
|
||||||
</button>
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/admin/rediger/${f.slug}`}
|
||||||
|
className="bg-[#11280f] px-4 py-2 rounded-xl text-[9px] font-black uppercase tracking-widest text-white hover:bg-[#8bc34a] transition-all whitespace-nowrap text-center"
|
||||||
|
>
|
||||||
|
Rediger alt
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
281
frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx
Normal file
281
frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
"use client";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
// KOMPONENT 1: Viser flate JSON-objekter (som fasiliteter) som rader med Nøkkel og Verdi
|
||||||
|
const KeyValueEditor = ({ label, value, onChange }: { label: string, value: any, onChange: (v: any) => void }) => {
|
||||||
|
const entries = Object.entries(value || {});
|
||||||
|
|
||||||
|
const updateKey = (oldKey: string, newKey: string, val: any) => {
|
||||||
|
const newObj: any = {};
|
||||||
|
for (const [k, v] of entries) {
|
||||||
|
if (k === oldKey) {
|
||||||
|
if (newKey.trim()) newObj[newKey] = val;
|
||||||
|
} else {
|
||||||
|
newObj[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(newObj);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVal = (key: string, val: string) => {
|
||||||
|
onChange({ ...value, [key]: val });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeKey = (key: string) => {
|
||||||
|
const newObj = { ...value };
|
||||||
|
delete newObj[key];
|
||||||
|
onChange(newObj);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
const tempKey = `ny_rad_${Date.now()}`;
|
||||||
|
onChange({ ...value, [tempKey]: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 mb-8 bg-gray-50 p-6 rounded-[2rem] border border-gray-100">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="w-1/3 p-3 rounded-xl border border-gray-200 text-xs font-bold focus:border-[#8bc34a] outline-none"
|
||||||
|
placeholder="Nøkkel (f.eks proshop)"
|
||||||
|
defaultValue={k.startsWith('ny_rad_') ? '' : k}
|
||||||
|
onBlur={e => updateKey(k, e.target.value, v)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-full p-3 rounded-xl border border-gray-200 text-xs focus:border-[#8bc34a] outline-none"
|
||||||
|
placeholder="Verdi (f.eks Ja, eller et navn)"
|
||||||
|
value={String(v)}
|
||||||
|
onChange={e => updateVal(k, e.target.value)}
|
||||||
|
/>
|
||||||
|
<button onClick={() => removeKey(k)} className="p-3 bg-red-100 text-red-600 hover:bg-red-200 rounded-xl font-bold transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={addRow} className="mt-2 text-left text-xs font-bold text-[#8bc34a] hover:text-[#11280f] transition-colors">+ Legg til ny rad</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// KOMPONENT 2: Viser Arrays med objekter (som Greenfee-lister) som små pene kort
|
||||||
|
const ListObjectEditor = ({ label, value, templateKeys, onChange }: { label: string, value: any[], templateKeys: string[], onChange: (v: any[]) => void }) => {
|
||||||
|
const items = Array.isArray(value) ? value : [];
|
||||||
|
|
||||||
|
const updateField = (index: number, key: string, val: string | number) => {
|
||||||
|
const newItems = [...items];
|
||||||
|
// Prøv å konvertere til tall hvis det gir mening (for priser)
|
||||||
|
const parsedVal = (!isNaN(Number(val)) && val !== "") ? Number(val) : val;
|
||||||
|
newItems[index] = { ...newItems[index], [key]: parsedVal };
|
||||||
|
onChange(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
const newItem: any = {};
|
||||||
|
templateKeys.forEach(k => newItem[k] = "");
|
||||||
|
onChange([...items, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRow = (index: number) => {
|
||||||
|
const newItems = items.filter((_, i) => i !== index);
|
||||||
|
onChange(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 mb-8 bg-gray-50 p-6 rounded-[2rem] border border-gray-100">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-[#11280f]">{label}</label>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<div key={idx} className="flex flex-col bg-white p-4 rounded-2xl border border-gray-100 shadow-sm relative group hover:border-[#8bc34a] transition-colors">
|
||||||
|
<button onClick={() => removeRow(idx)} className="absolute top-4 right-4 w-6 h-6 flex items-center justify-center bg-red-100 text-red-600 hover:bg-red-200 rounded-full text-xs font-bold transition-colors">✕</button>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pr-8">
|
||||||
|
{templateKeys.map(key => (
|
||||||
|
<div key={key} className="flex flex-col gap-1">
|
||||||
|
<label className="text-[9px] uppercase font-bold text-gray-400 tracking-wider">{key.replace(/_/g, ' ')}</label>
|
||||||
|
<input
|
||||||
|
className="p-2 rounded-lg border border-gray-200 text-xs font-bold text-gray-700 focus:border-[#8bc34a] outline-none"
|
||||||
|
value={item[key] || ""}
|
||||||
|
onChange={e => updateField(idx, key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={addRow} className="mt-2 text-left text-xs font-bold text-[#8bc34a] hover:text-[#11280f] transition-colors">+ Legg til nytt element</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default function EditFacilityClient({ initialData }: { initialData: any }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [formData, setFormData] = useState(initialData);
|
||||||
|
const [activeTab, setActiveTab] = useState('generelt');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: any) => {
|
||||||
|
setFormData((prev: any) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/facilities/${initialData.id}/full`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
alert("Anlegget ble oppdatert suksessfullt!");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
alert("Noe gikk galt under lagring.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Nettverksfeil.");
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'generelt', label: 'Generelt' },
|
||||||
|
{ id: 'lokasjon', label: 'Lokasjon & Kontakt' },
|
||||||
|
{ id: 'linker', label: 'Lenker & Media' },
|
||||||
|
{ id: 'okonomi', label: 'Økonomi & Medlemskap' },
|
||||||
|
{ id: 'avansert', label: 'Spesial & Strukturer (JSON)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const Input = ({ field, label, type = "text" }: { field: string, label: string, type?: string }) => (
|
||||||
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-gray-500">{label}</label>
|
||||||
|
{type === 'textarea' ? (
|
||||||
|
<textarea className="p-4 rounded-2xl border-2 border-gray-100 bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-all" rows={4} value={formData[field] || ""} onChange={e => handleChange(field, e.target.value)} />
|
||||||
|
) : (
|
||||||
|
<input type={type} className="p-4 rounded-2xl border-2 border-gray-100 bg-gray-50 focus:bg-white focus:border-[#8bc34a] outline-none transition-all font-bold text-[#11280f]" value={formData[field] || ""} onChange={e => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[1200px] mx-auto p-8 relative z-40 bg-white min-h-screen">
|
||||||
|
<div className="flex justify-between items-center mb-10 pb-6 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<Link href="/admin" className="text-sm font-bold text-gray-400 hover:text-[#8bc34a] mb-2 block">← Tilbake til oversikten</Link>
|
||||||
|
<h1 className="text-4xl font-black text-[#11280f]">Rediger: <span className="text-[#8bc34a]">{initialData.name}</span></h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#11280f] text-white px-8 py-4 rounded-full font-black uppercase tracking-widest hover:bg-[#8bc34a] transition-colors shadow-xl disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Lagrer..." : "Lagre endringer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-10">
|
||||||
|
<div className="w-full md:w-1/4 flex flex-col gap-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`p-4 rounded-2xl text-left font-black uppercase text-xs tracking-widest transition-all ${activeTab === tab.id ? 'bg-[#8bc34a] text-white shadow-lg translate-x-2' : 'bg-gray-50 text-gray-500 hover:bg-gray-100'}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full md:w-3/4 bg-white">
|
||||||
|
{activeTab === 'generelt' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||||
|
<div className="col-span-1 md:col-span-2"><Input field="name" label="Anleggsnavn" /></div>
|
||||||
|
<div className="col-span-1 md:col-span-2"><Input field="description" label="Beskrivelse" type="textarea" /></div>
|
||||||
|
<Input field="banetype" label="Banetype (f.eks Park/Skog)" />
|
||||||
|
<Input field="season" label="Sesong (f.eks April-Oktober)" />
|
||||||
|
<Input field="established_year" label="Byggeår" type="number" />
|
||||||
|
<Input field="architect" label="Arkitekt" />
|
||||||
|
<Input field="length_meters" label="Totallengde (meter)" type="number" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'lokasjon' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||||
|
<div className="col-span-1 md:col-span-2"><Input field="address" label="Gateadresse" /></div>
|
||||||
|
<Input field="zipcode" label="Postnummer" />
|
||||||
|
<Input field="city" label="Poststed / By" />
|
||||||
|
<Input field="county" label="Fylke" />
|
||||||
|
<Input field="phone" label="Telefon" />
|
||||||
|
<Input field="email" label="E-post" />
|
||||||
|
<Input field="lat" label="Breddegrad (Latitude)" type="number" />
|
||||||
|
<Input field="lng" label="Lengdegrad (Longitude)" type="number" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'linker' && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Input field="website_url" label="Nettside URL" />
|
||||||
|
<Input field="golfbox_booking_url" label="Golfbox Booking URL" />
|
||||||
|
<Input field="golfbox_tournament_url" label="Golfbox Turnering URL" />
|
||||||
|
<Input field="baneguide_url" label="Baneguide URL" />
|
||||||
|
<Input field="flyfoto_url" label="Flyfoto URL" />
|
||||||
|
<Input field="weather_url" label="Vær URL (YR)" />
|
||||||
|
<Input field="webcam_url" label="Webkamera URL" />
|
||||||
|
<Input field="video_url" label="Video URL (YouTube/Vimeo)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'okonomi' && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h3 className="font-black uppercase tracking-widest text-gray-300 mb-4 pb-2 border-b">Medlemskap</h3>
|
||||||
|
<Input field="navn_standard_medlemskap" label="Navn på standard medlemskap" />
|
||||||
|
<Input field="standard_medlemskap" label="Pris standard (kun tall)" type="number" />
|
||||||
|
<Input field="standard_medlemskap_kommentarer" label="Kommentar standard" />
|
||||||
|
<Input field="navn_rimeligste_alternativ" label="Navn på rimeligste" />
|
||||||
|
<Input field="rimeligste_alternativ" label="Pris rimeligste (kun tall)" type="number" />
|
||||||
|
<Input field="medlemskap_url" label="Lenke til medlemskapsside" />
|
||||||
|
|
||||||
|
<h3 className="font-black uppercase tracking-widest text-gray-300 mb-4 mt-8 pb-2 border-b">Veien til Golf (VTG)</h3>
|
||||||
|
<Input field="vtg_pris" label="Pris VTG kurs (kun tall)" type="number" />
|
||||||
|
<Input field="vtg_lenke" label="Lenke til VTG påmelding" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'avansert' && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="bg-[#f1f7ed] p-5 rounded-2xl border border-[#7ca982]/20 mb-4">
|
||||||
|
<h3 className="font-black text-[#11280f] text-sm uppercase tracking-widest mb-1">Strukturerte Data</h3>
|
||||||
|
<p className="text-xs text-gray-600">Her kan du redigere komplekse lister og felter uten å bekymre deg for kodefeil. Skjemaet lagrer det trygt i databasen for deg.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enkle Key-Value editorer for objekter */}
|
||||||
|
<KeyValueEditor label="Fasiliteter (Proshop, Kafé etc.)" value={formData.amenities} onChange={(v) => handleChange('amenities', v)} />
|
||||||
|
<KeyValueEditor label="Norsk Seniorgolf Info" value={formData.nsg_data} onChange={(v) => handleChange('nsg_data', v)} />
|
||||||
|
<KeyValueEditor label="Golfamore Info" value={formData.golfamore_data} onChange={(v) => handleChange('golfamore_data', v)} />
|
||||||
|
|
||||||
|
{/* Komplekse Liste-editorer for Arrays */}
|
||||||
|
<ListObjectEditor
|
||||||
|
label="Greenfee Priser"
|
||||||
|
value={formData.greenfee}
|
||||||
|
templateKeys={['banenavn', 'priskategori', 'pris_voksne', 'pris_junior']}
|
||||||
|
onChange={(v) => handleChange('greenfee', v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListObjectEditor
|
||||||
|
label="Golfpakker"
|
||||||
|
value={formData.golfpakker}
|
||||||
|
templateKeys={['navn', 'pris', 'beskrivelse']}
|
||||||
|
onChange={(v) => handleChange('golfpakker', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/app/admin/rediger/[slug]/page.tsx
Normal file
14
frontend/src/app/admin/rediger/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { API_URL } from "@/config/constants";
|
||||||
|
import EditFacilityClient from "./EditFacilityClient";
|
||||||
|
|
||||||
|
export default async function EditFacilityPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: 'no-store' });
|
||||||
|
const facility = await res.json();
|
||||||
|
|
||||||
|
if (!facility || facility.error) {
|
||||||
|
return <div className="p-20 text-center font-bold text-2xl">Fant ikke anlegget...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EditFacilityClient initialData={facility} />;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue