diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index 559aec1..da184c4 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -4,6 +4,7 @@ import { STATUS_MAP } from "@/config/constants"; import Image from "next/image"; import Link from "next/link"; import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { type EnrichedFacility } from "@/app/facilityData"; type SortMethod = "updated" | "dist" | "alpha"; type Variant = "home" | "catalog"; @@ -65,6 +66,8 @@ type FacilitySearchProps = { intro?: string; fixedAreaFilter?: string; hideTitleBlock?: boolean; + onFilteredFacilitiesChange?: (facilities: EnrichedFacility[]) => void; + filterHeading?: string; }; type SpecialFlags = { @@ -393,6 +396,8 @@ export default function FacilitySearch({ intro = "Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero.", fixedAreaFilter = "", hideTitleBlock = false, + onFilteredFacilitiesChange, + filterHeading = "Søk golfbaner", }: FacilitySearchProps) { const [searchQuery, setSearchQuery] = useState(""); const [areaFilter, setAreaFilter] = useState(fixedAreaFilter); @@ -509,6 +514,13 @@ export default function FacilitySearch({ const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0; const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0; const hasSimulator = hasTruthyAmenity(amenities.simulator); + const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange); + const vtgDates = parseJson(facility.vtg_datoer, []); + const hasVtg = + Boolean(facility.vtg_pris) || + Boolean(facility.vtg_lenke) || + Boolean(facility.vtg_beskrivelse) || + (Array.isArray(vtgDates) && vtgDates.length > 0); const architectKey = normalizeText(facility.architect || ""); const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0; @@ -570,9 +582,15 @@ export default function FacilitySearch({ return { ...facility, holeValue, + countySlug, + regions, + statuses, primaryStatus, hasGolfamore, hasNSG, + hasSimulator, + hasDrivingRange, + hasVtg, distance, lastUpdatedTs, matchesSearch, @@ -644,6 +662,10 @@ export default function FacilitySearch({ } }, [filtersCount, isCollapsibleHomeSearch]); + useEffect(() => { + onFilteredFacilitiesChange?.(processedFacilities); + }, [onFilteredFacilitiesChange, processedFacilities]); + return (
{variant === "catalog" && !hideTitleBlock && ( @@ -664,7 +686,7 @@ export default function FacilitySearch({ aria-expanded={searchPanelOpen} className="flex w-full items-center justify-between gap-4 rounded-[1.4rem] border border-white/12 bg-white/6 px-4 py-4 text-left transition hover:bg-white/10 md:hidden" > - Søk golfbaner + {filterHeading}
-

Søk golfbaner

+

{filterHeading}

) : ( @@ -923,9 +945,11 @@ export default function FacilitySearch({

{formatUpdatedDate(facility.footnote_updated_at || facility.status_updated_at)}

-

- {facility.footnote} -

+
)} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 7b86af5..b245c76 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -197,6 +197,57 @@ type InlineEditProps = { inputRows?: number; editorWidthClassName?: string; displayClassName?: string; + renderHtml?: boolean; +}; + +const escapeHtml = (value: string) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const sanitizeHref = (value: string) => { + const href = value.trim(); + return /^(https?:|mailto:|tel:|\/|#)/i.test(href) ? href : "#"; +}; + +const sanitizeInlineRichText = (value: string | null | undefined) => { + const source = String(value || "").replace(/\r\n?/g, "\n"); + if (!source.trim()) return ""; + + const placeholders = new Map(); + let index = 0; + const keep = (html: string) => { + const key = `__HTML_TOKEN_${index++}__`; + placeholders.set(key, html); + return key; + }; + + let safe = source + .replace(/<\s*br\s*\/?\s*>/gi, () => keep("
")) + .replace(/<\s*(strong|b)\s*>/gi, () => keep("")) + .replace(/<\s*\/\s*(strong|b)\s*>/gi, () => keep("")) + .replace(/<\s*(em|i)\s*>/gi, () => keep("")) + .replace(/<\s*\/\s*(em|i)\s*>/gi, () => keep("")) + .replace(/<\s*a\b([^>]*)>/gi, (_, attrs: string) => { + const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + const href = sanitizeHref(hrefMatch?.[1] || hrefMatch?.[2] || hrefMatch?.[3] || "#"); + if (/^(\/|#|mailto:|tel:)/i.test(href) || /^https?:\/\/([^/]+\.)?teeoff\.no(\/|$)/i.test(href)) { + return keep(``); + } + return keep(``); + }) + .replace(/<\s*\/\s*a\s*>/gi, () => keep("")); + + safe = escapeHtml(safe).replace(/\n/g, "
"); + + for (const [token, html] of placeholders) { + safe = safe.replaceAll(token, html); + } + + return safe; }; const InlineEdit = ({ @@ -210,6 +261,7 @@ const InlineEdit = ({ inputRows = 2, editorWidthClassName = 'max-w-[200px]', displayClassName = 'text-[10px] text-blue-600 break-all max-w-[150px] leading-tight line-clamp-2', + renderHtml = false, }: InlineEditProps) => { const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(initialValue || ''); @@ -240,7 +292,16 @@ const InlineEdit = ({ return (
setIsEditing(true)} title={title}>
- {initialValue ? initialValue : {emptyLabel}} + {initialValue ? ( + renderHtml ? ( + + ) : ( + initialValue + ) + ) : {emptyLabel}}
✏️
@@ -1878,6 +1939,7 @@ export default function AdminDashboard() { inputRows={4} editorWidthClassName="max-w-full" displayClassName="text-sm italic leading-6 text-[#11280f] whitespace-pre-wrap" + renderHtml />
@@ -2126,6 +2188,7 @@ export default function AdminDashboard() { inputRows={4} editorWidthClassName="max-w-[320px]" displayClassName="mb-2 text-[11px] italic leading-5 text-[#11280f] whitespace-pre-wrap" + renderHtml /> {f.course_statuses && f.course_statuses.map((cs: any, idx: number) => { let badgeColor = "bg-gray-100 text-gray-500"; diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index 8d13b79..cccb295 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -317,9 +317,10 @@ export default function FacilityDetailView({ facility }: { facility: any }) {

{formatDate(facility.footnote_updated_at || facility.status_updated_at)}

-
- {facility.footnote} -
+
)}
filterFacilitiesByArea(enrichFacilities(facilities), placeAreaFilter), + [facilities, placeAreaFilter] + ); + const [filteredFacilities, setFilteredFacilities] = useState(facilitiesInPlace); + + useEffect(() => { + setFilteredFacilities(facilitiesInPlace); + }, [facilitiesInPlace]); + + const preposition = PREPOSITION_PA_LABELS.has(placeLabel) ? "på" : "i"; + const filterHeading = `Filtrer golfbaner ${preposition} ${placeLabel}`; + const filterIntro = `Filtrer golfbanene ${preposition} ${placeLabel} videre etter banestatus, antall hull og andre egenskaper.`; + + return ( + <> +
+ +
+ + + + ); +} diff --git a/frontend/src/app/sted/[slug]/page.tsx b/frontend/src/app/sted/[slug]/page.tsx index 9554cfb..7719444 100755 --- a/frontend/src/app/sted/[slug]/page.tsx +++ b/frontend/src/app/sted/[slug]/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import FacilitySearch from "@/app/FacilitySearch"; -import PlaceMap from "@/components/PlaceMap"; +import PlaceExplorer from "@/app/sted/[slug]/PlaceExplorer"; import { type FacilityRecord, enrichFacilities, @@ -127,19 +126,13 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
-
- -
+ - - );