Etter å ha koblet til Google og Magic link

This commit is contained in:
Erol 2026-04-13 21:43:55 +02:00
parent e1fcabef6a
commit 1b09e88fd3
15 changed files with 6783 additions and 486 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
__pycache__/
*.pyc
*.pyo
.env

File diff suppressed because it is too large Load diff

View file

@ -15,10 +15,23 @@ services:
api:
build: ./backend
container_name: teeoff_api
environment:
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
PUBLIC_SESSION_SECRET: ${PUBLIC_SESSION_SECRET}
PUBLIC_COMMENT_DEFAULT_STATUS: ${PUBLIC_COMMENT_DEFAULT_STATUS}
SMTP_SERVER: ${SMTP_SERVER}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
PUBLIC_FROM_EMAIL: ${PUBLIC_FROM_EMAIL}
PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES: ${PUBLIC_MAGIC_LINK_MAX_AGE_MINUTES}
ports:
- "8001:8000"
volumes:
- ./backend:/app
- ./frontend/src/content:/shared/frontend-content:ro
# Denne linjen sørger for at bilder lagres direkte i frontendens public-mappe:
- ./frontend/public/media:/app/public/media
depends_on:

View file

@ -5,6 +5,14 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"react-hooks/set-state-in-effect": "off",
"react/no-children-prop": "off",
"react/no-unescaped-entities": "off",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:

View file

@ -8,6 +8,7 @@ import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
type AdminArticle = {
id: number;
section?: "banebesok" | "meninger";
slug: string;
title: string;
description?: string | null;
@ -37,6 +38,7 @@ type FacilityOption = {
};
type ArticleFormState = {
section: "banebesok" | "meninger";
slug: string;
title: string;
description: string;
@ -79,6 +81,7 @@ function heroImagesToText(images?: AdminArticle["hero_images"]) {
function createEmptyForm(): ArticleFormState {
return {
section: "banebesok",
slug: "",
title: "",
description: "",
@ -99,6 +102,7 @@ function createEmptyForm(): ArticleFormState {
function articleToForm(article: AdminArticle): ArticleFormState {
return {
section: article.section || "banebesok",
slug: article.slug || "",
title: article.title || "",
description: article.description || "",
@ -191,7 +195,7 @@ export default function AdminArticlesPage() {
};
const handleFieldChange = (field: keyof ArticleFormState, value: string) => {
setForm((current) => ({ ...current, [field]: value }));
setForm((current) => ({ ...current, [field]: value as ArticleFormState[keyof ArticleFormState] }));
};
const uploadArticleImage = async (file: File) => {
@ -284,6 +288,7 @@ export default function AdminArticlesPage() {
try {
const payload = {
...form,
section: form.section,
slug: form.slug.trim(),
title: form.title.trim(),
description: form.description.trim(),
@ -379,10 +384,10 @@ export default function AdminArticlesPage() {
<Link href="/admin" className="text-sm font-bold text-gray-500 transition hover:text-[#8bc34a]">
Tilbake til admin
</Link>
<h1 className="mt-3 text-4xl font-black tracking-tight text-[#11280f]">Artikler / Banebesøk</h1>
<h1 className="mt-3 text-4xl font-black tracking-tight text-[#11280f]">Artikler</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#536256]">
Første adminversjon for redaksjonelle artikler. Denne bruker Tiptap for innhold,
lagrer fortsatt HTML i databasen, og kan seedes fra de importerte Banebesøk-artiklene.
Redaksjonelle artikler kan ligge i egne seksjoner. Denne editoren bruker Tiptap,
lagrer HTML i databasen og kan seede både Banebesøk og Meninger fra importfilen.
</p>
</div>
<div className="flex flex-wrap gap-3">
@ -460,6 +465,9 @@ export default function AdminArticlesPage() {
<p className="mt-2 text-[11px] font-bold uppercase tracking-[0.14em] text-[#6A766C]">
/{article.slug}
</p>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
{article.section === "meninger" ? "Meninger" : "Banebesøk"}
</p>
<p className="mt-3 text-sm leading-6 text-[#536256]">
{article.facility_name || "Uten koblet bane"}
</p>
@ -469,7 +477,18 @@ export default function AdminArticlesPage() {
</aside>
<section className="surface-card rounded-[2rem] p-5 sm:p-8">
<div className="grid gap-5 md:grid-cols-2">
<div className="grid gap-5 md:grid-cols-3">
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Seksjon</span>
<select
value={form.section}
onChange={(event) => handleFieldChange("section", event.target.value)}
className="rounded-[1.1rem] border border-[#112015]/10 bg-white px-4 py-3 text-base font-bold text-[#112015] outline-none focus:border-[#8BC34A]"
>
<option value="banebesok">Banebesøk</option>
<option value="meninger">Meninger</option>
</select>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Tittel</span>
<input
@ -684,7 +703,7 @@ export default function AdminArticlesPage() {
) : null}
{form.slug ? (
<Link
href={`/banebesok/${form.slug}`}
href={`/${form.section}/${form.slug}`}
target="_blank"
className="rounded-full border border-[#112015]/10 bg-white px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
>

View file

@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import CourseVisitGallery from "@/components/CourseVisitGallery";
import ArticleComments from "@/components/ArticleComments";
import InfoPageShell from "@/components/InfoPageShell";
import { getCourseVisitBySlug, type CourseVisitBodyBlock } from "@/content/courseVisits";
import {
@ -166,11 +167,15 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
publisher: {
"@id": ORGANIZATION_ID,
},
about: {
"@type": "GolfCourse",
name: article.facilityName,
url: buildAbsoluteUrl(`/golfbaner/${article.facilitySlug}`),
},
...(article.facilityName && article.facilitySlug
? {
about: {
"@type": "GolfCourse",
name: article.facilityName,
url: buildAbsoluteUrl(`/golfbaner/${article.facilitySlug}`),
},
}
: {}),
};
return (
@ -193,6 +198,7 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
<div className="space-y-6">
<CourseVisitGallery title={article.title} images={article.heroImages} />
{article.blocks.map((block, index) => renderBlock(block, index))}
<ArticleComments slug={article.slug} section="banebesok" />
</div>
<aside className="space-y-6">
@ -231,18 +237,21 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
Videre fra artikkelen
</p>
<p className="mt-3 text-xl font-black">{article.facilityName}</p>
<p className="mt-3 text-xl font-black">{article.facilityName || article.title}</p>
<p className="mt-3 text-sm leading-6 text-white/78">
Bruk artikkelen for inspirasjon, og videre til baneprofilen når du trenger
praktiske detaljer og oppdatert klubbinfo.
{article.facilitySlug
? "Bruk artikkelen for inspirasjon, og gå videre til baneprofilen når du trenger praktiske detaljer og oppdatert klubbinfo."
: "Artikkelen står på egne ben, men du kan fortsatt gå tilbake til oversikten eller åpne originalkilden om den finnes."}
</p>
<div className="mt-5 flex flex-col gap-3">
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center justify-center rounded-full bg-[#FF5722] px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
>
Åpne baneprofil
</Link>
{article.facilitySlug ? (
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center justify-center rounded-full bg-[#FF5722] px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
>
Åpne baneprofil
</Link>
) : null}
<Link
href="/banebesok"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/8 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/14"

View file

@ -86,12 +86,14 @@ export default async function CourseVisitsPage() {
>
Les artikkelen
</Link>
<Link
href={`/golfbaner/${featuredArticle.facilitySlug}`}
className="inline-flex items-center rounded-full border border-white/18 bg-white/10 px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/18"
>
til baneprofil
</Link>
{featuredArticle.facilitySlug ? (
<Link
href={`/golfbaner/${featuredArticle.facilitySlug}`}
className="inline-flex items-center rounded-full border border-white/18 bg-white/10 px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/18"
>
til baneprofil
</Link>
) : null}
</div>
</div>
</div>
@ -171,12 +173,14 @@ export default async function CourseVisitsPage() {
>
Åpne artikkel
</Link>
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center rounded-full border border-[#112015]/10 bg-white px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
>
Baneprofil
</Link>
{article.facilitySlug ? (
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center rounded-full border border-[#112015]/10 bg-white px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
>
Baneprofil
</Link>
) : null}
</div>
</div>
</div>

View file

@ -0,0 +1,279 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import CourseVisitGallery from "@/components/CourseVisitGallery";
import ArticleComments from "@/components/ArticleComments";
import InfoPageShell from "@/components/InfoPageShell";
import { getOpinionArticleBySlug, type CourseVisitBodyBlock } from "@/content/courseVisits";
import {
ORGANIZATION_ID,
buildAbsoluteUrl,
createBreadcrumbJsonLd,
createPageMetadata,
} from "@/app/seo";
type OpinionPageProps = {
params: Promise<{ slug: string }>;
};
export const dynamic = "force-dynamic";
function renderBlock(block: CourseVisitBodyBlock, index: number) {
if (block.type === "richText") {
return (
<section key={index} className="surface-card rounded-[2rem] p-6 sm:p-8">
{block.title ? (
<h2 className="text-3xl font-black text-[#112015] sm:text-4xl">{block.title}</h2>
) : null}
<div
className="course-visit-richtext mt-4 space-y-4 text-base leading-8 text-[#334238] [&_a]:font-black [&_a]:text-[#112015] [&_a]:underline [&_a]:underline-offset-4 [&_em]:italic [&_h2]:mt-12 [&_h2]:text-3xl [&_h2]:font-black [&_h2]:text-[#112015] [&_h3]:mt-10 [&_h3]:text-2xl [&_h3]:font-black [&_img]:mt-5 [&_img]:w-full [&_img]:rounded-[1.5rem] [&_img]:border [&_img]:border-[#112015]/8 [&_img]:shadow-[0_12px_30px_rgba(17,32,21,0.08)] [&_p]:mt-4 [&_br+_a]:font-black"
dangerouslySetInnerHTML={{ __html: block.html }}
/>
</section>
);
}
if (block.type === "quote") {
return (
<section
key={index}
className="rounded-[2rem] border border-[#112015]/8 bg-[#112015] px-6 py-8 text-white shadow-[0_18px_40px_rgba(17,32,21,0.14)] sm:px-8"
>
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-[#8BC34A]">
Sitat
</p>
<blockquote className="mt-4 max-w-4xl text-3xl font-black leading-tight text-white sm:text-4xl">
{block.quote}
</blockquote>
{block.attribution ? (
<p className="mt-4 text-sm font-bold uppercase tracking-[0.14em] text-white/70">
{block.attribution}
</p>
) : null}
</section>
);
}
if (block.type === "checklist") {
return (
<section key={index} className="surface-card rounded-[2rem] p-6 sm:p-8">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
Innholdsgrep
</p>
<h2 className="mt-3 text-3xl font-black text-[#112015] sm:text-4xl">{block.title}</h2>
<div className="mt-6 grid gap-3">
{block.items.map((item) => (
<div
key={item}
className="rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-4"
>
<p className="text-sm font-bold leading-6 text-[#334238]">{item}</p>
</div>
))}
</div>
</section>
);
}
if (block.type === "factGrid") {
return (
<section key={index} className="surface-card rounded-[2rem] p-6 sm:p-8">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
Struktur
</p>
<h2 className="mt-3 text-3xl font-black text-[#112015] sm:text-4xl">{block.title}</h2>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{block.items.map((item) => (
<article
key={`${item.label}-${item.value}`}
className="rounded-[1.5rem] border border-[#112015]/8 bg-white p-5"
>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">
{item.label}
</p>
<p className="mt-3 text-lg font-black text-[#112015]">{item.value}</p>
</article>
))}
</div>
</section>
);
}
return (
<section key={index} className="rounded-[2rem] bg-[#FFF4EF] p-6 sm:p-8">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
Neste steg
</p>
<h2 className="mt-3 text-3xl font-black text-[#112015] sm:text-4xl">{block.title}</h2>
<p className="mt-4 max-w-3xl text-base leading-8 text-[#334238]">{block.body}</p>
</section>
);
}
export async function generateMetadata({ params }: OpinionPageProps) {
const { slug } = await params;
const article = await getOpinionArticleBySlug(slug);
if (!article) {
return createPageMetadata({
title: "Meninger-artikkel ikke funnet",
description: "Artikkelen du leter etter finnes ikke på TeeOff.",
path: `/meninger/${slug}`,
type: "article",
});
}
return createPageMetadata({
title: article.title,
description: article.description,
path: `/meninger/${article.slug}`,
image: article.heroImages[0]?.src,
type: "article",
});
}
export default async function OpinionPage({ params }: OpinionPageProps) {
const { slug } = await params;
const article = await getOpinionArticleBySlug(slug);
if (!article) {
notFound();
}
const breadcrumbJsonLd = createBreadcrumbJsonLd([
{ name: "Hjem", path: "/" },
{ name: "Meninger", path: "/meninger" },
{ name: article.title, path: `/meninger/${article.slug}` },
]);
const articleJsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: article.title,
description: article.description,
url: buildAbsoluteUrl(`/meninger/${article.slug}`),
image: article.heroImages.map((image) => buildAbsoluteUrl(image.src)),
datePublished: article.publishedAt,
dateModified: article.updatedAt || article.publishedAt,
inLanguage: "nb-NO",
isPartOf: {
"@type": "WebSite",
url: buildAbsoluteUrl("/"),
},
author: {
"@type": "Organization",
name: "TeeOff",
"@id": ORGANIZATION_ID,
},
publisher: {
"@id": ORGANIZATION_ID,
},
...(article.facilityName && article.facilitySlug
? {
about: {
"@type": "GolfCourse",
name: article.facilityName,
url: buildAbsoluteUrl(`/golfbaner/${article.facilitySlug}`),
},
}
: {}),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<InfoPageShell
eyebrow={article.eyebrow}
title={article.title}
intro={article.excerpt}
>
<div className="grid gap-6 xl:grid-cols-[1.25fr,0.75fr]">
<div className="space-y-6">
<CourseVisitGallery title={article.title} images={article.heroImages} />
{article.blocks.map((block, index) => renderBlock(block, index))}
<ArticleComments slug={article.slug} section="meninger" />
</div>
<aside className="space-y-6">
<section className="surface-card rounded-[2rem] p-6 sm:p-8 xl:sticky xl:top-28">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
Hurtigfakta
</p>
<div className="mt-5 grid gap-3">
{article.quickFacts.map((fact) =>
fact.href ? (
<Link
key={`${fact.label}-${fact.value}`}
href={fact.href}
className="rounded-[1.4rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-4 transition hover:border-[#8BC34A]"
>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">
{fact.label}
</p>
<p className="mt-2 text-sm font-black text-[#112015]">{fact.value}</p>
</Link>
) : (
<div
key={`${fact.label}-${fact.value}`}
className="rounded-[1.4rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-4"
>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">
{fact.label}
</p>
<p className="mt-2 text-sm font-black text-[#112015]">{fact.value}</p>
</div>
),
)}
</div>
<div className="mt-6 rounded-[1.6rem] bg-[#112015] p-5 text-white">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
Videre fra artikkelen
</p>
<p className="mt-3 text-xl font-black">{article.facilityName || article.title}</p>
<p className="mt-3 text-sm leading-6 text-white/78">
{article.facilitySlug
? "Artikkelen kan stå alene, men den er også koblet til en relevant baneprofil for videre lesing."
: "Dette er en frittstående Meninger-artikkel. Gå tilbake til oversikten eller åpne originalkilden om den finnes."}
</p>
<div className="mt-5 flex flex-col gap-3">
{article.facilitySlug ? (
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center justify-center rounded-full bg-[#FF5722] px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
>
Åpne baneprofil
</Link>
) : null}
<Link
href="/meninger"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/8 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/14"
>
Tilbake til meninger
</Link>
{article.sourceUrl ? (
<a
href={article.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/8 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/14"
>
Original kilde
</a>
) : null}
</div>
</div>
</section>
</aside>
</div>
</InfoPageShell>
</>
);
}

View file

@ -0,0 +1,192 @@
import Image from "next/image";
import Link from "next/link";
import InfoPageShell from "@/components/InfoPageShell";
import { getOpinionArticles } from "@/content/courseVisits";
import {
createBreadcrumbJsonLd,
createCollectionPageJsonLd,
createPageMetadata,
} from "@/app/seo";
const pageTitle = "Meninger";
const pageDescription =
"Redaksjonelle artikler, siste nytt og kommentarer fra TeeOff, samlet i én egen seksjon.";
export const metadata = createPageMetadata({
title: pageTitle,
description: pageDescription,
path: "/meninger",
});
export const dynamic = "force-dynamic";
export default async function OpinionsPage() {
const articles = await getOpinionArticles();
const featuredArticle = articles[0];
const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle,
description: pageDescription,
path: "/meninger",
});
const breadcrumbJsonLd = createBreadcrumbJsonLd([
{ name: "Hjem", path: "/" },
{ name: "Meninger", path: "/meninger" },
]);
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<InfoPageShell
eyebrow="Meninger"
title="Redaksjonelt innhold uten tvangstrøye"
intro="Meninger samler TeeOffs kommentarer, siste nytt og andre artikler som ikke nødvendigvis er rene banebesøk. Her kan innholdet stå alene, men fortsatt kobles til baneprofiler når det gir mening."
>
{featuredArticle ? (
<section className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
<article className="surface-card overflow-hidden rounded-[2rem]">
<div className="relative aspect-[4/5] sm:aspect-[16/10]">
<Image
src={featuredArticle.heroImages[0].src}
alt={featuredArticle.heroImages[0].alt}
fill
priority
sizes="(max-width: 768px) 100vw, 70vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#112015]/90 via-[#112015]/42 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5 sm:p-8">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
{featuredArticle.eyebrow}
</p>
<h2 className="mt-4 max-w-3xl text-4xl font-black text-white sm:text-5xl">
{featuredArticle.title}
</h2>
<p className="mt-4 max-w-2xl text-base leading-7 text-white/86">
{featuredArticle.excerpt}
</p>
<div className="mt-6 flex flex-wrap gap-3 text-[11px] font-black uppercase tracking-[0.16em] text-white/72">
<span>{featuredArticle.locationLabel}</span>
<span>{featuredArticle.readingTime}</span>
<span>{featuredArticle.publishedAt}</span>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={`/meninger/${featuredArticle.slug}`}
className="inline-flex items-center rounded-full bg-[#FF5722] px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
>
Les artikkelen
</Link>
{featuredArticle.facilitySlug ? (
<Link
href={`/golfbaner/${featuredArticle.facilitySlug}`}
className="inline-flex items-center rounded-full border border-white/18 bg-white/10 px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/18"
>
til baneprofil
</Link>
) : null}
</div>
</div>
</div>
</article>
<aside className="surface-card rounded-[2rem] p-6 sm:p-8">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
Redaksjonell seksjon
</p>
<h2 className="mt-4 text-3xl font-black text-[#112015]">Meninger står separat</h2>
<div className="mt-6 grid gap-3">
{featuredArticle.highlights.map((highlight) => (
<div
key={highlight}
className="rounded-[1.4rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-4"
>
<p className="text-sm font-bold leading-6 text-[#334238]">{highlight}</p>
</div>
))}
</div>
<p className="mt-6 text-sm leading-6 text-[#536256]">
Artikler med kategorier som ikke er Banebesøk kan importeres og publiseres
uten å lekes om til baneartikler først.
</p>
</aside>
</section>
) : null}
<section className="mt-8">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
Artikler
</p>
<h2 className="mt-3 text-3xl font-black text-[#112015]">Meninger i system</h2>
</div>
<p className="text-sm font-bold text-[#5A685C]">{articles.length} publisert</p>
</div>
<div className="mt-6 grid gap-5 lg:grid-cols-2">
{articles.map((article) => (
<article key={article.slug} className="surface-card overflow-hidden rounded-[2rem]">
<div className="grid gap-0 md:grid-cols-[0.95fr,1.05fr]">
<div className="relative min-h-[16rem]">
<Image
src={article.heroImages[0].src}
alt={article.heroImages[0].alt}
fill
sizes="(max-width: 768px) 100vw, 40vw"
className="object-cover"
/>
</div>
<div className="p-5 sm:p-6">
<div className="flex flex-wrap gap-3 text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">
<span>{article.eyebrow}</span>
<span>{article.locationLabel}</span>
<span>{article.readingTime}</span>
</div>
<h3 className="mt-4 text-3xl font-black text-[#112015]">{article.title}</h3>
<p className="mt-4 text-sm leading-6 text-[#536256]">{article.excerpt}</p>
<div className="mt-5 flex flex-wrap gap-2">
{article.quickFacts.slice(0, 3).map((fact) => (
<span
key={`${article.slug}-${fact.label}`}
className="rounded-full border border-[#112015]/8 bg-[#F7F9F2] px-3 py-2 text-[10px] font-black uppercase tracking-[0.16em] text-[#334238]"
>
{fact.label}: {fact.value}
</span>
))}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={`/meninger/${article.slug}`}
className="inline-flex items-center rounded-full bg-[#112015] px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
>
Åpne artikkel
</Link>
{article.facilitySlug ? (
<Link
href={`/golfbaner/${article.facilitySlug}`}
className="inline-flex items-center rounded-full border border-[#112015]/10 bg-white px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
>
Baneprofil
</Link>
) : null}
</div>
</div>
</div>
</article>
))}
</div>
</section>
</InfoPageShell>
</>
);
}

View file

@ -1,7 +1,7 @@
import type { MetadataRoute } from "next";
import { API_URL } from "@/config/constants";
import { getAvailablePlaceConfigs } from "@/app/facilityData";
import { getCourseVisits } from "@/content/courseVisits";
import { getCourseVisits, getOpinionArticles } from "@/content/courseVisits";
import { buildAbsoluteUrl } from "@/app/seo";
type SitemapFacility = {
@ -41,6 +41,12 @@ const staticRoutes: MetadataRoute.Sitemap = [
changeFrequency: "weekly",
priority: 0.72,
},
{
url: buildAbsoluteUrl("/meninger"),
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.7,
},
{
url: buildAbsoluteUrl("/turneringer"),
lastModified: new Date(),
@ -96,12 +102,19 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.7,
}));
const articleRoutes = (await getCourseVisits()).map((article) => ({
const courseVisitRoutes = (await getCourseVisits()).map((article) => ({
url: buildAbsoluteUrl(`/banebesok/${article.slug}`),
lastModified: article.updatedAt || article.publishedAt,
changeFrequency: "monthly" as const,
priority: 0.58,
}));
return [...staticRoutes, ...placeRoutes, ...facilityRoutes, ...articleRoutes];
const opinionRoutes = (await getOpinionArticles()).map((article) => ({
url: buildAbsoluteUrl(`/meninger/${article.slug}`),
lastModified: article.updatedAt || article.publishedAt,
changeFrequency: "monthly" as const,
priority: 0.56,
}));
return [...staticRoutes, ...placeRoutes, ...facilityRoutes, ...courseVisitRoutes, ...opinionRoutes];
}

View file

@ -0,0 +1,401 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
type ArticleCommentsProps = {
slug: string;
section: "banebesok" | "meninger";
};
type Viewer = {
id: number;
display_name?: string | null;
full_name?: string | null;
email?: string | null;
};
type CommentItem = {
id: number;
body: string;
status: string;
created_at?: string | null;
author?: {
display_name?: string | null;
};
is_pending_for_viewer?: boolean;
};
type AuthProviders = {
configured: boolean;
google: boolean;
magic_link: boolean;
};
type CommentsResponse = {
auth_configured: boolean;
auth_providers?: AuthProviders;
viewer: Viewer | null;
comments: CommentItem[];
};
function formatCommentDate(value?: string | null) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("nb-NO", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
async function fetchCommentsPayload(slug: string, section: "banebesok" | "meninger") {
const response = await fetch(`/api/articles/${slug}/comments?section=${section}`, {
credentials: "include",
});
if (!response.ok) {
throw new Error("Kunne ikke hente kommentarer.");
}
return (await response.json()) as CommentsResponse;
}
export default function ArticleComments({ slug, section }: ArticleCommentsProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [body, setBody] = useState("");
const [magicEmail, setMagicEmail] = useState("");
const [data, setData] = useState<CommentsResponse>({
auth_configured: false,
auth_providers: {
configured: false,
google: false,
magic_link: false,
},
viewer: null,
comments: [],
});
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSendingMagicLink, setIsSendingMagicLink] = useState(false);
const [feedback, setFeedback] = useState("");
const authStatus = searchParams.get("comment_auth");
const returnToParams = new URLSearchParams(searchParams.toString());
returnToParams.delete("comment_auth");
const returnToQuery = returnToParams.toString();
const returnTo = `${pathname || "/"}${returnToQuery ? `?${returnToQuery}` : ""}`;
useEffect(() => {
let cancelled = false;
const run = async () => {
setIsLoading(true);
try {
const result = await fetchCommentsPayload(slug, section);
if (!cancelled) {
setData(result);
}
} catch (error) {
if (!cancelled) {
setFeedback(error instanceof Error ? error.message : "Kunne ikke hente kommentarer.");
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [section, slug]);
useEffect(() => {
if (!authStatus) return;
const nextUrl = returnTo || "/";
router.replace(nextUrl, { scroll: false });
if (authStatus === "google_success" || authStatus === "magic_success") {
let cancelled = false;
const run = async () => {
setFeedback(
authStatus === "google_success" ? "Du er logget inn med Google." : "Du er logget inn."
);
try {
const result = await fetchCommentsPayload(slug, section);
if (!cancelled) {
setData(result);
}
} catch {
if (!cancelled) {
setFeedback("Du er logget inn, men kommentarene kunne ikke hentes på nytt.");
}
}
};
void run();
return () => {
cancelled = true;
};
}
if (authStatus === "google_cancelled") {
setFeedback("Google-innlogging ble avbrutt.");
return;
}
if (authStatus === "blocked") {
setFeedback("Denne brukeren er blokkert fra å kommentere.");
return;
}
if (authStatus === "magic_expired") {
setFeedback("Innloggingslenken er utløpt. Be om en ny.");
return;
}
if (authStatus === "magic_invalid") {
setFeedback("Innloggingslenken er ugyldig eller allerede brukt.");
return;
}
setFeedback("Innloggingen feilet. Prøv igjen.");
}, [authStatus, returnTo, router, section, slug]);
const googleLoginHref = `/api/public/auth/google/start?return_to=${encodeURIComponent(returnTo)}`;
const handleSubmit = async () => {
const trimmed = body.trim();
if (trimmed.length < 3) {
setFeedback("Kommentaren må være minst 3 tegn.");
return;
}
setIsSubmitting(true);
setFeedback("");
try {
const response = await fetch(`/api/articles/${slug}/comments?section=${section}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ body: trimmed }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.detail || "Kunne ikke lagre kommentaren.");
}
setBody("");
setFeedback(result.detail || "Kommentaren er lagret.");
setData(await fetchCommentsPayload(slug, section));
} catch (error) {
setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre kommentaren.");
} finally {
setIsSubmitting(false);
}
};
const handleMagicLinkRequest = async () => {
const trimmed = magicEmail.trim();
if (!trimmed || !trimmed.includes("@")) {
setFeedback("Oppgi en gyldig e-postadresse.");
return;
}
setIsSendingMagicLink(true);
setFeedback("");
try {
const response = await fetch("/api/public/auth/magic-link/request", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
email: trimmed,
return_to: returnTo,
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.detail || "Kunne ikke sende innloggingslenke.");
}
setFeedback(
result.detail || "Hvis adressen kan brukes til innlogging, sender vi deg en lenke nå."
);
} catch (error) {
setFeedback(
error instanceof Error ? error.message : "Kunne ikke sende innloggingslenke."
);
} finally {
setIsSendingMagicLink(false);
}
};
const handleLogout = async () => {
await fetch("/api/public/auth/logout", {
method: "POST",
credentials: "include",
});
setFeedback("Du er logget ut.");
setData(await fetchCommentsPayload(slug, section));
};
return (
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
Kommentarer
</p>
<h2 className="mt-3 text-3xl font-black text-[#112015]">Delta i praten</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[#536256]">
Kommentarer krever innlogging. Du kan bruke Google eller en engangslenke sendt til
e-postadressen din.
</p>
</div>
{data.viewer ? (
<div className="rounded-[1.4rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-3 text-sm font-bold text-[#334238]">
{data.viewer.display_name || data.viewer.full_name || "Innlogget"}
</div>
) : null}
</div>
{feedback ? (
<div className="mt-5 rounded-[1.4rem] border border-[#112015]/8 bg-white px-4 py-4 text-sm font-bold text-[#334238]">
{feedback}
</div>
) : null}
<div className="mt-6 rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] p-4 sm:p-5">
{!data.auth_configured ? (
<p className="text-sm font-bold text-[#334238]">
Ingen innloggingsmetoder er konfigurert ennå. Legg inn Google OAuth og SMTP før
kommentarinnlogging kan åpnes.
</p>
) : data.viewer ? (
<div className="space-y-4">
<textarea
rows={5}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="Skriv kommentaren din her..."
className="w-full rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className="rounded-full bg-[#112015] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A] disabled:opacity-50"
>
{isSubmitting ? "Publiserer..." : "Publiser kommentar"}
</button>
<button
type="button"
onClick={handleLogout}
className="rounded-full border border-[#112015]/10 bg-white px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A]"
>
Logg ut
</button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
{data.auth_providers?.google ? (
<Link
href={googleLoginHref}
className="rounded-full bg-[#112015] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
>
Fortsett med Google
</Link>
) : null}
<p className="text-sm font-bold text-[#536256]">
Du være innlogget for å kommentere.
</p>
</div>
{data.auth_providers?.magic_link ? (
<div className="rounded-[1.4rem] border border-[#112015]/8 bg-white p-4">
<p className="text-[11px] font-black uppercase tracking-[0.16em] text-[#6A766C]">
Eller via e-post
</p>
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
<input
type="email"
value={magicEmail}
onChange={(event) => setMagicEmail(event.target.value)}
placeholder="din@epost.no"
className="min-w-0 flex-1 rounded-full border border-[#112015]/10 bg-[#F7F9F2] px-4 py-3 text-sm text-[#112015] outline-none focus:border-[#8BC34A]"
/>
<button
type="button"
onClick={handleMagicLinkRequest}
disabled={isSendingMagicLink}
className="rounded-full bg-[#FF5722] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D] disabled:opacity-50"
>
{isSendingMagicLink ? "Sender..." : "Send innloggingslenke"}
</button>
</div>
</div>
) : null}
</div>
)}
</div>
<div className="mt-8 space-y-4">
{isLoading ? (
<div className="rounded-[1.4rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-5 text-sm font-bold text-[#536256]">
Laster kommentarer...
</div>
) : null}
{!isLoading && data.comments.length === 0 ? (
<div className="rounded-[1.4rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-5 text-sm font-bold text-[#536256]">
Ingen kommentarer ennå.
</div>
) : null}
{data.comments.map((comment) => (
<article
key={comment.id}
className={`rounded-[1.5rem] border px-4 py-4 sm:px-5 ${
comment.status === "pending"
? "border-amber-200 bg-amber-50"
: "border-[#112015]/8 bg-white"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-black text-[#112015]">
{comment.author?.display_name || "TeeOff-leser"}
</p>
<p className="mt-1 text-[11px] font-bold uppercase tracking-[0.16em] text-[#6A766C]">
{formatCommentDate(comment.created_at)}
</p>
</div>
{comment.status === "pending" ? (
<span className="rounded-full bg-amber-100 px-3 py-2 text-[10px] font-black uppercase tracking-[0.16em] text-amber-800">
Venter kontroll
</span>
) : null}
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[#334238]">{comment.body}</p>
</article>
))}
</div>
</section>
);
}

View file

@ -74,6 +74,7 @@ const primaryNavItems: NavItem[] = [
{ href: "/medlemskap", label: "Medlemskap" },
{ href: "/vtg", label: "VTG" },
{ href: "/banebesok", label: "Banebesøk" },
{ href: "/meninger", label: "Meninger" },
];
const resourceNavItems: NavItem[] = [

View file

@ -1,432 +1,17 @@
import { API_URL } from "@/config/constants";
import importedMeninger from "@/content/importedMeninger.json";
export type {
ArticleSection,
CourseVisitBodyBlock,
CourseVisitFact,
CourseVisitImage,
EditorialArticle as CourseVisitArticle,
} from "@/content/editorialArticles";
export type CourseVisitImage = {
src: string;
alt: string;
caption: string;
};
export type CourseVisitFact = {
label: string;
value: string;
href?: string;
};
export type CourseVisitBodyBlock =
| {
type: "richText";
title?: string;
html: string;
}
| {
type: "quote";
quote: string;
attribution?: string;
}
| {
type: "checklist";
title: string;
items: string[];
}
| {
type: "factGrid";
title: string;
items: CourseVisitFact[];
}
| {
type: "callout";
title: string;
body: string;
};
export type CourseVisitArticle = {
slug: string;
eyebrow: string;
title: string;
description: string;
excerpt: string;
locationLabel: string;
facilityName: string;
facilitySlug: string;
publishedAt: string;
updatedAt?: string;
readingTime: string;
heroImages: CourseVisitImage[];
quickFacts: CourseVisitFact[];
highlights: string[];
blocks: CourseVisitBodyBlock[];
sourceUrl?: string;
sourceLabel?: string;
};
type ImportedMeningerRecord = {
id: number;
slug: string;
title: string;
excerpt: string;
contentHtml: string;
publishedAt: string;
updatedAt?: string;
link?: string;
author?: {
name?: string | null;
};
featuredImage?: {
url?: string | null;
alt?: string | null;
caption?: string | null;
} | null;
facilitySlugs?: string[];
primaryFacilitySlug?: string | null;
};
type CourseVisitApiRecord = {
id?: number;
slug: string;
title: string;
description?: string | null;
excerpt?: string | null;
eyebrow?: string | null;
location_label?: string | null;
facility_name?: string | null;
facility_slug?: string | null;
author_name?: string | null;
hero_images?: CourseVisitImage[] | null;
content_html?: string | null;
source_url?: string | null;
source_label?: string | null;
published_at?: string | null;
updated_at?: string | null;
};
type FacilityMeta = {
name: string;
region: string;
};
const facilityMetaBySlug: Record<string, FacilityMeta> = {
"lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" },
"kjekstad-golfklubb": { name: "Kjekstad Golfklubb", region: "Buskerud" },
"kragero-golfklubb": { name: "Kragerø Golfklubb", region: "Telemark" },
"egersund-golfklubb": { name: "Egersund Golfklubb", region: "Rogaland" },
"tyrifjord-golfklubb": { name: "Tyrifjord Golfklubb", region: "Buskerud" },
"kongsvingers-golfklubb": { name: "Kongsvingers Golfklubb", region: "Innlandet" },
"drammen-golfklubb": { name: "Drammen Golfklubb", region: "Buskerud" },
};
const teeoffInternalLinkPattern = /https?:\/\/teeoff\.no\/([^"'#?\s>]+)/gi;
const imageTagPattern = /<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*\balt=['"]([^'"]*)['"][^>]*>/gi;
const imageTagWithoutAltPattern = /<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*>/gi;
const disallowedSegments = new Set(["wp-content", "wp-json", "meninger", "category", "author", "tag", "feed"]);
function decodeEntities(value: string) {
return value
.replace(/&#8230;/g, "...")
.replace(/&hellip;/g, "...")
.replace(/&nbsp;/g, " ")
.replace(/&laquo;/g, "«")
.replace(/&raquo;/g, "»")
.replace(/&#038;/g, "&")
.replace(/&amp;/g, "&");
}
function stripHtml(value: string) {
return decodeEntities(value).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("nb-NO", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(value));
}
function getReadingTime(html: string) {
const wordCount = stripHtml(html).split(/\s+/).filter(Boolean).length;
const minutes = Math.max(3, Math.round(wordCount / 220));
return `${minutes} min`;
}
function getFacilityMeta(slug: string) {
return facilityMetaBySlug[slug] || {
name: slug
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" "),
region: "Norge",
};
}
function normalizeInternalLinks(html: string) {
return html.replace(teeoffInternalLinkPattern, (fullMatch, rawPath: string) => {
const path = rawPath.split("?")[0].replace(/\/+$/, "");
const segments = path.split("/").filter(Boolean);
if (segments.length === 0 || disallowedSegments.has(segments[0])) {
return fullMatch;
}
const candidate = segments[segments.length - 1];
if (!candidate.includes("golf")) {
return fullMatch;
}
return `/golfbaner/${candidate}`;
});
}
function extractImagesFromHtml(html: string, articleTitle: string) {
const images: CourseVisitImage[] = [];
const seen = new Set<string>();
for (const match of html.matchAll(imageTagPattern)) {
const src = match[1];
const alt = decodeEntities(match[2] || "").trim();
if (!src || seen.has(src) || (!src.includes("/wp-content/uploads/") && !src.includes("i.ytimg.com"))) {
continue;
}
seen.add(src);
images.push({
src,
alt: alt || articleTitle,
caption: alt || articleTitle,
});
}
if (images.length === 0) {
for (const match of html.matchAll(imageTagWithoutAltPattern)) {
const src = match[1];
if (!src || seen.has(src) || !src.includes("/wp-content/uploads/")) {
continue;
}
seen.add(src);
images.push({
src,
alt: articleTitle,
caption: articleTitle,
});
}
}
return images;
}
function mapImportedArticle(entry: ImportedMeningerRecord): CourseVisitArticle | null {
const facilitySlug = entry.primaryFacilitySlug || entry.facilitySlugs?.[0];
if (!facilitySlug) {
return null;
}
const facilityMeta = getFacilityMeta(facilitySlug);
const normalizedHtml = normalizeInternalLinks(entry.contentHtml || "");
const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title);
const featuredImage = entry.featuredImage?.url
? [
{
src: entry.featuredImage.url,
alt: entry.featuredImage.alt || entry.title,
caption: entry.featuredImage.caption || entry.title,
},
]
: [];
const heroImages = [...featuredImage, ...extractedImages]
.filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index)
.slice(0, 6);
const excerpt = entry.excerpt || stripHtml(normalizedHtml).slice(0, 220);
const formattedPublishedAt = formatDate(entry.publishedAt);
return {
slug: entry.slug,
eyebrow: "Banebesøk",
title: entry.title,
description: excerpt,
excerpt,
locationLabel: facilityMeta.region,
facilityName: facilityMeta.name,
facilitySlug,
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
readingTime: getReadingTime(normalizedHtml),
heroImages:
heroImages.length > 0
? heroImages
: [
{
src: "/Toppbilde-standard.jpg",
alt: entry.title,
caption: entry.title,
},
],
quickFacts: [
{
label: "Baneprofil",
value: facilityMeta.name,
href: `/golfbaner/${facilitySlug}`,
},
{
label: "Publisert",
value: formattedPublishedAt,
},
{
label: "Forfatter",
value: entry.author?.name || "TeeOff",
},
{
label: "Kildespor",
value: "Importert fra gamle TeeOff",
},
],
highlights: [
"Originalartikkel importert fra gamle TeeOff.",
`Koblet til dagens baneprofil for ${facilityMeta.name}.`,
"Bevarer originaltekst, originale bilder og langlesingsformat.",
"Kan senere flyttes til database eller editor uten å kaste artikkel-UI-et.",
],
blocks: [
{
type: "richText",
title: "Original artikkel",
html: normalizedHtml,
},
],
sourceUrl: entry.link,
sourceLabel: "Importert fra gamle TeeOff",
};
}
function mapApiArticle(entry: CourseVisitApiRecord): CourseVisitArticle | null {
const facilitySlug = String(entry.facility_slug || "").trim();
if (!facilitySlug) {
return null;
}
const facilityMeta = getFacilityMeta(facilitySlug);
const facilityName = String(entry.facility_name || "").trim() || facilityMeta.name;
const locationLabel = String(entry.location_label || "").trim() || facilityMeta.region;
const normalizedHtml = normalizeInternalLinks(String(entry.content_html || ""));
const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title);
const dbImages = Array.isArray(entry.hero_images) ? entry.hero_images : [];
const heroImages = [...dbImages, ...extractedImages]
.filter((image): image is CourseVisitImage => Boolean(image?.src))
.map((image) => ({
src: image.src,
alt: image.alt || entry.title,
caption: image.caption || image.alt || entry.title,
}))
.filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index)
.slice(0, 6);
const publishedAt = String(entry.published_at || entry.updated_at || "");
const excerpt =
String(entry.excerpt || "").trim() ||
String(entry.description || "").trim() ||
stripHtml(normalizedHtml).slice(0, 220);
return {
slug: entry.slug,
eyebrow: String(entry.eyebrow || "").trim() || "Banebesøk",
title: entry.title,
description: String(entry.description || "").trim() || excerpt,
excerpt,
locationLabel,
facilityName,
facilitySlug,
publishedAt,
updatedAt: String(entry.updated_at || "").trim() || undefined,
readingTime: getReadingTime(normalizedHtml),
heroImages:
heroImages.length > 0
? heroImages
: [
{
src: "/Toppbilde-standard.jpg",
alt: entry.title,
caption: entry.title,
},
],
quickFacts: [
{
label: "Baneprofil",
value: facilityName,
href: `/golfbaner/${facilitySlug}`,
},
{
label: "Publisert",
value: publishedAt ? formatDate(publishedAt) : "Ikke datert",
},
{
label: "Forfatter",
value: String(entry.author_name || "").trim() || "TeeOff",
},
...(entry.source_label
? [
{
label: "Kildespor",
value: String(entry.source_label),
},
]
: []),
],
highlights: [
`Koblet til dagens baneprofil for ${facilityName}.`,
"Lagret som redaksjonell artikkel i TeeOff-admin.",
"Kan redigeres videre som HTML uten å miste artikkeloppsettet.",
"Beholder mobilvennlig hero, faktaboks og langlesingsstruktur.",
],
blocks: [
{
type: "richText",
title: "Artikkel",
html: normalizedHtml,
},
],
sourceUrl: String(entry.source_url || "").trim() || undefined,
sourceLabel: String(entry.source_label || "").trim() || undefined,
};
}
const fallbackCourseVisits = (importedMeninger as ImportedMeningerRecord[])
.map(mapImportedArticle)
.filter((article): article is CourseVisitArticle => Boolean(article))
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
export async function getCourseVisits() {
try {
const response = await fetch(`${API_URL}/course-visits`, { cache: "no-store" });
if (response.ok) {
const data = await response.json();
if (Array.isArray(data)) {
const mapped = data
.map((entry) => mapApiArticle(entry as CourseVisitApiRecord))
.filter((article): article is CourseVisitArticle => Boolean(article));
if (mapped.length > 0) {
return mapped;
}
}
}
} catch {
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
}
return fallbackCourseVisits;
}
export async function getCourseVisitBySlug(slug: string) {
try {
const response = await fetch(`${API_URL}/course-visits/${slug}`, { cache: "no-store" });
if (response.ok) {
const data = await response.json();
const mapped = mapApiArticle(data as CourseVisitApiRecord);
if (mapped) {
return mapped;
}
}
} catch {
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
}
return fallbackCourseVisits.find((article) => article.slug === slug);
}
export {
buildEditorialPath,
getCourseVisitBySlug,
getCourseVisits,
getEditorialArticleBySlug,
getEditorialArticles,
getOpinionArticleBySlug,
getOpinionArticles,
} from "@/content/editorialArticles";

View file

@ -0,0 +1,540 @@
import { API_URL } from "@/config/constants";
import importedMeninger from "@/content/importedMeninger.json";
export type ArticleSection = "banebesok" | "meninger";
export type CourseVisitImage = {
src: string;
alt: string;
caption: string;
};
export type CourseVisitFact = {
label: string;
value: string;
href?: string;
};
export type CourseVisitBodyBlock =
| {
type: "richText";
title?: string;
html: string;
}
| {
type: "quote";
quote: string;
attribution?: string;
}
| {
type: "checklist";
title: string;
items: string[];
}
| {
type: "factGrid";
title: string;
items: CourseVisitFact[];
}
| {
type: "callout";
title: string;
body: string;
};
export type EditorialArticle = {
section: ArticleSection;
slug: string;
eyebrow: string;
title: string;
description: string;
excerpt: string;
locationLabel: string;
facilityName?: string;
facilitySlug?: string;
publishedAt: string;
updatedAt?: string;
readingTime: string;
heroImages: CourseVisitImage[];
quickFacts: CourseVisitFact[];
highlights: string[];
blocks: CourseVisitBodyBlock[];
sourceUrl?: string;
sourceLabel?: string;
};
type ImportedCategory = {
name?: string | null;
slug?: string | null;
};
type ImportedMeningerRecord = {
id: number;
slug: string;
title: string;
excerpt: string;
contentHtml: string;
publishedAt: string;
updatedAt?: string;
link?: string;
author?: {
name?: string | null;
};
featuredImage?: {
url?: string | null;
alt?: string | null;
caption?: string | null;
} | null;
categories?: ImportedCategory[];
categorySlugs?: string[];
facilitySlugs?: string[];
primaryFacilitySlug?: string | null;
};
type ArticleApiRecord = {
id?: number;
section?: string | null;
slug: string;
title: string;
description?: string | null;
excerpt?: string | null;
eyebrow?: string | null;
location_label?: string | null;
facility_name?: string | null;
facility_slug?: string | null;
author_name?: string | null;
hero_images?: CourseVisitImage[] | null;
content_html?: string | null;
source_url?: string | null;
source_label?: string | null;
published_at?: string | null;
updated_at?: string | null;
};
type FacilityMeta = {
name: string;
region: string;
};
const facilityMetaBySlug: Record<string, FacilityMeta> = {
"lofoten-golfklubb": { name: "Lofoten Golfklubb", region: "Nordland" },
"kjekstad-golfklubb": { name: "Kjekstad Golfklubb", region: "Buskerud" },
"kragero-golfklubb": { name: "Kragerø Golfklubb", region: "Telemark" },
"egersund-golfklubb": { name: "Egersund Golfklubb", region: "Rogaland" },
"tyrifjord-golfklubb": { name: "Tyrifjord Golfklubb", region: "Buskerud" },
"kongsvingers-golfklubb": { name: "Kongsvingers Golfklubb", region: "Innlandet" },
"drammen-golfklubb": { name: "Drammen Golfklubb", region: "Buskerud" },
};
const teeoffInternalLinkPattern = /https?:\/\/teeoff\.no\/([^"'#?\s>]+)/gi;
const imageTagPattern = /<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*\balt=['"]([^'"]*)['"][^>]*>/gi;
const imageTagWithoutAltPattern = /<img\b[^>]*\bsrc=['"]([^'"]+)['"][^>]*>/gi;
const disallowedSegments = new Set(["wp-content", "wp-json", "meninger", "category", "author", "tag", "feed"]);
function normalizeSection(value?: string | null): ArticleSection {
return String(value || "").trim().toLowerCase() === "meninger" ? "meninger" : "banebesok";
}
export function buildEditorialPath(section: ArticleSection, slug: string) {
return `/${section}/${slug}`;
}
function getSectionLabel(section: ArticleSection) {
return section === "meninger" ? "Meninger" : "Banebesøk";
}
function resolveImportedSection(entry: ImportedMeningerRecord): {
section: ArticleSection;
eyebrow: string;
} {
const slugSet = new Set(
(entry.categorySlugs || [])
.map((slug) => String(slug || "").trim().toLowerCase())
.filter(Boolean),
);
if (slugSet.has("banebesok")) {
return { section: "banebesok", eyebrow: "Banebesøk" };
}
if (slugSet.has("siste-nytt")) {
return { section: "meninger", eyebrow: "Siste nytt" };
}
const categoryLabel = (entry.categories || [])
.map((category) => String(category?.name || "").trim())
.find(Boolean);
return {
section: "meninger",
eyebrow: categoryLabel || "Meninger",
};
}
function decodeEntities(value: string) {
return value
.replace(/&#8230;/g, "...")
.replace(/&hellip;/g, "...")
.replace(/&nbsp;/g, " ")
.replace(/&laquo;/g, "«")
.replace(/&raquo;/g, "»")
.replace(/&#038;/g, "&")
.replace(/&amp;/g, "&");
}
function stripHtml(value: string) {
return decodeEntities(value).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("nb-NO", {
day: "numeric",
month: "long",
year: "numeric",
}).format(new Date(value));
}
function getReadingTime(html: string) {
const wordCount = stripHtml(html).split(/\s+/).filter(Boolean).length;
const minutes = Math.max(3, Math.round(wordCount / 220));
return `${minutes} min`;
}
function getFacilityMeta(slug: string) {
return facilityMetaBySlug[slug] || {
name: slug
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" "),
region: "Norge",
};
}
function normalizeInternalLinks(html: string) {
return html.replace(teeoffInternalLinkPattern, (fullMatch, rawPath: string) => {
const path = rawPath.split("?")[0].replace(/\/+$/, "");
const segments = path.split("/").filter(Boolean);
if (segments.length === 0 || disallowedSegments.has(segments[0])) {
return fullMatch;
}
const candidate = segments[segments.length - 1];
if (!candidate.includes("golf")) {
return fullMatch;
}
return `/golfbaner/${candidate}`;
});
}
function extractImagesFromHtml(html: string, articleTitle: string) {
const images: CourseVisitImage[] = [];
const seen = new Set<string>();
for (const match of html.matchAll(imageTagPattern)) {
const src = match[1];
const alt = decodeEntities(match[2] || "").trim();
if (!src || seen.has(src) || (!src.includes("/wp-content/uploads/") && !src.includes("i.ytimg.com"))) {
continue;
}
seen.add(src);
images.push({
src,
alt: alt || articleTitle,
caption: alt || articleTitle,
});
}
if (images.length === 0) {
for (const match of html.matchAll(imageTagWithoutAltPattern)) {
const src = match[1];
if (!src || seen.has(src) || !src.includes("/wp-content/uploads/")) {
continue;
}
seen.add(src);
images.push({
src,
alt: articleTitle,
caption: articleTitle,
});
}
}
return images;
}
function buildQuickFacts(args: {
facilityName?: string;
facilitySlug?: string;
publishedAt?: string;
authorName?: string;
sourceLabel?: string;
}) {
const facts: CourseVisitFact[] = [];
if (args.facilityName && args.facilitySlug) {
facts.push({
label: "Baneprofil",
value: args.facilityName,
href: `/golfbaner/${args.facilitySlug}`,
});
}
if (args.publishedAt) {
facts.push({
label: "Publisert",
value: formatDate(args.publishedAt),
});
} else {
facts.push({
label: "Publisert",
value: "Ikke datert",
});
}
facts.push({
label: "Forfatter",
value: args.authorName || "TeeOff",
});
if (args.sourceLabel) {
facts.push({
label: "Kildespor",
value: args.sourceLabel,
});
}
return facts;
}
function buildHighlights(section: ArticleSection, facilityName?: string) {
const highlights = [
"Lagret som redaksjonell artikkel i TeeOff-admin.",
"Kan redigeres videre som HTML uten å miste artikkeloppsettet.",
];
if (facilityName) {
highlights.unshift(`Koblet til dagens baneprofil for ${facilityName}.`);
} else if (section === "meninger") {
highlights.unshift("Står på egne ben uten krav om kobling til baneprofil.");
} else {
highlights.unshift("Banebesøk uten baneprofilkobling kan nå publiseres som egne artikler.");
}
if (section === "meninger") {
highlights.push("Brukes for redaksjonelle artikler, siste nytt og andre meningsposter.");
} else {
highlights.push("Beholder mobilvennlig hero, faktaboks og langlesingsstruktur.");
}
return highlights;
}
function mapImportedArticle(entry: ImportedMeningerRecord): EditorialArticle {
const { section, eyebrow } = resolveImportedSection(entry);
const facilitySlug = entry.primaryFacilitySlug || entry.facilitySlugs?.[0] || undefined;
const facilityMeta = facilitySlug ? getFacilityMeta(facilitySlug) : null;
const facilityName = facilityMeta?.name;
const locationLabel = facilityMeta?.region || "Norge";
const normalizedHtml = normalizeInternalLinks(entry.contentHtml || "");
const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title);
const featuredImage = entry.featuredImage?.url
? [
{
src: entry.featuredImage.url,
alt: entry.featuredImage.alt || entry.title,
caption: entry.featuredImage.caption || entry.title,
},
]
: [];
const heroImages = [...featuredImage, ...extractedImages]
.filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index)
.slice(0, 6);
const excerpt = entry.excerpt || stripHtml(normalizedHtml).slice(0, 220);
return {
section,
slug: entry.slug,
eyebrow,
title: entry.title,
description: excerpt,
excerpt,
locationLabel,
facilityName,
facilitySlug,
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
readingTime: getReadingTime(normalizedHtml),
heroImages:
heroImages.length > 0
? heroImages
: [
{
src: "/Toppbilde-standard.jpg",
alt: entry.title,
caption: entry.title,
},
],
quickFacts: buildQuickFacts({
facilityName,
facilitySlug,
publishedAt: entry.publishedAt,
authorName: entry.author?.name || "TeeOff",
sourceLabel: "Importert fra gamle TeeOff",
}),
highlights: [
"Originalartikkel importert fra gamle TeeOff.",
...buildHighlights(section, facilityName),
],
blocks: [
{
type: "richText",
title: "Original artikkel",
html: normalizedHtml,
},
],
sourceUrl: entry.link,
sourceLabel: "Importert fra gamle TeeOff",
};
}
function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
const section = normalizeSection(entry.section);
const facilitySlug = String(entry.facility_slug || "").trim() || undefined;
const facilityMeta = facilitySlug ? getFacilityMeta(facilitySlug) : null;
const facilityName = String(entry.facility_name || "").trim() || facilityMeta?.name || undefined;
const locationLabel = String(entry.location_label || "").trim() || facilityMeta?.region || "Norge";
const normalizedHtml = normalizeInternalLinks(String(entry.content_html || ""));
const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title);
const dbImages = Array.isArray(entry.hero_images) ? entry.hero_images : [];
const heroImages = [...dbImages, ...extractedImages]
.filter((image): image is CourseVisitImage => Boolean(image?.src))
.map((image) => ({
src: image.src,
alt: image.alt || entry.title,
caption: image.caption || image.alt || entry.title,
}))
.filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index)
.slice(0, 6);
const publishedAt = String(entry.published_at || entry.updated_at || "").trim();
const excerpt =
String(entry.excerpt || "").trim() ||
String(entry.description || "").trim() ||
stripHtml(normalizedHtml).slice(0, 220);
return {
section,
slug: entry.slug,
eyebrow: String(entry.eyebrow || "").trim() || getSectionLabel(section),
title: entry.title,
description: String(entry.description || "").trim() || excerpt,
excerpt,
locationLabel,
facilityName,
facilitySlug,
publishedAt,
updatedAt: String(entry.updated_at || "").trim() || undefined,
readingTime: getReadingTime(normalizedHtml),
heroImages:
heroImages.length > 0
? heroImages
: [
{
src: "/Toppbilde-standard.jpg",
alt: entry.title,
caption: entry.title,
},
],
quickFacts: buildQuickFacts({
facilityName,
facilitySlug,
publishedAt,
authorName: String(entry.author_name || "").trim() || "TeeOff",
sourceLabel: String(entry.source_label || "").trim() || undefined,
}),
highlights: buildHighlights(section, facilityName),
blocks: [
{
type: "richText",
title: "Artikkel",
html: normalizedHtml,
},
],
sourceUrl: String(entry.source_url || "").trim() || undefined,
sourceLabel: String(entry.source_label || "").trim() || undefined,
};
}
const fallbackEditorialArticles = (importedMeninger as ImportedMeningerRecord[])
.map(mapImportedArticle)
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
function getFallbackArticles(section: ArticleSection) {
return fallbackEditorialArticles.filter((article) => article.section === section);
}
async function fetchPublishedArticles(section: ArticleSection) {
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
if (!response.ok) {
return null;
}
const data = await response.json();
if (!Array.isArray(data)) {
return null;
}
return data.map((entry) => mapApiArticle(entry as ArticleApiRecord));
}
async function fetchPublishedArticleBySlug(slug: string, section: ArticleSection) {
const response = await fetch(`${API_URL}/articles/${slug}?section=${section}`, { cache: "no-store" });
if (!response.ok) {
return null;
}
const data = await response.json();
return mapApiArticle(data as ArticleApiRecord);
}
export async function getEditorialArticles(section: ArticleSection) {
try {
const mapped = await fetchPublishedArticles(section);
if (mapped && mapped.length > 0) {
return mapped;
}
} catch {
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
}
return getFallbackArticles(section);
}
export async function getEditorialArticleBySlug(slug: string, section: ArticleSection) {
try {
const mapped = await fetchPublishedArticleBySlug(slug, section);
if (mapped) {
return mapped;
}
} catch {
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
}
return getFallbackArticles(section).find((article) => article.slug === slug);
}
export async function getCourseVisits() {
return getEditorialArticles("banebesok");
}
export async function getCourseVisitBySlug(slug: string) {
return getEditorialArticleBySlug(slug, "banebesok");
}
export async function getOpinionArticles() {
return getEditorialArticles("meninger");
}
export async function getOpinionArticleBySlug(slug: string) {
return getEditorialArticleBySlug(slug, "meninger");
}

File diff suppressed because one or more lines are too long