Etter å ha koblet til Google og Magic link
This commit is contained in:
parent
e1fcabef6a
commit
1b09e88fd3
15 changed files with 6783 additions and 486 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
.env
|
||||||
|
|
|
||||||
1065
backend/main.py
1065
backend/main.py
File diff suppressed because it is too large
Load diff
|
|
@ -15,10 +15,23 @@ services:
|
||||||
api:
|
api:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: teeoff_api
|
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:
|
ports:
|
||||||
- "8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
- ./frontend/src/content:/shared/frontend-content:ro
|
||||||
# Denne linjen sørger for at bilder lagres direkte i frontendens public-mappe:
|
# Denne linjen sørger for at bilder lagres direkte i frontendens public-mappe:
|
||||||
- ./frontend/public/media:/app/public/media
|
- ./frontend/public/media:/app/public/media
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ import nextTs from "eslint-config-next/typescript";
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...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.
|
// Override default ignores of eslint-config-next.
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
// Default ignores of eslint-config-next:
|
// Default ignores of eslint-config-next:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
|
||||||
|
|
||||||
type AdminArticle = {
|
type AdminArticle = {
|
||||||
id: number;
|
id: number;
|
||||||
|
section?: "banebesok" | "meninger";
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
@ -37,6 +38,7 @@ type FacilityOption = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type ArticleFormState = {
|
type ArticleFormState = {
|
||||||
|
section: "banebesok" | "meninger";
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -79,6 +81,7 @@ function heroImagesToText(images?: AdminArticle["hero_images"]) {
|
||||||
|
|
||||||
function createEmptyForm(): ArticleFormState {
|
function createEmptyForm(): ArticleFormState {
|
||||||
return {
|
return {
|
||||||
|
section: "banebesok",
|
||||||
slug: "",
|
slug: "",
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
@ -99,6 +102,7 @@ function createEmptyForm(): ArticleFormState {
|
||||||
|
|
||||||
function articleToForm(article: AdminArticle): ArticleFormState {
|
function articleToForm(article: AdminArticle): ArticleFormState {
|
||||||
return {
|
return {
|
||||||
|
section: article.section || "banebesok",
|
||||||
slug: article.slug || "",
|
slug: article.slug || "",
|
||||||
title: article.title || "",
|
title: article.title || "",
|
||||||
description: article.description || "",
|
description: article.description || "",
|
||||||
|
|
@ -191,7 +195,7 @@ export default function AdminArticlesPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFieldChange = (field: keyof ArticleFormState, value: string) => {
|
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) => {
|
const uploadArticleImage = async (file: File) => {
|
||||||
|
|
@ -284,6 +288,7 @@ export default function AdminArticlesPage() {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...form,
|
||||||
|
section: form.section,
|
||||||
slug: form.slug.trim(),
|
slug: form.slug.trim(),
|
||||||
title: form.title.trim(),
|
title: form.title.trim(),
|
||||||
description: form.description.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]">
|
<Link href="/admin" className="text-sm font-bold text-gray-500 transition hover:text-[#8bc34a]">
|
||||||
← Tilbake til admin
|
← Tilbake til admin
|
||||||
</Link>
|
</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]">
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#536256]">
|
||||||
Første adminversjon for redaksjonelle artikler. Denne bruker nå Tiptap for innhold,
|
Redaksjonelle artikler kan nå ligge i egne seksjoner. Denne editoren bruker Tiptap,
|
||||||
lagrer fortsatt HTML i databasen, og kan seedes fra de importerte Banebesøk-artiklene.
|
lagrer HTML i databasen og kan seede både Banebesøk og Meninger fra importfilen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<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]">
|
<p className="mt-2 text-[11px] font-bold uppercase tracking-[0.14em] text-[#6A766C]">
|
||||||
/{article.slug}
|
/{article.slug}
|
||||||
</p>
|
</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]">
|
<p className="mt-3 text-sm leading-6 text-[#536256]">
|
||||||
{article.facility_name || "Uten koblet bane"}
|
{article.facility_name || "Uten koblet bane"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -469,7 +477,18 @@ export default function AdminArticlesPage() {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="surface-card rounded-[2rem] p-5 sm:p-8">
|
<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">
|
<label className="flex flex-col gap-2">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Tittel</span>
|
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Tittel</span>
|
||||||
<input
|
<input
|
||||||
|
|
@ -684,7 +703,7 @@ export default function AdminArticlesPage() {
|
||||||
) : null}
|
) : null}
|
||||||
{form.slug ? (
|
{form.slug ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/banebesok/${form.slug}`}
|
href={`/${form.section}/${form.slug}`}
|
||||||
target="_blank"
|
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]"
|
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]"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import CourseVisitGallery from "@/components/CourseVisitGallery";
|
import CourseVisitGallery from "@/components/CourseVisitGallery";
|
||||||
|
import ArticleComments from "@/components/ArticleComments";
|
||||||
import InfoPageShell from "@/components/InfoPageShell";
|
import InfoPageShell from "@/components/InfoPageShell";
|
||||||
import { getCourseVisitBySlug, type CourseVisitBodyBlock } from "@/content/courseVisits";
|
import { getCourseVisitBySlug, type CourseVisitBodyBlock } from "@/content/courseVisits";
|
||||||
import {
|
import {
|
||||||
|
|
@ -166,11 +167,15 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
|
||||||
publisher: {
|
publisher: {
|
||||||
"@id": ORGANIZATION_ID,
|
"@id": ORGANIZATION_ID,
|
||||||
},
|
},
|
||||||
about: {
|
...(article.facilityName && article.facilitySlug
|
||||||
"@type": "GolfCourse",
|
? {
|
||||||
name: article.facilityName,
|
about: {
|
||||||
url: buildAbsoluteUrl(`/golfbaner/${article.facilitySlug}`),
|
"@type": "GolfCourse",
|
||||||
},
|
name: article.facilityName,
|
||||||
|
url: buildAbsoluteUrl(`/golfbaner/${article.facilitySlug}`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -193,6 +198,7 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<CourseVisitGallery title={article.title} images={article.heroImages} />
|
<CourseVisitGallery title={article.title} images={article.heroImages} />
|
||||||
{article.blocks.map((block, index) => renderBlock(block, index))}
|
{article.blocks.map((block, index) => renderBlock(block, index))}
|
||||||
|
<ArticleComments slug={article.slug} section="banebesok" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="space-y-6">
|
<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]">
|
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
|
||||||
Videre fra artikkelen
|
Videre fra artikkelen
|
||||||
</p>
|
</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">
|
<p className="mt-3 text-sm leading-6 text-white/78">
|
||||||
Bruk artikkelen for inspirasjon, og gå videre til baneprofilen når du trenger
|
{article.facilitySlug
|
||||||
praktiske detaljer og oppdatert klubbinfo.
|
? "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>
|
</p>
|
||||||
<div className="mt-5 flex flex-col gap-3">
|
<div className="mt-5 flex flex-col gap-3">
|
||||||
<Link
|
{article.facilitySlug ? (
|
||||||
href={`/golfbaner/${article.facilitySlug}`}
|
<Link
|
||||||
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]"
|
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>
|
Åpne baneprofil
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href="/banebesok"
|
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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,14 @@ export default async function CourseVisitsPage() {
|
||||||
>
|
>
|
||||||
Les artikkelen
|
Les artikkelen
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{featuredArticle.facilitySlug ? (
|
||||||
href={`/golfbaner/${featuredArticle.facilitySlug}`}
|
<Link
|
||||||
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"
|
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"
|
||||||
Gå til baneprofil
|
>
|
||||||
</Link>
|
Gå til baneprofil
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -171,12 +173,14 @@ export default async function CourseVisitsPage() {
|
||||||
>
|
>
|
||||||
Åpne artikkel
|
Åpne artikkel
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
{article.facilitySlug ? (
|
||||||
href={`/golfbaner/${article.facilitySlug}`}
|
<Link
|
||||||
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]"
|
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>
|
Baneprofil
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
279
frontend/src/app/meninger/[slug]/page.tsx
Normal file
279
frontend/src/app/meninger/[slug]/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/src/app/meninger/page.tsx
Normal file
192
frontend/src/app/meninger/page.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
Gå 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 nå 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 nå 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import { getAvailablePlaceConfigs } from "@/app/facilityData";
|
import { getAvailablePlaceConfigs } from "@/app/facilityData";
|
||||||
import { getCourseVisits } from "@/content/courseVisits";
|
import { getCourseVisits, getOpinionArticles } from "@/content/courseVisits";
|
||||||
import { buildAbsoluteUrl } from "@/app/seo";
|
import { buildAbsoluteUrl } from "@/app/seo";
|
||||||
|
|
||||||
type SitemapFacility = {
|
type SitemapFacility = {
|
||||||
|
|
@ -41,6 +41,12 @@ const staticRoutes: MetadataRoute.Sitemap = [
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.72,
|
priority: 0.72,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: buildAbsoluteUrl("/meninger"),
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.7,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: buildAbsoluteUrl("/turneringer"),
|
url: buildAbsoluteUrl("/turneringer"),
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|
@ -96,12 +102,19 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const articleRoutes = (await getCourseVisits()).map((article) => ({
|
const courseVisitRoutes = (await getCourseVisits()).map((article) => ({
|
||||||
url: buildAbsoluteUrl(`/banebesok/${article.slug}`),
|
url: buildAbsoluteUrl(`/banebesok/${article.slug}`),
|
||||||
lastModified: article.updatedAt || article.publishedAt,
|
lastModified: article.updatedAt || article.publishedAt,
|
||||||
changeFrequency: "monthly" as const,
|
changeFrequency: "monthly" as const,
|
||||||
priority: 0.58,
|
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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
401
frontend/src/components/ArticleComments.tsx
Normal file
401
frontend/src/components/ArticleComments.tsx
Normal 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 få 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 må 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 på kontroll
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[#334238]">{comment.body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,7 @@ const primaryNavItems: NavItem[] = [
|
||||||
{ href: "/medlemskap", label: "Medlemskap" },
|
{ href: "/medlemskap", label: "Medlemskap" },
|
||||||
{ href: "/vtg", label: "VTG" },
|
{ href: "/vtg", label: "VTG" },
|
||||||
{ href: "/banebesok", label: "Banebesøk" },
|
{ href: "/banebesok", label: "Banebesøk" },
|
||||||
|
{ href: "/meninger", label: "Meninger" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const resourceNavItems: NavItem[] = [
|
const resourceNavItems: NavItem[] = [
|
||||||
|
|
|
||||||
|
|
@ -1,432 +1,17 @@
|
||||||
import { API_URL } from "@/config/constants";
|
export type {
|
||||||
import importedMeninger from "@/content/importedMeninger.json";
|
ArticleSection,
|
||||||
|
CourseVisitBodyBlock,
|
||||||
|
CourseVisitFact,
|
||||||
|
CourseVisitImage,
|
||||||
|
EditorialArticle as CourseVisitArticle,
|
||||||
|
} from "@/content/editorialArticles";
|
||||||
|
|
||||||
export type CourseVisitImage = {
|
export {
|
||||||
src: string;
|
buildEditorialPath,
|
||||||
alt: string;
|
getCourseVisitBySlug,
|
||||||
caption: string;
|
getCourseVisits,
|
||||||
};
|
getEditorialArticleBySlug,
|
||||||
|
getEditorialArticles,
|
||||||
export type CourseVisitFact = {
|
getOpinionArticleBySlug,
|
||||||
label: string;
|
getOpinionArticles,
|
||||||
value: string;
|
} from "@/content/editorialArticles";
|
||||||
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(/…/g, "...")
|
|
||||||
.replace(/…/g, "...")
|
|
||||||
.replace(/ /g, " ")
|
|
||||||
.replace(/«/g, "«")
|
|
||||||
.replace(/»/g, "»")
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/&/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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
540
frontend/src/content/editorialArticles.ts
Normal file
540
frontend/src/content/editorialArticles.ts
Normal 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(/…/g, "...")
|
||||||
|
.replace(/…/g, "...")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/«/g, "«")
|
||||||
|
.replace(/»/g, "»")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/&/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
Loading…
Reference in a new issue