Endringer gjort, inklusive åpne for html i hovedbeskrivelsen

This commit is contained in:
Erol 2026-04-12 15:19:39 +02:00
parent 0e6752e010
commit d29b4a8976
21 changed files with 151 additions and 35 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 B

BIN
frontend/public/icons/closed.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

BIN
frontend/public/icons/open.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
frontend/public/icons/open24.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

BIN
frontend/public/icons/unknown.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

View file

@ -220,6 +220,59 @@ const toPlainText = (value: string | null | undefined) =>
.replace(/\s+/g, " ")
.trim();
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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 }>) => {
if (!value) return "Hele Norge";
const builtIn = HIERARCHICAL_AREA_OPTIONS.find((option) => option.value === value);
@ -575,9 +628,9 @@ export default function FacilitySearch({
{processedFacilities.map((facility) => (
<article
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">
<Image
src={facility.image_url || "/Toppbilde-standard.jpg"}
@ -628,41 +681,44 @@ export default function FacilitySearch({
</Link>
<div className="space-y-5 p-5">
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-wrap gap-2">
<span className="rounded-full bg-[#EEF5E4] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#112015]">
{facility.holeValue || "--"} hull
</span>
<span className="rounded-full bg-[#F4F5F1] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]">
{facility.banetype || "Banetype ukjent"}
</span>
<div className="flex flex-1 flex-col p-5">
<div className="space-y-5">
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-wrap gap-2">
<span className="rounded-full bg-[#EEF5E4] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#112015]">
{facility.holeValue || "--"} hull
</span>
<span className="rounded-full bg-[#F4F5F1] px-3 py-1.5 text-[10px] font-extrabold uppercase tracking-[0.15em] text-[#617063]">
{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>
{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>
{facility.footnote && (
<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>
)}
{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>
{facility.footnote && (
<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]">
<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]">
<span className="truncate text-[#617063]">{facility.phone ? facility.phone : facility.city || "Se detaljer"}</span>
<div className="flex items-center justify-self-center gap-1">
{facility.website_url && (

View file

@ -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">
<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>&lt;strong&gt;</code>, <code>&lt;em&gt;</code>, <code>&lt;a href=\"...\"&gt;</code>, <code>&lt;br&gt;</code>, <code>&lt;p&gt;</code>, <code>&lt;ul&gt;</code>, <code>&lt;ol&gt;</code> og <code>&lt;li&gt;</code>.
</p>
</div>
<div className="flex flex-col gap-2 mb-8">

View file

@ -34,6 +34,59 @@ const renderValue = (val: string) => {
);
};
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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 }) => (
<svg
className={`${className} flex-shrink-0 text-current`}
@ -199,7 +252,10 @@ export default function FacilityDetailView({ facility }: { facility: any }) {
</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>
{/* SIDEBAR (22%) */}