Endringer gjort, inklusive åpne for html i hovedbeskrivelsen
BIN
frontend/public/icons/close-soon.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/icons/close-soon24.png
Executable file
|
After Width: | Height: | Size: 572 B |
BIN
frontend/public/icons/closed-open.png
Executable file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/public/icons/closed-open24.png
Executable file
|
After Width: | Height: | Size: 873 B |
BIN
frontend/public/icons/closed.png
Executable file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/public/icons/closed24.png
Executable file
|
After Width: | Height: | Size: 602 B |
BIN
frontend/public/icons/discontinued.png
Executable file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/icons/discontinued24.png
Executable file
|
After Width: | Height: | Size: 730 B |
BIN
frontend/public/icons/open-soon.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/icons/open-soon24.png
Executable file
|
After Width: | Height: | Size: 565 B |
BIN
frontend/public/icons/open-winter.png
Executable file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/icons/open-winter24.png
Executable file
|
After Width: | Height: | Size: 656 B |
BIN
frontend/public/icons/open.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/icons/open24.png
Executable file
|
After Width: | Height: | Size: 583 B |
BIN
frontend/public/icons/under-development.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/public/icons/under-development24.png
Executable file
|
After Width: | Height: | Size: 609 B |
BIN
frontend/public/icons/unknown.png
Executable file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/public/icons/unknown24.png
Executable file
|
After Width: | Height: | Size: 633 B |
|
|
@ -220,6 +220,59 @@ const toPlainText = (value: string | null | undefined) =>
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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 sanitizeRichText = (value: string | null | undefined) => {
|
||||||
|
const source = String(value || "").replace(/\r\n?/g, "\n");
|
||||||
|
if (!source.trim()) return "";
|
||||||
|
|
||||||
|
const placeholders = new Map<string, string>();
|
||||||
|
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("<br />"))
|
||||||
|
.replace(/<\s*(strong|b)\s*>/gi, () => keep("<strong>"))
|
||||||
|
.replace(/<\s*\/\s*(strong|b)\s*>/gi, () => keep("</strong>"))
|
||||||
|
.replace(/<\s*(em|i)\s*>/gi, () => keep("<em>"))
|
||||||
|
.replace(/<\s*\/\s*(em|i)\s*>/gi, () => keep("</em>"))
|
||||||
|
.replace(/<\s*p\s*>/gi, () => keep("<p>"))
|
||||||
|
.replace(/<\s*\/\s*p\s*>/gi, () => keep("</p>"))
|
||||||
|
.replace(/<\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`))
|
||||||
|
.replace(/<\s*\/\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`</${tag.toLowerCase()}>`))
|
||||||
|
.replace(/<\s*li\s*>/gi, () => keep("<li>"))
|
||||||
|
.replace(/<\s*\/\s*li\s*>/gi, () => keep("</li>"))
|
||||||
|
.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] || "#");
|
||||||
|
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer">`);
|
||||||
|
})
|
||||||
|
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
|
||||||
|
|
||||||
|
safe = escapeHtml(safe).replace(/\n/g, "<br />");
|
||||||
|
|
||||||
|
for (const [token, html] of placeholders) {
|
||||||
|
safe = safe.replaceAll(token, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return safe;
|
||||||
|
};
|
||||||
|
|
||||||
const getAreaLabel = (value: string, countyOptions: Array<{ slug: string; label: string }>) => {
|
const getAreaLabel = (value: string, countyOptions: Array<{ slug: string; label: string }>) => {
|
||||||
if (!value) return "Hele Norge";
|
if (!value) return "Hele Norge";
|
||||||
const builtIn = HIERARCHICAL_AREA_OPTIONS.find((option) => option.value === value);
|
const builtIn = HIERARCHICAL_AREA_OPTIONS.find((option) => option.value === value);
|
||||||
|
|
@ -575,9 +628,9 @@ export default function FacilitySearch({
|
||||||
{processedFacilities.map((facility) => (
|
{processedFacilities.map((facility) => (
|
||||||
<article
|
<article
|
||||||
key={facility.id}
|
key={facility.id}
|
||||||
className="surface-card group overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
|
className="surface-card group flex h-full flex-col overflow-hidden rounded-[2rem] transition hover:-translate-y-1 hover:shadow-xl"
|
||||||
>
|
>
|
||||||
<Link href={`/golfbaner/${facility.slug}`} className="block">
|
<Link href={`/golfbaner/${facility.slug}`} className="block shrink-0">
|
||||||
<div className="relative h-56 overflow-hidden bg-[#D9DED5] sm:h-60">
|
<div className="relative h-56 overflow-hidden bg-[#D9DED5] sm:h-60">
|
||||||
<Image
|
<Image
|
||||||
src={facility.image_url || "/Toppbilde-standard.jpg"}
|
src={facility.image_url || "/Toppbilde-standard.jpg"}
|
||||||
|
|
@ -628,41 +681,44 @@ export default function FacilitySearch({
|
||||||
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="space-y-5 p-5">
|
<div className="flex flex-1 flex-col p-5">
|
||||||
<div className="flex items-start gap-3">
|
<div className="space-y-5">
|
||||||
<div className="flex min-w-0 flex-wrap gap-2">
|
<div className="flex items-start gap-3">
|
||||||
<span className="rounded-full bg-[#EEF5E4] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#112015]">
|
<div className="flex min-w-0 flex-wrap gap-2">
|
||||||
{facility.holeValue || "--"} hull
|
<span className="rounded-full bg-[#EEF5E4] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#112015]">
|
||||||
</span>
|
{facility.holeValue || "--"} hull
|
||||||
<span className="rounded-full bg-[#F4F5F1] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]">
|
</span>
|
||||||
{facility.banetype || "Banetype ukjent"}
|
<span className="rounded-full bg-[#F4F5F1] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]">
|
||||||
</span>
|
{facility.banetype || "Banetype ukjent"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Number.isFinite(facility.distance) && (
|
||||||
|
<span className="ml-auto shrink-0 self-center rounded-full bg-[#F7F8F3] px-3 py-1.5 text-right text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]">
|
||||||
|
{Math.round(facility.distance)} km unna
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{Number.isFinite(facility.distance) && (
|
|
||||||
<span className="ml-auto shrink-0 self-center rounded-full bg-[#F7F8F3] px-3 py-1.5 text-right text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]">
|
{facility.footnote && (
|
||||||
{Math.round(facility.distance)} km unna
|
<div className="rounded-[1.35rem] border border-[#FFD9CC] bg-[#FFF7F3] px-4 py-3">
|
||||||
</span>
|
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#9A6A5E]">
|
||||||
|
{formatUpdatedDate(facility.footnote_updated_at || facility.status_updated_at)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[15px] italic leading-7 text-[#7B3D2C]" style={noteClampStyle}>
|
||||||
|
{facility.footnote}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{String(facility.description || "").trim() && (
|
||||||
|
<div
|
||||||
|
className="text-[15px] leading-7 text-[#617063] [&_a]:font-bold [&_a]:text-[#FF5722] [&_a]:underline-offset-2 hover:[&_a]:text-[#C94F2D] [&_em]:italic [&_ol]:my-4 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-4 [&_p:last-child]:mb-0 [&_strong]:font-extrabold [&_ul]:my-4 [&_ul]:list-disc [&_ul]:pl-5"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeRichText(facility.description) }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{facility.footnote && (
|
<div className="mt-auto pt-5 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 text-sm font-bold text-[#112015]">
|
||||||
<div className="rounded-[1.35rem] border border-[#FFD9CC] bg-[#FFF7F3] px-4 py-3">
|
|
||||||
<p className="text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#9A6A5E]">
|
|
||||||
{formatUpdatedDate(facility.footnote_updated_at || facility.status_updated_at)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-[15px] italic leading-7 text-[#7B3D2C]" style={noteClampStyle}>
|
|
||||||
{facility.footnote}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{toPlainText(facility.description) && (
|
|
||||||
<p className="text-[15px] leading-7 text-[#617063]">
|
|
||||||
{toPlainText(facility.description)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 text-sm font-bold text-[#112015]">
|
|
||||||
<span className="truncate text-[#617063]">{facility.phone ? facility.phone : facility.city || "Se detaljer"}</span>
|
<span className="truncate text-[#617063]">{facility.phone ? facility.phone : facility.city || "Se detaljer"}</span>
|
||||||
<div className="flex items-center justify-self-center gap-1">
|
<div className="flex items-center justify-self-center gap-1">
|
||||||
{facility.website_url && (
|
{facility.website_url && (
|
||||||
|
|
|
||||||
|
|
@ -482,7 +482,11 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
|
||||||
|
|
||||||
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
<div className="col-span-1 md:col-span-2 flex flex-col gap-2 mb-8">
|
||||||
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Hovedbeskrivelse</label>
|
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Hovedbeskrivelse</label>
|
||||||
<textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={4} value={getValue('description', 'textarea')} onChange={e => handleChange('description', e.target.value)} />
|
<textarea className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base shadow-sm focus:border-[#8bc34a] outline-none" rows={8} value={getValue('description', 'textarea')} onChange={e => handleChange('description', e.target.value)} />
|
||||||
|
<p className="text-xs leading-6 text-gray-500">
|
||||||
|
Støtter enkel HTML i kort og detaljvisning:
|
||||||
|
{" "}<code><strong></code>, <code><em></code>, <code><a href=\"...\"></code>, <code><br></code>, <code><p></code>, <code><ul></code>, <code><ol></code> og <code><li></code>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-8">
|
<div className="flex flex-col gap-2 mb-8">
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,59 @@ const renderValue = (val: string) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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 sanitizeRichText = (value: string | null | undefined) => {
|
||||||
|
const source = String(value || "").replace(/\r\n?/g, "\n");
|
||||||
|
if (!source.trim()) return "";
|
||||||
|
|
||||||
|
const placeholders = new Map<string, string>();
|
||||||
|
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("<br />"))
|
||||||
|
.replace(/<\s*(strong|b)\s*>/gi, () => keep("<strong>"))
|
||||||
|
.replace(/<\s*\/\s*(strong|b)\s*>/gi, () => keep("</strong>"))
|
||||||
|
.replace(/<\s*(em|i)\s*>/gi, () => keep("<em>"))
|
||||||
|
.replace(/<\s*\/\s*(em|i)\s*>/gi, () => keep("</em>"))
|
||||||
|
.replace(/<\s*p\s*>/gi, () => keep("<p>"))
|
||||||
|
.replace(/<\s*\/\s*p\s*>/gi, () => keep("</p>"))
|
||||||
|
.replace(/<\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`<${tag.toLowerCase()}>`))
|
||||||
|
.replace(/<\s*\/\s*(ul|ol)\s*>/gi, (_, tag: string) => keep(`</${tag.toLowerCase()}>`))
|
||||||
|
.replace(/<\s*li\s*>/gi, () => keep("<li>"))
|
||||||
|
.replace(/<\s*\/\s*li\s*>/gi, () => keep("</li>"))
|
||||||
|
.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] || "#");
|
||||||
|
return keep(`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer">`);
|
||||||
|
})
|
||||||
|
.replace(/<\s*\/\s*a\s*>/gi, () => keep("</a>"));
|
||||||
|
|
||||||
|
safe = escapeHtml(safe).replace(/\n/g, "<br />");
|
||||||
|
|
||||||
|
for (const [token, html] of placeholders) {
|
||||||
|
safe = safe.replaceAll(token, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return safe;
|
||||||
|
};
|
||||||
|
|
||||||
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
const Icon = ({ children, className = "w-5 h-5" }: { children: React.ReactNode, className?: string }) => (
|
||||||
<svg
|
<svg
|
||||||
className={`${className} flex-shrink-0 text-current`}
|
className={`${className} flex-shrink-0 text-current`}
|
||||||
|
|
@ -199,7 +252,10 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="leading-relaxed text-lg md:text-xl text-gray-600" dangerouslySetInnerHTML={{ __html: facility.description || 'Ingen beskrivelse tilgjengelig.' }} />
|
<div
|
||||||
|
className="leading-relaxed text-lg md:text-xl text-gray-600 [&_a]:font-bold [&_a]:text-[#ff5722] [&_a]:underline-offset-2 hover:[&_a]:text-[#d53300] [&_em]:italic [&_ol]:my-5 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-5 [&_p:last-child]:mb-0 [&_strong]:font-bold [&_ul]:my-5 [&_ul]:list-disc [&_ul]:pl-6"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizeRichText(facility.description || 'Ingen beskrivelse tilgjengelig.') }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SIDEBAR (22%) */}
|
{/* SIDEBAR (22%) */}
|
||||||
|
|
|
||||||