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__/
|
||||
*.pyc
|
||||
*.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:
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 nå Tiptap for innhold,
|
||||
lagrer fortsatt HTML i databasen, og kan seedes fra de importerte Banebesøk-artiklene.
|
||||
Redaksjonelle artikler kan nå 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]"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 gå 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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
Gå 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"
|
||||
>
|
||||
Gå 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>
|
||||
|
|
|
|||
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 { 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];
|
||||
}
|
||||
|
|
|
|||
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: "/vtg", label: "VTG" },
|
||||
{ href: "/banebesok", label: "Banebesøk" },
|
||||
{ href: "/meninger", label: "Meninger" },
|
||||
];
|
||||
|
||||
const resourceNavItems: NavItem[] = [
|
||||
|
|
|
|||
|
|
@ -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(/…/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);
|
||||
}
|
||||
export {
|
||||
buildEditorialPath,
|
||||
getCourseVisitBySlug,
|
||||
getCourseVisits,
|
||||
getEditorialArticleBySlug,
|
||||
getEditorialArticles,
|
||||
getOpinionArticleBySlug,
|
||||
getOpinionArticles,
|
||||
} from "@/content/editorialArticles";
|
||||
|
|
|
|||
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