Nye-TeeOff/frontend/src/content/courseVisits.ts

432 lines
12 KiB
TypeScript

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