Før det fungerer med npm run lint

This commit is contained in:
Erol 2026-04-13 15:29:43 +02:00
parent 25ca19eba1
commit e1fcabef6a
18 changed files with 4056 additions and 68 deletions

366
backend/import_meninger.py Normal file
View file

@ -0,0 +1,366 @@
import argparse
import json
import re
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import requests
WP_API_BASE = "https://teeoff.no/wp-json/wp/v2/meninger"
DEFAULT_USER_AGENT = "TeeOff-Meninger-Importer/1.0"
DEFAULT_OUTPUT = Path("/opt/teeoff/frontend/src/content/importedMeninger.json")
DEFAULT_MEDIA_DIR = Path("/opt/teeoff/frontend/public/media/meninger")
INTERNAL_GOLF_COURSE_PATTERN = re.compile(r"https?://teeoff\.no/golfbaner/([^/?#]+)/?", re.IGNORECASE)
INTERNAL_TEEOFF_LINK_PATTERN = re.compile(r"https?://teeoff\.no/([^\"'#? ]+)", re.IGNORECASE)
IMG_SRC_PATTERN = re.compile(r"<img\b[^>]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE)
DISALLOWED_INTERNAL_SEGMENTS = {
"wp-content",
"wp-json",
"meninger",
"category",
"author",
"tag",
"feed",
}
class TextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []
def handle_data(self, data: str) -> None:
if data:
self.parts.append(data)
def get_text(self) -> str:
return " ".join(part.strip() for part in self.parts if part.strip())
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Importer Meninger-artikler fra gammel TeeOff WordPress-instans."
)
parser.add_argument(
"--output",
default=str(DEFAULT_OUTPUT),
help=f"Sti til JSON-filen som skal skrives. Standard: {DEFAULT_OUTPUT}",
)
parser.add_argument(
"--media-dir",
default=str(DEFAULT_MEDIA_DIR),
help=f"Mappe for nedlastede bilder. Standard: {DEFAULT_MEDIA_DIR}",
)
parser.add_argument(
"--per-page",
type=int,
default=100,
help="Antall artikler per API-kall. Standard: 100",
)
parser.add_argument(
"--category",
default=None,
help="Filtrer på kategorislug, f.eks. 'banebesok'.",
)
parser.add_argument(
"--download-media",
action="store_true",
help="Last ned featured media og inline-bilder lokalt og skriv om URL-er i HTML.",
)
parser.add_argument(
"--draft",
action="store_true",
help="Ta med artikler som ikke er publisert dersom API-et returnerer dem.",
)
parser.add_argument(
"--limit",
type=int,
default=None,
help="Maks antall artikler som skal skrives etter filtrering.",
)
return parser.parse_args()
def fetch_json(url: str, params: dict[str, Any] | None = None) -> Any:
response = requests.get(
url,
params=params,
timeout=30,
headers={"User-Agent": DEFAULT_USER_AGENT},
)
response.raise_for_status()
return response.json()
def fetch_all_posts(per_page: int, limit: int | None = None) -> list[dict[str, Any]]:
page = 1
posts: list[dict[str, Any]] = []
while True:
try:
data = fetch_json(
WP_API_BASE,
params={
"per_page": per_page,
"page": page,
"_embed": "1",
},
)
except requests.HTTPError as exc:
response = exc.response
if response is not None and response.status_code == 400 and page > 1:
break
raise
if not data:
break
posts.extend(data)
if limit is not None and len(posts) >= limit:
return posts[:limit]
page += 1
return posts
def strip_tags(value: str | None) -> str:
if not value:
return ""
parser = TextExtractor()
parser.feed(unescape(value))
parser.close()
return parser.get_text()
def ensure_directory(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def choose_media_url(media_entry: dict[str, Any]) -> str | None:
media_details = media_entry.get("media_details") or {}
sizes = media_details.get("sizes") or {}
for key in ("full", "1536x1536", "large", "medium"):
candidate = sizes.get(key, {}).get("source_url")
if candidate:
return candidate
return media_entry.get("source_url")
def download_file(url: str, target_dir: Path, basename: str) -> str | None:
ensure_directory(target_dir)
parsed = urlparse(url)
suffix = Path(parsed.path).suffix.lower()
if suffix not in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"}:
suffix = ".jpg"
filename = f"{basename}{suffix}"
target_path = target_dir / filename
if not target_path.exists():
response = requests.get(url, timeout=60, headers={"User-Agent": DEFAULT_USER_AGENT})
response.raise_for_status()
target_path.write_bytes(response.content)
return "/" + str(target_path.relative_to(Path("/opt/teeoff/frontend/public"))).replace("\\", "/")
def extract_categories(post: dict[str, Any]) -> list[dict[str, str]]:
embedded_terms = (post.get("_embedded") or {}).get("wp:term") or []
categories: list[dict[str, str]] = []
for term_group in embedded_terms:
if not isinstance(term_group, list):
continue
for term in term_group:
if term.get("taxonomy") != "category":
continue
categories.append(
{
"id": str(term.get("id", "")),
"name": str(term.get("name", "")),
"slug": str(term.get("slug", "")),
}
)
return categories
def detect_facility_slugs(html: str) -> list[str]:
found = INTERNAL_GOLF_COURSE_PATTERN.findall(html or "")
if html:
for raw_path in INTERNAL_TEEOFF_LINK_PATTERN.findall(html):
path = raw_path.strip("/").split("?")[0]
if not path:
continue
segments = [segment for segment in path.split("/") if segment]
if not segments:
continue
if segments[0] in DISALLOWED_INTERNAL_SEGMENTS:
continue
candidate = segments[-1]
if "golf" not in candidate:
continue
found.append(candidate)
seen: dict[str, None] = {}
for slug in found:
seen[slug] = None
return list(seen.keys())
def collect_inline_image_urls(html: str) -> list[str]:
urls: list[str] = []
for src in IMG_SRC_PATTERN.findall(html or ""):
if src.startswith("http"):
urls.append(src)
deduped: dict[str, None] = {}
for url in urls:
deduped[url] = None
return list(deduped.keys())
def rewrite_html_media(
html: str,
post_slug: str,
target_dir: Path,
featured_url: str | None = None,
) -> tuple[str, list[str], str | None]:
downloaded_urls: list[str] = []
rewrite_map: dict[str, str] = {}
image_index = 1
if featured_url:
local_featured = download_file(featured_url, target_dir, f"{post_slug}-featured")
if local_featured:
downloaded_urls.append(local_featured)
rewrite_map[featured_url] = local_featured
else:
local_featured = None
for url in collect_inline_image_urls(html):
local_path = download_file(url, target_dir, f"{post_slug}-inline-{image_index:02d}")
image_index += 1
if not local_path:
continue
rewrite_map[url] = local_path
downloaded_urls.append(local_path)
rewritten_html = html or ""
for original, local in rewrite_map.items():
rewritten_html = rewritten_html.replace(original, local)
return rewritten_html, downloaded_urls, local_featured
def normalize_post(
post: dict[str, Any],
category_filter: str | None,
download_media: bool,
media_dir: Path,
) -> dict[str, Any] | None:
status = str(post.get("status") or "")
categories = extract_categories(post)
category_slugs = [entry["slug"] for entry in categories if entry.get("slug")]
if category_filter and category_filter not in category_slugs:
return None
title_html = str((post.get("title") or {}).get("rendered") or "")
excerpt_html = str((post.get("excerpt") or {}).get("rendered") or "")
content_html = str((post.get("content") or {}).get("rendered") or "")
embedded = post.get("_embedded") or {}
author_entry = ((embedded.get("author") or [None])[0]) or {}
featured_entry = ((embedded.get("wp:featuredmedia") or [None])[0]) or {}
featured_url = choose_media_url(featured_entry) if featured_entry else None
featured_alt = str(featured_entry.get("alt_text") or "") if featured_entry else ""
featured_caption = strip_tags(str((featured_entry.get("caption") or {}).get("rendered") or ""))
if download_media:
content_html, downloaded_media, local_featured = rewrite_html_media(
content_html,
str(post.get("slug") or "mening"),
media_dir,
featured_url,
)
featured_image = local_featured or featured_url
else:
downloaded_media = []
featured_image = featured_url
facility_slugs = detect_facility_slugs(content_html)
return {
"id": post.get("id"),
"slug": post.get("slug"),
"status": status,
"type": post.get("type"),
"link": post.get("link"),
"title": strip_tags(title_html),
"titleHtml": title_html,
"excerpt": strip_tags(excerpt_html),
"excerptHtml": excerpt_html,
"contentHtml": content_html,
"publishedAt": post.get("date"),
"updatedAt": post.get("modified"),
"author": {
"id": author_entry.get("id"),
"name": author_entry.get("name"),
"slug": author_entry.get("slug"),
"link": author_entry.get("link"),
},
"featuredImage": {
"url": featured_image,
"originalUrl": featured_url,
"alt": featured_alt,
"caption": featured_caption,
}
if featured_url or featured_image
else None,
"inlineMedia": downloaded_media,
"categories": categories,
"categorySlugs": category_slugs,
"facilitySlugs": facility_slugs,
"primaryFacilitySlug": facility_slugs[0] if facility_slugs else None,
}
def main() -> None:
args = parse_args()
output_path = Path(args.output)
media_dir = Path(args.media_dir)
print("🚀 Starter import av Meninger fra WordPress...")
posts = fetch_all_posts(args.per_page, args.limit)
print(f"📦 Hentet {len(posts)} artikler fra {WP_API_BASE}")
normalized_posts: list[dict[str, Any]] = []
for post in posts:
if not args.draft and str(post.get("status") or "") != "publish":
continue
normalized = normalize_post(
post,
category_filter=args.category,
download_media=args.download_media,
media_dir=media_dir,
)
if normalized is None:
continue
normalized_posts.append(normalized)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps(normalized_posts, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"✅ Skrev {len(normalized_posts)} artikler til {output_path}")
if args.download_media:
print(f"🖼️ Bilder ble lagret under {media_dir}")
if args.category:
print(f"🏷️ Kategorifilter brukt: {args.category}")
linked_count = sum(1 for post in normalized_posts if post.get("primaryFacilitySlug"))
print(f"{linked_count} artikler fikk koblet minst én golfbane-slug fra internlenker.")
if __name__ == "__main__":
main()

View file

@ -17,7 +17,9 @@ import asyncpg
import json
import pyotp
import os
import re
from datetime import datetime, date, timedelta
from pathlib import Path
from jose import jwt, JWTError
from passlib.context import CryptContext
from dotenv import load_dotenv
@ -102,6 +104,25 @@ class BulkVtgRequest(BaseModel):
class AdminPasswordConfirm(BaseModel):
password: str
class ArticleUpsertRequest(BaseModel):
slug: str
title: str
description: Optional[str] = None
excerpt: Optional[str] = None
eyebrow: Optional[str] = None
location_label: Optional[str] = None
facility_name: Optional[str] = None
facility_slug: Optional[str] = None
author_name: Optional[str] = None
status: Optional[str] = "draft"
hero_images: Optional[List[dict[str, Any]]] = []
content_html: Optional[str] = None
source_url: Optional[str] = None
source_label: Optional[str] = None
published_at: Optional[str] = None
updated_at: Optional[str] = None
# --- FUNKSJONER ---
def format_row(row):
"""
@ -169,6 +190,90 @@ def generate_totp_qr_svg(provisioning_uri: str) -> str:
)
return image.to_string(encoding="unicode")
def format_article_row(row):
if row is None:
return None
data = dict(row)
for key in ["published_at", "updated_at", "created_at"]:
if isinstance(data.get(key), (date, datetime)):
data[key] = data[key].isoformat()
hero_images = data.get("hero_images")
if hero_images is None:
data["hero_images"] = []
elif isinstance(hero_images, str):
try:
data["hero_images"] = json.loads(hero_images)
except Exception:
data["hero_images"] = []
elif not isinstance(hero_images, list):
data["hero_images"] = []
return data
def normalize_article_status(status: str | None) -> str:
normalized = str(status or "draft").strip().lower()
if normalized not in {"draft", "published"}:
raise HTTPException(status_code=400, detail="Ugyldig artikkelstatus. Bruk 'draft' eller 'published'.")
return normalized
def parse_optional_datetime(value: str | None) -> datetime | None:
if value is None:
return None
trimmed = str(value).strip()
if not trimmed:
return None
try:
return datetime.fromisoformat(trimmed.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Ugyldig datoformat: {value}") from exc
def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
sanitized: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
continue
src = str(item.get("src") or "").strip()
if not src:
continue
sanitized.append(
{
"src": src,
"alt": str(item.get("alt") or "").strip(),
"caption": str(item.get("caption") or "").strip(),
}
)
return sanitized
def humanize_slug(slug: str | None) -> str:
if not slug:
return "Ukjent bane"
return " ".join(part.capitalize() for part in str(slug).split("-") if part)
ARTICLE_IMAGE_PATTERN = re.compile(r"<img\b[^>]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE)
def extract_html_image_urls(html: str | None) -> list[str]:
urls: list[str] = []
for url in ARTICLE_IMAGE_PATTERN.findall(html or ""):
if not isinstance(url, str) or not url.strip():
continue
urls.append(url.strip())
deduped: dict[str, None] = {}
for url in urls:
deduped[url] = None
return list(deduped.keys())
async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: str | None = None):
if job_type not in SCRAPE_JOB_TYPES:
raise HTTPException(status_code=400, detail=f"Ugyldig jobbtype: {job_type}")
@ -207,6 +312,33 @@ async def ensure_facility_columns(conn):
""")
async def ensure_articles_table(conn):
await conn.execute("""
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
excerpt TEXT,
eyebrow VARCHAR(120) DEFAULT 'Banebesøk',
location_label VARCHAR(255),
facility_name VARCHAR(255),
facility_slug VARCHAR(255),
author_name VARCHAR(255),
status VARCHAR(32) NOT NULL DEFAULT 'draft',
hero_images JSONB NOT NULL DEFAULT '[]'::jsonb,
content_html TEXT,
source_url TEXT,
source_label VARCHAR(255),
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
await conn.execute("CREATE INDEX IF NOT EXISTS articles_status_idx ON articles (status)")
await conn.execute("CREATE INDEX IF NOT EXISTS articles_published_at_idx ON articles (published_at DESC)")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Opprett database-pool ved start
@ -220,6 +352,7 @@ async def lifespan(app: FastAPI):
)
async with app.state.pool.acquire() as conn:
await ensure_facility_columns(conn)
await ensure_articles_table(conn)
await ensure_scrape_jobs_table(conn)
print("✅ Database tilkoblet og pool opprettet")
except Exception as e:
@ -387,8 +520,242 @@ async def get_facility(slug: str):
return format_row(row)
@app.get("/api/course-visits")
async def get_course_visits():
"""Henter publiserte Banebesøk-artikler."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT *
FROM articles
WHERE status = 'published'
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
""")
return [format_article_row(row) for row in rows]
@app.get("/api/course-visits/{slug}")
async def get_course_visit(slug: str):
"""Henter én publisert Banebesøk-artikkel."""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT *
FROM articles
WHERE slug = $1 AND status = 'published'
""", slug)
if not row:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return format_article_row(row)
# --- ADMIN ENDPOINTS ---
@app.get("/api/admin/articles")
async def get_admin_articles(status: Optional[str] = Query(default="all")):
"""Henter artikler for admin med valgfritt statusfilter."""
normalized_status = str(status or "all").strip().lower()
if normalized_status not in {"all", "draft", "published"}:
raise HTTPException(status_code=400, detail="Ugyldig statusfilter")
query = """
SELECT *
FROM articles
{where_clause}
ORDER BY COALESCE(published_at, created_at) DESC, updated_at DESC, id DESC
"""
async with app.state.pool.acquire() as conn:
if normalized_status == "all":
rows = await conn.fetch(query.format(where_clause=""))
else:
rows = await conn.fetch(
query.format(where_clause="WHERE status = $1"),
normalized_status,
)
return [format_article_row(row) for row in rows]
@app.get("/api/admin/articles/{article_id}")
async def get_admin_article(article_id: int):
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM articles WHERE id = $1", article_id)
if not row:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return format_article_row(row)
@app.post("/api/admin/articles")
async def upsert_admin_article(request: ArticleUpsertRequest):
status = normalize_article_status(request.status)
published_at = parse_optional_datetime(request.published_at)
updated_at = parse_optional_datetime(request.updated_at) or datetime.utcnow()
if status == "published" and not published_at:
published_at = datetime.utcnow()
hero_images = sanitize_hero_images(request.hero_images)
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
INSERT INTO articles (
slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images,
content_html, source_url, source_label, published_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11::jsonb,
$12, $13, $14, $15, $16
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
excerpt = EXCLUDED.excerpt,
eyebrow = EXCLUDED.eyebrow,
location_label = EXCLUDED.location_label,
facility_name = EXCLUDED.facility_name,
facility_slug = EXCLUDED.facility_slug,
author_name = EXCLUDED.author_name,
status = EXCLUDED.status,
hero_images = EXCLUDED.hero_images,
content_html = EXCLUDED.content_html,
source_url = EXCLUDED.source_url,
source_label = EXCLUDED.source_label,
published_at = EXCLUDED.published_at,
updated_at = EXCLUDED.updated_at
RETURNING *
""",
request.slug.strip(),
request.title.strip(),
(request.description or "").strip() or None,
(request.excerpt or "").strip() or None,
(request.eyebrow or "Banebesøk").strip(),
(request.location_label or "").strip() or None,
(request.facility_name or "").strip() or None,
(request.facility_slug or "").strip() or None,
(request.author_name or "TeeOff").strip(),
status,
json.dumps(hero_images),
request.content_html or "",
(request.source_url or "").strip() or None,
(request.source_label or "").strip() or None,
published_at,
updated_at,
)
return format_article_row(row)
@app.delete("/api/admin/articles/{article_id}")
async def delete_admin_article(article_id: int):
async with app.state.pool.acquire() as conn:
deleted = await conn.fetchrow("DELETE FROM articles WHERE id = $1 RETURNING id", article_id)
if not deleted:
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
return {"status": "success"}
@app.post("/api/admin/articles/seed-imported")
async def seed_admin_articles_from_imported_json():
imported_path = Path("/opt/teeoff/frontend/src/content/importedMeninger.json")
if not imported_path.exists():
raise HTTPException(status_code=404, detail="Fant ikke importedMeninger.json")
try:
imported_articles = json.loads(imported_path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=500, detail="Kunne ikke lese importedMeninger.json") from exc
async with app.state.pool.acquire() as conn:
facility_rows = await conn.fetch("SELECT slug, name, county FROM facilities")
facility_lookup = {
str(row["slug"]): {
"name": row["name"],
"county": row["county"],
}
for row in facility_rows
}
upserted_count = 0
async with conn.transaction():
for item in imported_articles:
facility_slug = item.get("primaryFacilitySlug") or ((item.get("facilitySlugs") or [None])[0])
if not facility_slug:
continue
facility = facility_lookup.get(str(facility_slug), {})
content_html = str(item.get("contentHtml") or "")
featured_image = item.get("featuredImage") or {}
hero_images: list[dict[str, str]] = []
featured_url = str(featured_image.get("url") or "").strip()
if featured_url:
hero_images.append(
{
"src": featured_url,
"alt": str(featured_image.get("alt") or item.get("title") or "").strip(),
"caption": str(featured_image.get("caption") or item.get("title") or "").strip(),
}
)
for url in extract_html_image_urls(content_html)[:5]:
if any(existing["src"] == url for existing in hero_images):
continue
hero_images.append(
{
"src": url,
"alt": str(item.get("title") or "").strip(),
"caption": str(item.get("title") or "").strip(),
}
)
published_at = parse_optional_datetime(item.get("publishedAt"))
updated_at = parse_optional_datetime(item.get("updatedAt")) or published_at or datetime.utcnow()
await conn.execute("""
INSERT INTO articles (
slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images,
content_html, source_url, source_label, published_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, 'published', $10::jsonb,
$11, $12, $13, $14, $15
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
excerpt = EXCLUDED.excerpt,
eyebrow = EXCLUDED.eyebrow,
location_label = EXCLUDED.location_label,
facility_name = EXCLUDED.facility_name,
facility_slug = EXCLUDED.facility_slug,
author_name = EXCLUDED.author_name,
status = EXCLUDED.status,
hero_images = EXCLUDED.hero_images,
content_html = EXCLUDED.content_html,
source_url = EXCLUDED.source_url,
source_label = EXCLUDED.source_label,
published_at = EXCLUDED.published_at,
updated_at = EXCLUDED.updated_at
""",
str(item.get("slug") or "").strip(),
str(item.get("title") or "").strip(),
str(item.get("excerpt") or "").strip() or None,
str(item.get("excerpt") or "").strip() or None,
"Banebesøk",
str(facility.get("county") or "Norge"),
str(facility.get("name") or humanize_slug(str(facility_slug))),
str(facility_slug),
str(((item.get("author") or {}).get("name")) or "TeeOff"),
json.dumps(hero_images),
content_html,
str(item.get("link") or "").strip() or None,
"Importert fra gamle TeeOff",
published_at,
updated_at,
)
upserted_count += 1
return {"status": "success", "count": upserted_count}
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""

View file

@ -42,6 +42,8 @@ services:
command: npm start
ports:
- "3000:3000"
volumes:
- ./frontend/public/uploads:/app/public/uploads
# VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
depends_on:
- api

1
frontend/.gitignore vendored
View file

@ -19,6 +19,7 @@
# production
/build
/public/uploads
# misc
.DS_Store

View file

@ -1,7 +1,23 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
formats: ["image/avif", "image/webp"],
remotePatterns: [
{
protocol: "https",
hostname: "teeoff.no",
},
{
protocol: "https",
hostname: "www.teeoff.no",
},
{
protocol: "https",
hostname: "i.ytimg.com",
},
],
},
};
export default nextConfig;

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,13 @@
"lint": "eslint"
},
"dependencies": {
"@tiptap/extension-image": "^3.22.3",
"@tiptap/extension-link": "^3.22.3",
"@tiptap/extension-placeholder": "^3.22.3",
"@tiptap/extension-underline": "^3.22.3",
"@tiptap/pm": "^3.22.3",
"@tiptap/react": "^3.22.3",
"@tiptap/starter-kit": "^3.22.3",
"leaflet": "^1.9.4",
"next": "16.1.6",
"react": "19.2.3",

View file

@ -0,0 +1,709 @@
"use client";
import Link from "next/link";
import { type ChangeEvent, useEffect, useRef, useState } from "react";
import { API_URL } from "@/config/constants";
import { adminFetch } from "@/config/adminFetch";
import TiptapHtmlEditor from "@/components/TiptapHtmlEditor";
type AdminArticle = {
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;
status: "draft" | "published";
hero_images?: Array<{
src: string;
alt?: string;
caption?: string;
}>;
content_html?: string | null;
source_url?: string | null;
source_label?: string | null;
published_at?: string | null;
updated_at?: string | null;
};
type FacilityOption = {
slug: string;
name: string;
county?: string | null;
};
type ArticleFormState = {
slug: string;
title: string;
description: string;
excerpt: string;
eyebrow: string;
location_label: string;
facility_name: string;
facility_slug: string;
author_name: string;
status: "draft" | "published";
heroImageUrls: string;
content_html: string;
source_url: string;
source_label: string;
published_at: string;
};
function slugify(value: string) {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function toDatetimeLocal(value?: string | null) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const offset = date.getTimezoneOffset();
const local = new Date(date.getTime() - offset * 60_000);
return local.toISOString().slice(0, 16);
}
function heroImagesToText(images?: AdminArticle["hero_images"]) {
if (!Array.isArray(images)) return "";
return images.map((image) => image.src).filter(Boolean).join("\n");
}
function createEmptyForm(): ArticleFormState {
return {
slug: "",
title: "",
description: "",
excerpt: "",
eyebrow: "Banebesøk",
location_label: "",
facility_name: "",
facility_slug: "",
author_name: "TeeOff",
status: "draft",
heroImageUrls: "",
content_html: "",
source_url: "",
source_label: "",
published_at: "",
};
}
function articleToForm(article: AdminArticle): ArticleFormState {
return {
slug: article.slug || "",
title: article.title || "",
description: article.description || "",
excerpt: article.excerpt || "",
eyebrow: article.eyebrow || "Banebesøk",
location_label: article.location_label || "",
facility_name: article.facility_name || "",
facility_slug: article.facility_slug || "",
author_name: article.author_name || "TeeOff",
status: article.status || "draft",
heroImageUrls: heroImagesToText(article.hero_images),
content_html: article.content_html || "",
source_url: article.source_url || "",
source_label: article.source_label || "",
published_at: toDatetimeLocal(article.published_at),
};
}
function buildHeroImages(heroImageUrls: string, title: string) {
return heroImageUrls
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((src) => ({
src,
alt: title,
caption: title,
}));
}
export default function AdminArticlesPage() {
const [articles, setArticles] = useState<AdminArticle[]>([]);
const [facilities, setFacilities] = useState<FacilityOption[]>([]);
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(null);
const [form, setForm] = useState<ArticleFormState>(createEmptyForm());
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isSeeding, setIsSeeding] = useState(false);
const [isUploadingHeroImages, setIsUploadingHeroImages] = useState(false);
const [feedback, setFeedback] = useState<string>("");
const [slugTouched, setSlugTouched] = useState(false);
const heroImageInputRef = useRef<HTMLInputElement | null>(null);
const loadArticles = async () => {
const response = await adminFetch(`${API_URL}/admin/articles`);
const data = await response.json();
setArticles(Array.isArray(data) ? (data as AdminArticle[]) : []);
};
const loadFacilities = async () => {
const response = await fetch(`${API_URL}/facilities`, { credentials: "include" });
const data = await response.json();
const mapped = Array.isArray(data)
? data
.filter((item): item is FacilityOption => Boolean(item?.slug && item?.name))
.map((item) => ({
slug: item.slug,
name: item.name,
county: item.county || "",
}))
: [];
setFacilities(mapped);
};
useEffect(() => {
const init = async () => {
try {
await Promise.all([loadArticles(), loadFacilities()]);
} finally {
setIsLoading(false);
}
};
void init();
}, []);
const handleSelectArticle = (article: AdminArticle) => {
setSelectedArticleId(article.id);
setForm(articleToForm(article));
setSlugTouched(true);
setFeedback("");
};
const handleCreateNew = () => {
setSelectedArticleId(null);
setForm(createEmptyForm());
setSlugTouched(false);
setFeedback("");
};
const handleFieldChange = (field: keyof ArticleFormState, value: string) => {
setForm((current) => ({ ...current, [field]: value }));
};
const uploadArticleImage = async (file: File) => {
const payload = new FormData();
payload.append("file", file);
const response = await adminFetch("/api/admin/uploads/images", {
method: "POST",
body: payload,
credentials: "include",
});
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Kunne ikke laste opp bildet." }));
throw new Error(error.detail || "Kunne ikke laste opp bildet.");
}
const result = (await response.json()) as { url?: string };
if (!result.url) {
throw new Error("Uploaden returnerte ingen bildeadresse.");
}
return result.url;
};
const appendHeroImageUrls = (urls: string[]) => {
setForm((current) => {
const existingUrls = current.heroImageUrls
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const combined = [...existingUrls, ...urls].filter(
(url, index, list) => list.indexOf(url) === index,
);
return {
...current,
heroImageUrls: combined.join("\n"),
};
});
};
const handleTitleChange = (value: string) => {
setForm((current) => ({
...current,
title: value,
slug: slugTouched ? current.slug : slugify(value),
}));
};
const handleFacilityChange = (facilitySlug: string) => {
const facility = facilities.find((entry) => entry.slug === facilitySlug);
setForm((current) => ({
...current,
facility_slug: facilitySlug,
facility_name: facility?.name || current.facility_name,
location_label: facility?.county || current.location_label,
}));
};
const handleHeroImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length === 0) return;
setIsUploadingHeroImages(true);
setFeedback("");
try {
const uploadedUrls = await Promise.all(files.map((file) => uploadArticleImage(file)));
appendHeroImageUrls(uploadedUrls);
setFeedback(
`Lastet opp ${uploadedUrls.length} hero-bilde${uploadedUrls.length === 1 ? "" : "r"} som AVIF.`,
);
} catch (error) {
setFeedback(error instanceof Error ? error.message : "Kunne ikke laste opp hero-bilder.");
} finally {
setIsUploadingHeroImages(false);
}
};
const handleSave = async () => {
setIsSaving(true);
setFeedback("");
try {
const payload = {
...form,
slug: form.slug.trim(),
title: form.title.trim(),
description: form.description.trim(),
excerpt: form.excerpt.trim(),
eyebrow: form.eyebrow.trim(),
location_label: form.location_label.trim(),
facility_name: form.facility_name.trim(),
facility_slug: form.facility_slug.trim(),
author_name: form.author_name.trim(),
content_html: form.content_html,
source_url: form.source_url.trim(),
source_label: form.source_label.trim(),
published_at: form.published_at ? new Date(form.published_at).toISOString() : null,
hero_images: buildHeroImages(form.heroImageUrls, form.title.trim()),
};
const response = await adminFetch(`${API_URL}/admin/articles`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Kunne ikke lagre artikkelen" }));
throw new Error(error.detail || "Kunne ikke lagre artikkelen");
}
const savedArticle = (await response.json()) as AdminArticle;
await loadArticles();
setSelectedArticleId(savedArticle.id);
setForm(articleToForm(savedArticle));
setSlugTouched(true);
setFeedback("Artikkelen er lagret.");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre artikkelen.");
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!selectedArticleId) return;
const confirmed = window.confirm("Vil du slette denne artikkelen?");
if (!confirmed) return;
setIsDeleting(true);
setFeedback("");
try {
const response = await adminFetch(`${API_URL}/admin/articles/${selectedArticleId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Kunne ikke slette artikkelen.");
}
await loadArticles();
handleCreateNew();
setFeedback("Artikkelen ble slettet.");
} catch (error) {
setFeedback(error instanceof Error ? error.message : "Kunne ikke slette artikkelen.");
} finally {
setIsDeleting(false);
}
};
const handleSeedImported = async () => {
setIsSeeding(true);
setFeedback("");
try {
const response = await adminFetch(`${API_URL}/admin/articles/seed-imported`, {
method: "POST",
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Kunne ikke importere artiklene." }));
throw new Error(error.detail || "Kunne ikke importere artiklene.");
}
const result = (await response.json()) as { count?: number };
await loadArticles();
setFeedback(`Importerte eller oppdaterte ${result.count || 0} artikler fra importedMeninger.json.`);
} catch (error) {
setFeedback(error instanceof Error ? error.message : "Kunne ikke importere artiklene.");
} finally {
setIsSeeding(false);
}
};
return (
<main className="min-h-screen bg-[#f3f6ee] px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-[1700px]">
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<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>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#536256]">
Første adminversjon for redaksjonelle artikler. Denne bruker Tiptap for innhold,
lagrer fortsatt HTML i databasen, og kan seedes fra de importerte Banebesøk-artiklene.
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleSeedImported}
disabled={isSeeding}
className="rounded-full border border-[#11280f]/10 bg-white px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-[#11280f] transition hover:border-[#8BC34A] disabled:opacity-50"
>
{isSeeding ? "Importerer..." : "Seed fra import"}
</button>
<button
type="button"
onClick={handleCreateNew}
className="rounded-full bg-[#11280f] px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
>
Ny artikkel
</button>
</div>
</div>
{feedback ? (
<div className="mb-6 rounded-[1.5rem] border border-[#11280f]/10 bg-white px-5 py-4 text-sm font-bold text-[#334238] shadow-sm">
{feedback}
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[360px,minmax(0,1fr)]">
<aside className="surface-card rounded-[2rem] p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
Artikler
</p>
<p className="mt-2 text-2xl font-black text-[#112015]">{articles.length} totalt</p>
</div>
</div>
<div className="mt-5 space-y-3">
{isLoading ? (
<div className="rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-5 text-sm font-bold text-[#536256]">
Laster artikler...
</div>
) : null}
{!isLoading && articles.length === 0 ? (
<div className="rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-5 text-sm font-bold text-[#536256]">
Ingen artikler ennå. Seed importen eller opprett en ny.
</div>
) : null}
{articles.map((article) => (
<button
key={article.id}
type="button"
onClick={() => handleSelectArticle(article)}
className={`w-full rounded-[1.5rem] border px-4 py-4 text-left transition ${
selectedArticleId === article.id
? "border-[#FF5722] bg-[#FFF4EF] shadow-sm"
: "border-[#112015]/8 bg-white hover:border-[#8BC34A]"
}`}
>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-black text-[#112015]">{article.title}</p>
<span
className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-[0.16em] ${
article.status === "published"
? "bg-[#edf6e3] text-[#11280f]"
: "bg-[#f1f3f5] text-[#5d6a60]"
}`}
>
{article.status === "published" ? "Publisert" : "Utkast"}
</span>
</div>
<p className="mt-2 text-[11px] font-bold uppercase tracking-[0.14em] text-[#6A766C]">
/{article.slug}
</p>
<p className="mt-3 text-sm leading-6 text-[#536256]">
{article.facility_name || "Uten koblet bane"}
</p>
</button>
))}
</div>
</aside>
<section className="surface-card rounded-[2rem] p-5 sm:p-8">
<div className="grid gap-5 md:grid-cols-2">
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Tittel</span>
<input
value={form.title}
onChange={(event) => handleTitleChange(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]"
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Slug</span>
<input
value={form.slug}
onChange={(event) => {
setSlugTouched(true);
handleFieldChange("slug", slugify(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]"
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Status</span>
<select
value={form.status}
onChange={(event) => handleFieldChange("status", 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="draft">Utkast</option>
<option value="published">Publisert</option>
</select>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Publiseringsdato</span>
<input
type="datetime-local"
value={form.published_at}
onChange={(event) => handleFieldChange("published_at", 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]"
/>
</label>
</div>
<div className="mt-5 grid gap-5 md:grid-cols-2">
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Eyebrow</span>
<input
value={form.eyebrow}
onChange={(event) => handleFieldChange("eyebrow", 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]"
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Forfatter</span>
<input
value={form.author_name}
onChange={(event) => handleFieldChange("author_name", 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]"
/>
</label>
</div>
<div className="mt-5 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]">Koblet bane</span>
<select
value={form.facility_slug}
onChange={(event) => handleFacilityChange(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="">Velg bane</option>
{facilities.map((facility) => (
<option key={facility.slug} value={facility.slug}>
{facility.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Banenavn</span>
<input
value={form.facility_name}
onChange={(event) => handleFieldChange("facility_name", 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]"
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Stedsetikett</span>
<input
value={form.location_label}
onChange={(event) => handleFieldChange("location_label", 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]"
/>
</label>
</div>
<div className="mt-5 grid gap-5">
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Description</span>
<textarea
rows={3}
value={form.description}
onChange={(event) => handleFieldChange("description", event.target.value)}
className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Ingress / excerpt</span>
<textarea
rows={4}
value={form.excerpt}
onChange={(event) => handleFieldChange("excerpt", event.target.value)}
className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
</label>
<label className="flex flex-col gap-2">
<input
ref={heroImageInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleHeroImageUpload}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Hero-bilder</span>
<button
type="button"
onClick={() => heroImageInputRef.current?.click()}
disabled={isUploadingHeroImages}
className="rounded-full border border-[#112015]/10 bg-white px-4 py-2 text-[10px] font-black uppercase tracking-[0.16em] text-[#112015] transition hover:border-[#8BC34A] disabled:cursor-not-allowed disabled:opacity-50"
>
{isUploadingHeroImages ? "Laster opp..." : "Last opp bilder"}
</button>
</div>
<textarea
rows={5}
value={form.heroImageUrls}
onChange={(event) => handleFieldChange("heroImageUrls", event.target.value)}
placeholder="En bilde-URL per linje"
className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 font-mono text-sm text-[#112015] outline-none focus:border-[#8BC34A]"
/>
<p className="text-sm leading-6 text-[#536256]">
Filer som lastes opp her konverteres automatisk til AVIF før URL-ene legges inn.
</p>
</label>
</div>
<div className="mt-5 rounded-[1.5rem] border border-emerald-200 bg-emerald-50 px-5 py-4">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-emerald-800">
Bildeopplasting
</p>
<p className="mt-2 text-sm leading-6 text-emerald-900">
Artikler kan laste opp bildefiler direkte. Uploadene prosesseres serveren,
konverteres til AVIF og lagres som lokale filer som kan brukes både som hero-bilder
og inne i brødteksten.
</p>
</div>
<div className="mt-5 grid gap-5 md:grid-cols-2">
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Kildelenke</span>
<input
value={form.source_url}
onChange={(event) => handleFieldChange("source_url", event.target.value)}
className="rounded-[1.1rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Kildelabel</span>
<input
value={form.source_label}
onChange={(event) => handleFieldChange("source_label", event.target.value)}
className="rounded-[1.1rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
</label>
</div>
<div className="mt-5">
<div className="mb-3 flex items-end justify-between gap-3">
<div>
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Artikkelinnhold</span>
<p className="mt-1 text-sm leading-6 text-[#536256]">
Tiptap brukes som editor, men HTML lagres fortsatt i databasen for enkel og kontrollert rendering.
</p>
</div>
</div>
<TiptapHtmlEditor
value={form.content_html}
onChange={(html) => handleFieldChange("content_html", html)}
onUploadImage={uploadArticleImage}
placeholder="Skriv artikkelen her. Bruk toolbaren for overskrifter, lister, lenker og last opp bilder direkte."
/>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
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"
>
{isSaving ? "Lagrer..." : "Lagre artikkel"}
</button>
{selectedArticleId ? (
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
className="rounded-full border border-red-200 bg-red-50 px-5 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-red-700 transition hover:bg-red-100 disabled:opacity-50"
>
{isDeleting ? "Sletter..." : "Slett artikkel"}
</button>
) : null}
{form.slug ? (
<Link
href={`/banebesok/${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]"
>
Åpne offentlig side
</Link>
) : null}
{form.facility_slug ? (
<Link
href={`/golfbaner/${form.facility_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-[#FF5722]"
>
Åpne baneprofil
</Link>
) : null}
</div>
</section>
</div>
</div>
</main>
);
}

View file

@ -730,6 +730,13 @@ export default function AdminDashboard() {
</Link>
</div>
<div className="space-y-2">
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Innhold</div>
<Link href="/admin/artikler" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
Artikler
</Link>
</div>
<div className="space-y-2">
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Konto</div>
<button
@ -779,6 +786,12 @@ export default function AdminDashboard() {
{isSidebarCollapsed ? 'V' : 'VTG'}
</Link>
</div>
<div className="space-y-2 mt-6">
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Innhold</div>
<Link href="/admin/artikler" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Artikler">
{isSidebarCollapsed ? 'A' : 'Artikler'}
</Link>
</div>
<div className="space-y-2 mt-6">
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Konto</div>
<button
@ -818,6 +831,12 @@ export default function AdminDashboard() {
</div>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/admin/artikler"
className="rounded-2xl border border-gray-200 bg-white px-5 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 shadow-sm transition-colors hover:border-[#ff5722] hover:text-[#11280f]"
>
Artikler / Banebesøk
</Link>
<button
onClick={openTwoFactorModal}
className="rounded-2xl border border-gray-200 bg-white px-5 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 shadow-sm transition-colors hover:border-[#8bc34a] hover:text-[#11280f]"

View file

@ -0,0 +1,100 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const MAX_UPLOAD_SIZE_BYTES = 20 * 1024 * 1024;
const ALLOWED_MIME_TYPES = new Set([
"image/avif",
"image/gif",
"image/jpeg",
"image/png",
"image/tiff",
"image/webp",
]);
export const runtime = "nodejs";
function sanitizeFilenameStem(filename: string) {
const stem = path.parse(filename).name;
const normalized = stem
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || "image";
}
export async function POST(request: Request) {
const cookieStore = await cookies();
if (!cookieStore.get("admin_session")) {
return NextResponse.json({ detail: "Admin-innlogging kreves" }, { status: 401 });
}
try {
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ detail: "Fant ingen bildefil i requesten." }, { status: 400 });
}
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ detail: "Filtypen støttes ikke. Bruk JPG, PNG, WebP, TIFF, GIF eller AVIF." },
{ status: 415 },
);
}
if (file.size > MAX_UPLOAD_SIZE_BYTES) {
return NextResponse.json(
{ detail: "Bildeopplasting er begrenset til 20 MB per fil." },
{ status: 413 },
);
}
const inputBuffer = Buffer.from(await file.arrayBuffer());
const image = sharp(inputBuffer, { animated: false }).rotate();
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) {
return NextResponse.json({ detail: "Kunne ikke lese bildedimensjonene." }, { status: 400 });
}
const dateSegment = new Date().toISOString().slice(0, 10);
const safeName = sanitizeFilenameStem(file.name);
const filename = `${safeName}-${randomUUID()}.avif`;
const relativeDirectory = path.join("uploads", "articles", dateSegment);
const absoluteDirectory = path.join(process.cwd(), "public", relativeDirectory);
const absolutePath = path.join(absoluteDirectory, filename);
await mkdir(absoluteDirectory, { recursive: true });
const outputBuffer = await image
.resize({
width: 2400,
withoutEnlargement: true,
})
.avif({
quality: 68,
effort: 6,
})
.toBuffer();
await writeFile(absolutePath, outputBuffer);
return NextResponse.json({
url: `/${relativeDirectory.replaceAll(path.sep, "/")}/${filename}`,
contentType: "image/avif",
width: metadata.width,
height: metadata.height,
});
} catch (error) {
console.error("Image upload failed", error);
return NextResponse.json({ detail: "Kunne ikke prosessere bildefilen." }, { status: 500 });
}
}

View file

@ -0,0 +1,270 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import CourseVisitGallery from "@/components/CourseVisitGallery";
import InfoPageShell from "@/components/InfoPageShell";
import { getCourseVisitBySlug, type CourseVisitBodyBlock } from "@/content/courseVisits";
import {
ORGANIZATION_ID,
buildAbsoluteUrl,
createBreadcrumbJsonLd,
createPageMetadata,
} from "@/app/seo";
type CourseVisitPageProps = {
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 }: CourseVisitPageProps) {
const { slug } = await params;
const article = await getCourseVisitBySlug(slug);
if (!article) {
return createPageMetadata({
title: "Banebesøk ikke funnet",
description: "Artikkelen du leter etter finnes ikke på TeeOff.",
path: `/banebesok/${slug}`,
type: "article",
});
}
return createPageMetadata({
title: article.title,
description: article.description,
path: `/banebesok/${article.slug}`,
image: article.heroImages[0]?.src,
type: "article",
});
}
export default async function CourseVisitPage({ params }: CourseVisitPageProps) {
const { slug } = await params;
const article = await getCourseVisitBySlug(slug);
if (!article) {
notFound();
}
const breadcrumbJsonLd = createBreadcrumbJsonLd([
{ name: "Hjem", path: "/" },
{ name: "Banebesøk", path: "/banebesok" },
{ name: article.title, path: `/banebesok/${article.slug}` },
]);
const articleJsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: article.title,
description: article.description,
url: buildAbsoluteUrl(`/banebesok/${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,
},
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))}
</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}</p>
<p className="mt-3 text-sm leading-6 text-white/78">
Bruk artikkelen for inspirasjon, og videre til baneprofilen når du trenger
praktiske detaljer og oppdatert klubbinfo.
</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>
<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"
>
Tilbake til banebesøk
</Link>
{article.sourceUrl ? (
<a
href={article.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/8 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/14"
>
Original kilde
</a>
) : null}
</div>
</div>
</section>
</aside>
</div>
</InfoPageShell>
</>
);
}

View file

@ -1,5 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import InfoPageShell from "@/components/InfoPageShell";
import { getCourseVisits } from "@/content/courseVisits";
import {
createBreadcrumbJsonLd,
createCollectionPageJsonLd,
@ -8,22 +10,7 @@ import {
const pageTitle = "Banebesøk";
const pageDescription =
"Personlige artikler fra golfbaner TeeOff har spilt, med bilder, inntrykk og detaljer som er nyttige før ditt eget besøk.";
const articlePillars = [
{
title: "Baneguide med personlighet",
text: "Hver artikkel skal være mer enn faktaark. Målet er å beskrive flyt, inntrykk og hva som faktisk gjør banen minneverdig.",
},
{
title: "Foto og stemning",
text: "Toppslider, utvalgte bilder og tydelige avsnitt gjør at banebesøk kan fungere både som inspirasjon og som planlegging.",
},
{
title: "Norsk golfkontekst",
text: "Banebesøk skal løfte fram særpreg ved norske anlegg i stedet for å bli en generisk reiseblogg.",
},
];
"Redaksjonelle artikler fra norske golfbaner, bygget for lange historier, sterke bilder og nyttige lenker videre til TeeOffs baneprofiler.";
export const metadata = createPageMetadata({
title: pageTitle,
@ -31,12 +18,18 @@ export const metadata = createPageMetadata({
path: "/banebesok",
});
export default function CourseVisitsPage() {
export const dynamic = "force-dynamic";
export default async function CourseVisitsPage() {
const articles = await getCourseVisits();
const featuredArticle = articles[0];
const collectionJsonLd = createCollectionPageJsonLd({
name: pageTitle,
description: pageDescription,
path: "/banebesok",
});
const breadcrumbJsonLd = createBreadcrumbJsonLd([
{ name: "Hjem", path: "/" },
{ name: "Banebesøk", path: "/banebesok" },
@ -52,50 +45,145 @@ export default function CourseVisitsPage() {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<InfoPageShell
eyebrow="Banebesøk"
title="Golfbaner fortalt som opplevelser"
intro="Banebesøk blir TeeOffs redaksjonelle del: artikler om baner vi faktisk har spilt, med bilder, inntrykk og konkrete ting det er verdt å vite før man drar dit selv."
intro="Banebesøk er TeeOffs redaksjonelle format for lange artikler om faktiske baner. Her kan dere kombinere stemning, bilder, nyttige råd og internlenker videre til den praktiske baneprofilen."
>
<div className="grid gap-6 xl:grid-cols-[1.3fr,0.9fr]">
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
Første versjon
</p>
<h2 className="mt-4 text-3xl font-black text-[#112015]">Seksjonen er klar til å fylles</h2>
<p className="mt-4 max-w-3xl text-base leading-7 text-[#4F5F50]">
Denne siden er satt opp som eget hjem for lange artikler, banebilder og personlige
vurderinger. Neste steg er å koble en faktisk artikkelmodell med egne URL-er per
banebesøk.
</p>
<div className="mt-8 grid gap-4 md:grid-cols-3">
{articlePillars.map((pillar) => (
<article key={pillar.title} className="rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] p-5">
<h3 className="text-xl font-black text-[#112015]">{pillar.title}</h3>
<p className="mt-3 text-sm leading-6 text-[#5B675C]">{pillar.text}</p>
</article>
))}
</div>
</section>
{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={`/banebesok/${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>
<Link
href={`/golfbaner/${featuredArticle.facilitySlug}`}
className="inline-flex items-center rounded-full border border-white/18 bg-white/10 px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/18"
>
til baneprofil
</Link>
</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]">
Neste anbefalte steg
</p>
<h2 className="mt-4 text-2xl font-black text-[#112015]">Bygg artikkelflyten før volumet</h2>
<p className="mt-4 text-sm leading-6 text-[#4F5F50]">
Start med et lite, redigerbart oppsett: ingress, slider, hovedtekst, faktaboks og
bildegalleri. Da blir første publisering enkel å ut uten å låse dere til et tungt
CMS-spor med en gang.
</p>
<Link
href="/kontakt"
className="mt-6 inline-flex items-center rounded-full bg-[#112015] px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
>
Planlegg første artikkel
</Link>
</aside>
</div>
<aside className="surface-card rounded-[2rem] p-6 sm:p-8">
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
Dette er plass
</p>
<h2 className="mt-4 text-3xl font-black text-[#112015]">
Første publiseringsklare struktur
</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]">
Innholdet ligger foreløpig i kode, men modellen er laget for å kunne ta imot
HTML eller editor-data senere uten at artikkelsidene designes om.
</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]">Banebesøk 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={`/banebesok/${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>
<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>
</div>
</div>
</div>
</article>
))}
</div>
</section>
</InfoPageShell>
</>
);

View file

@ -1,6 +1,7 @@
import type { MetadataRoute } from "next";
import { API_URL } from "@/config/constants";
import { getAvailablePlaceConfigs } from "@/app/facilityData";
import { getCourseVisits } from "@/content/courseVisits";
import { buildAbsoluteUrl } from "@/app/seo";
type SitemapFacility = {
@ -95,5 +96,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.7,
}));
return [...staticRoutes, ...placeRoutes, ...facilityRoutes];
const articleRoutes = (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];
}

View file

@ -0,0 +1,127 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import type { CourseVisitImage } from "@/content/courseVisits";
type CourseVisitGalleryProps = {
title: string;
images: CourseVisitImage[];
};
export default function CourseVisitGallery({ title, images }: CourseVisitGalleryProps) {
const [activeIndex, setActiveIndex] = useState(0);
const activeImage = images[activeIndex] || images[0];
if (!activeImage) {
return null;
}
const showPrevious = () => {
setActiveIndex((current) => (current === 0 ? images.length - 1 : current - 1));
};
const showNext = () => {
setActiveIndex((current) => (current === images.length - 1 ? 0 : current + 1));
};
return (
<section className="surface-card overflow-hidden rounded-[2rem]">
<div className="relative aspect-[4/5] sm:aspect-[16/10]">
<Image
src={activeImage.src}
alt={activeImage.alt}
fill
priority
sizes="(max-width: 768px) 100vw, 70vw"
className="object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-[#112015]/88 via-[#112015]/38 to-transparent px-4 pb-4 pt-16 sm:px-6 sm:pb-6">
<div className="flex items-end justify-between gap-4">
<div className="max-w-2xl">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#8BC34A]">
Bilde {activeIndex + 1} av {images.length}
</p>
<p className="mt-2 text-sm leading-6 text-white/92 sm:text-base">
{activeImage.caption}
</p>
</div>
{images.length > 1 ? (
<div className="hidden gap-2 sm:flex">
<button
type="button"
onClick={showPrevious}
className="rounded-full border border-white/20 bg-white/10 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
aria-label={`Forrige bilde i ${title}`}
>
Forrige
</button>
<button
type="button"
onClick={showNext}
className="rounded-full border border-white/20 bg-white/10 px-4 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
aria-label={`Neste bilde i ${title}`}
>
Neste
</button>
</div>
) : null}
</div>
</div>
</div>
{images.length > 1 ? (
<div className="border-t border-[#112015]/8 bg-[#F6F8F1] p-3 sm:p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{images.map((image, index) => (
<button
key={`${image.src}-${index}`}
type="button"
onClick={() => setActiveIndex(index)}
className={`group overflow-hidden rounded-[1.35rem] border text-left transition ${
index === activeIndex
? "border-[#FF5722] shadow-[0_8px_20px_rgba(17,32,21,0.08)]"
: "border-[#112015]/8 hover:border-[#8BC34A]"
}`}
aria-label={`Vis bilde ${index + 1} i ${title}`}
aria-pressed={index === activeIndex}
>
<div className="relative aspect-[4/3]">
<Image
src={image.src}
alt={image.alt}
fill
sizes="(max-width: 768px) 50vw, 20vw"
className="object-cover transition duration-300 group-hover:scale-[1.03]"
/>
</div>
<div className="bg-white px-3 py-3">
<p className="line-clamp-2 text-xs font-bold leading-5 text-[#334238]">
{image.caption}
</p>
</div>
</button>
))}
</div>
<div className="mt-3 flex gap-2 sm:hidden">
<button
type="button"
onClick={showPrevious}
className="flex-1 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]"
>
Forrige
</button>
<button
type="button"
onClick={showNext}
className="flex-1 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-[#FF5722]"
>
Neste
</button>
</div>
</div>
) : null}
</section>
);
}

View file

@ -0,0 +1,207 @@
"use client";
import { type ChangeEvent, useEffect, useRef, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
type TiptapHtmlEditorProps = {
value: string;
onChange: (html: string) => void;
placeholder?: string;
onUploadImage?: (file: File) => Promise<string>;
};
type ToolbarButtonProps = {
active?: boolean;
disabled?: boolean;
label: string;
onClick: () => void;
};
function ToolbarButton({ active = false, disabled = false, label, onClick }: ToolbarButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-3 py-2 text-[11px] font-black uppercase tracking-[0.14em] transition ${
active
? "border-[#FF5722] bg-[#FFF4EF] text-[#112015]"
: "border-[#112015]/10 bg-white text-[#536256] hover:border-[#8BC34A] hover:text-[#112015]"
} ${disabled ? "cursor-not-allowed opacity-50 hover:border-[#112015]/10 hover:text-[#536256]" : ""}`}
>
{label}
</button>
);
}
export default function TiptapHtmlEditor({
value,
onChange,
placeholder = "Skriv artikkelen her...",
onUploadImage,
}: TiptapHtmlEditorProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isUploading, setIsUploading] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [2, 3],
},
}),
Underline,
Link.configure({
autolink: true,
openOnClick: false,
protocols: ["http", "https", "mailto"],
}),
Image,
Placeholder.configure({
placeholder,
}),
],
content: value || "<p></p>",
immediatelyRender: false,
editorProps: {
attributes: {
class:
"min-h-[28rem] rounded-[1.5rem] border border-[#112015]/10 bg-white px-5 py-5 text-base leading-8 text-[#112015] outline-none focus:border-[#8BC34A]",
},
},
onUpdate: ({ editor: currentEditor }) => {
onChange(currentEditor.getHTML());
},
});
useEffect(() => {
if (!editor) return;
const currentHtml = editor.getHTML();
if (value !== currentHtml) {
editor.commands.setContent(value || "<p></p>", { emitUpdate: false });
}
}, [editor, value]);
if (!editor) {
return (
<div className="rounded-[1.5rem] border border-[#112015]/10 bg-white px-5 py-5 text-sm font-bold text-[#536256]">
Laster editor...
</div>
);
}
const setLink = () => {
const previousUrl = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("Lenke-URL", previousUrl || "https://");
if (url === null) return;
if (url.trim() === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url.trim() }).run();
};
const insertImage = () => {
const url = window.prompt("Bilde-URL", "https://");
if (!url || !url.trim()) return;
editor.chain().focus().setImage({ src: url.trim() }).run();
};
const triggerImageUpload = () => {
fileInputRef.current?.click();
};
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !onUploadImage) return;
setIsUploading(true);
try {
const url = await onUploadImage(file);
editor.chain().focus().setImage({ src: url, alt: file.name }).run();
} catch (error) {
window.alert(error instanceof Error ? error.message : "Kunne ikke laste opp bildet.");
} finally {
setIsUploading(false);
}
};
return (
<div className="space-y-4">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
<div className="flex flex-wrap gap-2 rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] p-3">
<ToolbarButton label="Brødtekst" onClick={() => editor.chain().focus().setParagraph().run()} />
<ToolbarButton
label="H2"
active={editor.isActive("heading", { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
/>
<ToolbarButton
label="H3"
active={editor.isActive("heading", { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
/>
<ToolbarButton
label="Bold"
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
/>
<ToolbarButton
label="Italic"
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
/>
<ToolbarButton
label="Underline"
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
/>
<ToolbarButton
label="Punktliste"
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
/>
<ToolbarButton
label="Nummerert"
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
/>
<ToolbarButton
label="Sitat"
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
/>
<ToolbarButton
label="Lenke"
active={editor.isActive("link")}
onClick={setLink}
/>
<ToolbarButton
label={isUploading ? "Laster opp..." : "Last opp bilde"}
onClick={triggerImageUpload}
disabled={isUploading || !onUploadImage}
/>
<ToolbarButton label="Bilde-URL" onClick={insertImage} />
<ToolbarButton label="Angre" onClick={() => editor.chain().focus().undo().run()} />
<ToolbarButton label="Gjør om" onClick={() => editor.chain().focus().redo().run()} />
</div>
<div className="rounded-[1.75rem] border border-[#112015]/8 bg-[#FCFDF9] p-3 sm:p-4">
<EditorContent editor={editor} />
</div>
</div>
);
}

View file

@ -0,0 +1,432 @@
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);
}

File diff suppressed because one or more lines are too long

View file

@ -12,14 +12,27 @@ import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const session = request.cookies.get('admin_session');
const isAdminPage = pathname.startsWith('/admin');
const isAdminApi = pathname.startsWith('/api/admin');
// 1. Tillat alltid tilgang til innloggingssiden
if (pathname.startsWith('/admin/login')) {
return NextResponse.next();
}
// 2. Beskytt alle andre ruter under /admin
if (pathname.startsWith('/admin')) {
// 2. Beskytt interne admin-API-ruter med 401 i stedet for redirect
if (isAdminApi) {
if (!session) {
return NextResponse.json({ detail: 'Admin-innlogging kreves' }, { status: 401 });
}
}
// 3. Beskytt alle andre ruter under /admin
if (isAdminPage) {
if (pathname.startsWith('/admin/login')) {
return NextResponse.next();
}
if (!session) {
// Ingen sesjon funnet -> Send til innlogging
const loginUrl = new URL('/admin/login', request.url);
@ -32,5 +45,5 @@ export function middleware(request: NextRequest) {
// Definer hvilke ruter middleware skal kjøre på
export const config = {
matcher: ['/admin/:path*'],
matcher: ['/admin/:path*', '/api/admin/:path*'],
};