Før det fungerer med npm run lint
This commit is contained in:
parent
25ca19eba1
commit
e1fcabef6a
18 changed files with 4056 additions and 68 deletions
366
backend/import_meninger.py
Normal file
366
backend/import_meninger.py
Normal 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()
|
||||||
367
backend/main.py
367
backend/main.py
|
|
@ -17,7 +17,9 @@ import asyncpg
|
||||||
import json
|
import json
|
||||||
import pyotp
|
import pyotp
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
@ -102,6 +104,25 @@ class BulkVtgRequest(BaseModel):
|
||||||
|
|
||||||
class AdminPasswordConfirm(BaseModel):
|
class AdminPasswordConfirm(BaseModel):
|
||||||
password: str
|
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 ---
|
# --- FUNKSJONER ---
|
||||||
def format_row(row):
|
def format_row(row):
|
||||||
"""
|
"""
|
||||||
|
|
@ -169,6 +190,90 @@ def generate_totp_qr_svg(provisioning_uri: str) -> str:
|
||||||
)
|
)
|
||||||
return image.to_string(encoding="unicode")
|
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):
|
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:
|
if job_type not in SCRAPE_JOB_TYPES:
|
||||||
raise HTTPException(status_code=400, detail=f"Ugyldig jobbtype: {job_type}")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Opprett database-pool ved start
|
# Opprett database-pool ved start
|
||||||
|
|
@ -220,6 +352,7 @@ async def lifespan(app: FastAPI):
|
||||||
)
|
)
|
||||||
async with app.state.pool.acquire() as conn:
|
async with app.state.pool.acquire() as conn:
|
||||||
await ensure_facility_columns(conn)
|
await ensure_facility_columns(conn)
|
||||||
|
await ensure_articles_table(conn)
|
||||||
await ensure_scrape_jobs_table(conn)
|
await ensure_scrape_jobs_table(conn)
|
||||||
print("✅ Database tilkoblet og pool opprettet")
|
print("✅ Database tilkoblet og pool opprettet")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -387,8 +520,242 @@ async def get_facility(slug: str):
|
||||||
|
|
||||||
return format_row(row)
|
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 ---
|
# --- 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")
|
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
||||||
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
|
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)."""
|
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ services:
|
||||||
command: npm start
|
command: npm start
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./frontend/public/uploads:/app/public/uploads
|
||||||
# VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
|
# VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
|
||||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
|
|
@ -19,6 +19,7 @@
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
/public/uploads
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,23 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
export default nextConfig;
|
||||||
|
|
|
||||||
889
frontend/package-lock.json
generated
889
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,13 @@
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|
|
||||||
709
frontend/src/app/admin/artikler/page.tsx
Normal file
709
frontend/src/app/admin/artikler/page.tsx
Normal 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 nå 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 nå laste opp bildefiler direkte. Uploadene prosesseres på 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -730,6 +730,13 @@ export default function AdminDashboard() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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="space-y-2">
|
||||||
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Konto</div>
|
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Konto</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -779,6 +786,12 @@ export default function AdminDashboard() {
|
||||||
{isSidebarCollapsed ? 'V' : 'VTG'}
|
{isSidebarCollapsed ? 'V' : 'VTG'}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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="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>
|
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Konto</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -818,6 +831,12 @@ export default function AdminDashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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
|
<button
|
||||||
onClick={openTwoFactorModal}
|
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]"
|
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]"
|
||||||
|
|
|
||||||
100
frontend/src/app/api/admin/uploads/images/route.ts
Normal file
100
frontend/src/app/api/admin/uploads/images/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
270
frontend/src/app/banebesok/[slug]/page.tsx
Normal file
270
frontend/src/app/banebesok/[slug]/page.tsx
Normal 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 gå 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import InfoPageShell from "@/components/InfoPageShell";
|
import InfoPageShell from "@/components/InfoPageShell";
|
||||||
|
import { getCourseVisits } from "@/content/courseVisits";
|
||||||
import {
|
import {
|
||||||
createBreadcrumbJsonLd,
|
createBreadcrumbJsonLd,
|
||||||
createCollectionPageJsonLd,
|
createCollectionPageJsonLd,
|
||||||
|
|
@ -8,22 +10,7 @@ import {
|
||||||
|
|
||||||
const pageTitle = "Banebesøk";
|
const pageTitle = "Banebesøk";
|
||||||
const pageDescription =
|
const pageDescription =
|
||||||
"Personlige artikler fra golfbaner TeeOff har spilt, med bilder, inntrykk og detaljer som er nyttige før ditt eget besøk.";
|
"Redaksjonelle artikler fra norske golfbaner, bygget for lange historier, sterke bilder og nyttige lenker videre til TeeOffs baneprofiler.";
|
||||||
|
|
||||||
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.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const metadata = createPageMetadata({
|
export const metadata = createPageMetadata({
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
|
|
@ -31,12 +18,18 @@ export const metadata = createPageMetadata({
|
||||||
path: "/banebesok",
|
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({
|
const collectionJsonLd = createCollectionPageJsonLd({
|
||||||
name: pageTitle,
|
name: pageTitle,
|
||||||
description: pageDescription,
|
description: pageDescription,
|
||||||
path: "/banebesok",
|
path: "/banebesok",
|
||||||
});
|
});
|
||||||
|
|
||||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||||
{ name: "Hjem", path: "/" },
|
{ name: "Hjem", path: "/" },
|
||||||
{ name: "Banebesøk", path: "/banebesok" },
|
{ name: "Banebesøk", path: "/banebesok" },
|
||||||
|
|
@ -52,50 +45,145 @@ export default function CourseVisitsPage() {
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InfoPageShell
|
<InfoPageShell
|
||||||
eyebrow="Banebesøk"
|
eyebrow="Banebesøk"
|
||||||
title="Golfbaner fortalt som opplevelser"
|
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]">
|
{featuredArticle ? (
|
||||||
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
|
<section className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
||||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
<article className="surface-card overflow-hidden rounded-[2rem]">
|
||||||
Første versjon
|
<div className="relative aspect-[4/5] sm:aspect-[16/10]">
|
||||||
</p>
|
<Image
|
||||||
<h2 className="mt-4 text-3xl font-black text-[#112015]">Seksjonen er klar til å fylles</h2>
|
src={featuredArticle.heroImages[0].src}
|
||||||
<p className="mt-4 max-w-3xl text-base leading-7 text-[#4F5F50]">
|
alt={featuredArticle.heroImages[0].alt}
|
||||||
Denne siden er satt opp som eget hjem for lange artikler, banebilder og personlige
|
fill
|
||||||
vurderinger. Neste steg er å koble på en faktisk artikkelmodell med egne URL-er per
|
priority
|
||||||
banebesøk.
|
sizes="(max-width: 768px) 100vw, 70vw"
|
||||||
</p>
|
className="object-cover"
|
||||||
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
/>
|
||||||
{articlePillars.map((pillar) => (
|
<div className="absolute inset-0 bg-gradient-to-t from-[#112015]/90 via-[#112015]/42 to-transparent" />
|
||||||
<article key={pillar.title} className="rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] p-5">
|
<div className="absolute inset-x-0 bottom-0 p-5 sm:p-8">
|
||||||
<h3 className="text-xl font-black text-[#112015]">{pillar.title}</h3>
|
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||||
<p className="mt-3 text-sm leading-6 text-[#5B675C]">{pillar.text}</p>
|
{featuredArticle.eyebrow}
|
||||||
</article>
|
</p>
|
||||||
))}
|
<h2 className="mt-4 max-w-3xl text-4xl font-black text-white sm:text-5xl">
|
||||||
</div>
|
{featuredArticle.title}
|
||||||
</section>
|
</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"
|
||||||
|
>
|
||||||
|
Gå til baneprofil
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<aside className="surface-card rounded-[2rem] p-6 sm:p-8">
|
<aside className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
|
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
|
||||||
Neste anbefalte steg
|
Dette er på plass nå
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-4 text-2xl font-black text-[#112015]">Bygg artikkelflyten før volumet</h2>
|
<h2 className="mt-4 text-3xl font-black text-[#112015]">
|
||||||
<p className="mt-4 text-sm leading-6 text-[#4F5F50]">
|
Første publiseringsklare struktur
|
||||||
Start med et lite, redigerbart oppsett: ingress, slider, hovedtekst, faktaboks og
|
</h2>
|
||||||
bildegalleri. Da blir første publisering enkel å få ut uten å låse dere til et tungt
|
<div className="mt-6 grid gap-3">
|
||||||
CMS-spor med en gang.
|
{featuredArticle.highlights.map((highlight) => (
|
||||||
</p>
|
<div
|
||||||
<Link
|
key={highlight}
|
||||||
href="/kontakt"
|
className="rounded-[1.4rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-4"
|
||||||
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]"
|
>
|
||||||
>
|
<p className="text-sm font-bold leading-6 text-[#334238]">{highlight}</p>
|
||||||
Planlegg første artikkel
|
</div>
|
||||||
</Link>
|
))}
|
||||||
</aside>
|
</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 må 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>
|
</InfoPageShell>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import { getAvailablePlaceConfigs } from "@/app/facilityData";
|
import { getAvailablePlaceConfigs } from "@/app/facilityData";
|
||||||
|
import { getCourseVisits } from "@/content/courseVisits";
|
||||||
import { buildAbsoluteUrl } from "@/app/seo";
|
import { buildAbsoluteUrl } from "@/app/seo";
|
||||||
|
|
||||||
type SitemapFacility = {
|
type SitemapFacility = {
|
||||||
|
|
@ -95,5 +96,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
priority: 0.7,
|
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];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
frontend/src/components/CourseVisitGallery.tsx
Normal file
127
frontend/src/components/CourseVisitGallery.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/components/TiptapHtmlEditor.tsx
Normal file
207
frontend/src/components/TiptapHtmlEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
frontend/src/content/courseVisits.ts
Normal file
432
frontend/src/content/courseVisits.ts
Normal 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(/…/g, "...")
|
||||||
|
.replace(/…/g, "...")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/«/g, "«")
|
||||||
|
.replace(/»/g, "»")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(value: string) {
|
||||||
|
return decodeEntities(value).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("nb-NO", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadingTime(html: string) {
|
||||||
|
const wordCount = stripHtml(html).split(/\s+/).filter(Boolean).length;
|
||||||
|
const minutes = Math.max(3, Math.round(wordCount / 220));
|
||||||
|
return `${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFacilityMeta(slug: string) {
|
||||||
|
return facilityMetaBySlug[slug] || {
|
||||||
|
name: slug
|
||||||
|
.split("-")
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" "),
|
||||||
|
region: "Norge",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInternalLinks(html: string) {
|
||||||
|
return html.replace(teeoffInternalLinkPattern, (fullMatch, rawPath: string) => {
|
||||||
|
const path = rawPath.split("?")[0].replace(/\/+$/, "");
|
||||||
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
if (segments.length === 0 || disallowedSegments.has(segments[0])) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = segments[segments.length - 1];
|
||||||
|
if (!candidate.includes("golf")) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/golfbaner/${candidate}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImagesFromHtml(html: string, articleTitle: string) {
|
||||||
|
const images: CourseVisitImage[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const match of html.matchAll(imageTagPattern)) {
|
||||||
|
const src = match[1];
|
||||||
|
const alt = decodeEntities(match[2] || "").trim();
|
||||||
|
if (!src || seen.has(src) || (!src.includes("/wp-content/uploads/") && !src.includes("i.ytimg.com"))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(src);
|
||||||
|
images.push({
|
||||||
|
src,
|
||||||
|
alt: alt || articleTitle,
|
||||||
|
caption: alt || articleTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
for (const match of html.matchAll(imageTagWithoutAltPattern)) {
|
||||||
|
const src = match[1];
|
||||||
|
if (!src || seen.has(src) || !src.includes("/wp-content/uploads/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(src);
|
||||||
|
images.push({
|
||||||
|
src,
|
||||||
|
alt: articleTitle,
|
||||||
|
caption: articleTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapImportedArticle(entry: ImportedMeningerRecord): CourseVisitArticle | null {
|
||||||
|
const facilitySlug = entry.primaryFacilitySlug || entry.facilitySlugs?.[0];
|
||||||
|
if (!facilitySlug) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const facilityMeta = getFacilityMeta(facilitySlug);
|
||||||
|
const normalizedHtml = normalizeInternalLinks(entry.contentHtml || "");
|
||||||
|
const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title);
|
||||||
|
const featuredImage = entry.featuredImage?.url
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
src: entry.featuredImage.url,
|
||||||
|
alt: entry.featuredImage.alt || entry.title,
|
||||||
|
caption: entry.featuredImage.caption || entry.title,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const heroImages = [...featuredImage, ...extractedImages]
|
||||||
|
.filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
const excerpt = entry.excerpt || stripHtml(normalizedHtml).slice(0, 220);
|
||||||
|
const formattedPublishedAt = formatDate(entry.publishedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: entry.slug,
|
||||||
|
eyebrow: "Banebesøk",
|
||||||
|
title: entry.title,
|
||||||
|
description: excerpt,
|
||||||
|
excerpt,
|
||||||
|
locationLabel: facilityMeta.region,
|
||||||
|
facilityName: facilityMeta.name,
|
||||||
|
facilitySlug,
|
||||||
|
publishedAt: entry.publishedAt,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
readingTime: getReadingTime(normalizedHtml),
|
||||||
|
heroImages:
|
||||||
|
heroImages.length > 0
|
||||||
|
? heroImages
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
src: "/Toppbilde-standard.jpg",
|
||||||
|
alt: entry.title,
|
||||||
|
caption: entry.title,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quickFacts: [
|
||||||
|
{
|
||||||
|
label: "Baneprofil",
|
||||||
|
value: facilityMeta.name,
|
||||||
|
href: `/golfbaner/${facilitySlug}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Publisert",
|
||||||
|
value: formattedPublishedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Forfatter",
|
||||||
|
value: entry.author?.name || "TeeOff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Kildespor",
|
||||||
|
value: "Importert fra gamle TeeOff",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
highlights: [
|
||||||
|
"Originalartikkel importert fra gamle TeeOff.",
|
||||||
|
`Koblet til dagens baneprofil for ${facilityMeta.name}.`,
|
||||||
|
"Bevarer originaltekst, originale bilder og langlesingsformat.",
|
||||||
|
"Kan senere flyttes til database eller editor uten å kaste artikkel-UI-et.",
|
||||||
|
],
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "richText",
|
||||||
|
title: "Original artikkel",
|
||||||
|
html: normalizedHtml,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceUrl: entry.link,
|
||||||
|
sourceLabel: "Importert fra gamle TeeOff",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiArticle(entry: CourseVisitApiRecord): CourseVisitArticle | null {
|
||||||
|
const facilitySlug = String(entry.facility_slug || "").trim();
|
||||||
|
if (!facilitySlug) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const facilityMeta = getFacilityMeta(facilitySlug);
|
||||||
|
const facilityName = String(entry.facility_name || "").trim() || facilityMeta.name;
|
||||||
|
const locationLabel = String(entry.location_label || "").trim() || facilityMeta.region;
|
||||||
|
const normalizedHtml = normalizeInternalLinks(String(entry.content_html || ""));
|
||||||
|
const extractedImages = extractImagesFromHtml(normalizedHtml, entry.title);
|
||||||
|
const dbImages = Array.isArray(entry.hero_images) ? entry.hero_images : [];
|
||||||
|
const heroImages = [...dbImages, ...extractedImages]
|
||||||
|
.filter((image): image is CourseVisitImage => Boolean(image?.src))
|
||||||
|
.map((image) => ({
|
||||||
|
src: image.src,
|
||||||
|
alt: image.alt || entry.title,
|
||||||
|
caption: image.caption || image.alt || entry.title,
|
||||||
|
}))
|
||||||
|
.filter((image, index, list) => list.findIndex((candidate) => candidate.src === image.src) === index)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
const publishedAt = String(entry.published_at || entry.updated_at || "");
|
||||||
|
const excerpt =
|
||||||
|
String(entry.excerpt || "").trim() ||
|
||||||
|
String(entry.description || "").trim() ||
|
||||||
|
stripHtml(normalizedHtml).slice(0, 220);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: entry.slug,
|
||||||
|
eyebrow: String(entry.eyebrow || "").trim() || "Banebesøk",
|
||||||
|
title: entry.title,
|
||||||
|
description: String(entry.description || "").trim() || excerpt,
|
||||||
|
excerpt,
|
||||||
|
locationLabel,
|
||||||
|
facilityName,
|
||||||
|
facilitySlug,
|
||||||
|
publishedAt,
|
||||||
|
updatedAt: String(entry.updated_at || "").trim() || undefined,
|
||||||
|
readingTime: getReadingTime(normalizedHtml),
|
||||||
|
heroImages:
|
||||||
|
heroImages.length > 0
|
||||||
|
? heroImages
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
src: "/Toppbilde-standard.jpg",
|
||||||
|
alt: entry.title,
|
||||||
|
caption: entry.title,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quickFacts: [
|
||||||
|
{
|
||||||
|
label: "Baneprofil",
|
||||||
|
value: facilityName,
|
||||||
|
href: `/golfbaner/${facilitySlug}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Publisert",
|
||||||
|
value: publishedAt ? formatDate(publishedAt) : "Ikke datert",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Forfatter",
|
||||||
|
value: String(entry.author_name || "").trim() || "TeeOff",
|
||||||
|
},
|
||||||
|
...(entry.source_label
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Kildespor",
|
||||||
|
value: String(entry.source_label),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
highlights: [
|
||||||
|
`Koblet til dagens baneprofil for ${facilityName}.`,
|
||||||
|
"Lagret som redaksjonell artikkel i TeeOff-admin.",
|
||||||
|
"Kan redigeres videre som HTML uten å miste artikkeloppsettet.",
|
||||||
|
"Beholder mobilvennlig hero, faktaboks og langlesingsstruktur.",
|
||||||
|
],
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "richText",
|
||||||
|
title: "Artikkel",
|
||||||
|
html: normalizedHtml,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceUrl: String(entry.source_url || "").trim() || undefined,
|
||||||
|
sourceLabel: String(entry.source_label || "").trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackCourseVisits = (importedMeninger as ImportedMeningerRecord[])
|
||||||
|
.map(mapImportedArticle)
|
||||||
|
.filter((article): article is CourseVisitArticle => Boolean(article))
|
||||||
|
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
||||||
|
|
||||||
|
export async function getCourseVisits() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/course-visits`, { cache: "no-store" });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const mapped = data
|
||||||
|
.map((entry) => mapApiArticle(entry as CourseVisitApiRecord))
|
||||||
|
.filter((article): article is CourseVisitArticle => Boolean(article));
|
||||||
|
if (mapped.length > 0) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackCourseVisits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCourseVisitBySlug(slug: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/course-visits/${slug}`, { cache: "no-store" });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const mapped = mapApiArticle(data as CourseVisitApiRecord);
|
||||||
|
if (mapped) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackCourseVisits.find((article) => article.slug === slug);
|
||||||
|
}
|
||||||
379
frontend/src/content/importedMeninger.json
Normal file
379
frontend/src/content/importedMeninger.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -12,14 +12,27 @@ import { NextResponse, type NextRequest } from 'next/server';
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
const session = request.cookies.get('admin_session');
|
const session = request.cookies.get('admin_session');
|
||||||
|
const isAdminPage = pathname.startsWith('/admin');
|
||||||
|
const isAdminApi = pathname.startsWith('/api/admin');
|
||||||
|
|
||||||
// 1. Tillat alltid tilgang til innloggingssiden
|
// 1. Tillat alltid tilgang til innloggingssiden
|
||||||
if (pathname.startsWith('/admin/login')) {
|
if (pathname.startsWith('/admin/login')) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Beskytt alle andre ruter under /admin
|
// 2. Beskytt interne admin-API-ruter med 401 i stedet for redirect
|
||||||
if (pathname.startsWith('/admin')) {
|
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) {
|
if (!session) {
|
||||||
// Ingen sesjon funnet -> Send til innlogging
|
// Ingen sesjon funnet -> Send til innlogging
|
||||||
const loginUrl = new URL('/admin/login', request.url);
|
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å
|
// Definer hvilke ruter middleware skal kjøre på
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/admin/:path*'],
|
matcher: ['/admin/:path*', '/api/admin/:path*'],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue