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 pyotp
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, date, timedelta
|
||||
from pathlib import Path
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from dotenv import load_dotenv
|
||||
|
|
@ -102,6 +104,25 @@ class BulkVtgRequest(BaseModel):
|
|||
|
||||
class AdminPasswordConfirm(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class ArticleUpsertRequest(BaseModel):
|
||||
slug: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
excerpt: Optional[str] = None
|
||||
eyebrow: Optional[str] = None
|
||||
location_label: Optional[str] = None
|
||||
facility_name: Optional[str] = None
|
||||
facility_slug: Optional[str] = None
|
||||
author_name: Optional[str] = None
|
||||
status: Optional[str] = "draft"
|
||||
hero_images: Optional[List[dict[str, Any]]] = []
|
||||
content_html: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
source_label: Optional[str] = None
|
||||
published_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
# --- FUNKSJONER ---
|
||||
def format_row(row):
|
||||
"""
|
||||
|
|
@ -169,6 +190,90 @@ def generate_totp_qr_svg(provisioning_uri: str) -> str:
|
|||
)
|
||||
return image.to_string(encoding="unicode")
|
||||
|
||||
|
||||
def format_article_row(row):
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
data = dict(row)
|
||||
for key in ["published_at", "updated_at", "created_at"]:
|
||||
if isinstance(data.get(key), (date, datetime)):
|
||||
data[key] = data[key].isoformat()
|
||||
|
||||
hero_images = data.get("hero_images")
|
||||
if hero_images is None:
|
||||
data["hero_images"] = []
|
||||
elif isinstance(hero_images, str):
|
||||
try:
|
||||
data["hero_images"] = json.loads(hero_images)
|
||||
except Exception:
|
||||
data["hero_images"] = []
|
||||
elif not isinstance(hero_images, list):
|
||||
data["hero_images"] = []
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def normalize_article_status(status: str | None) -> str:
|
||||
normalized = str(status or "draft").strip().lower()
|
||||
if normalized not in {"draft", "published"}:
|
||||
raise HTTPException(status_code=400, detail="Ugyldig artikkelstatus. Bruk 'draft' eller 'published'.")
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_optional_datetime(value: str | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
trimmed = str(value).strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(trimmed.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Ugyldig datoformat: {value}") from exc
|
||||
|
||||
|
||||
def sanitize_hero_images(value: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
sanitized: list[dict[str, str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
src = str(item.get("src") or "").strip()
|
||||
if not src:
|
||||
continue
|
||||
sanitized.append(
|
||||
{
|
||||
"src": src,
|
||||
"alt": str(item.get("alt") or "").strip(),
|
||||
"caption": str(item.get("caption") or "").strip(),
|
||||
}
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
def humanize_slug(slug: str | None) -> str:
|
||||
if not slug:
|
||||
return "Ukjent bane"
|
||||
return " ".join(part.capitalize() for part in str(slug).split("-") if part)
|
||||
|
||||
|
||||
ARTICLE_IMAGE_PATTERN = re.compile(r"<img\b[^>]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE)
|
||||
|
||||
|
||||
def extract_html_image_urls(html: str | None) -> list[str]:
|
||||
urls: list[str] = []
|
||||
for url in ARTICLE_IMAGE_PATTERN.findall(html or ""):
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
continue
|
||||
urls.append(url.strip())
|
||||
deduped: dict[str, None] = {}
|
||||
for url in urls:
|
||||
deduped[url] = None
|
||||
return list(deduped.keys())
|
||||
|
||||
async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: str | None = None):
|
||||
if job_type not in SCRAPE_JOB_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"Ugyldig jobbtype: {job_type}")
|
||||
|
|
@ -207,6 +312,33 @@ async def ensure_facility_columns(conn):
|
|||
""")
|
||||
|
||||
|
||||
async def ensure_articles_table(conn):
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
excerpt TEXT,
|
||||
eyebrow VARCHAR(120) DEFAULT 'Banebesøk',
|
||||
location_label VARCHAR(255),
|
||||
facility_name VARCHAR(255),
|
||||
facility_slug VARCHAR(255),
|
||||
author_name VARCHAR(255),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
hero_images JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
content_html TEXT,
|
||||
source_url TEXT,
|
||||
source_label VARCHAR(255),
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS articles_status_idx ON articles (status)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS articles_published_at_idx ON articles (published_at DESC)")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Opprett database-pool ved start
|
||||
|
|
@ -220,6 +352,7 @@ async def lifespan(app: FastAPI):
|
|||
)
|
||||
async with app.state.pool.acquire() as conn:
|
||||
await ensure_facility_columns(conn)
|
||||
await ensure_articles_table(conn)
|
||||
await ensure_scrape_jobs_table(conn)
|
||||
print("✅ Database tilkoblet og pool opprettet")
|
||||
except Exception as e:
|
||||
|
|
@ -387,8 +520,242 @@ async def get_facility(slug: str):
|
|||
|
||||
return format_row(row)
|
||||
|
||||
|
||||
@app.get("/api/course-visits")
|
||||
async def get_course_visits():
|
||||
"""Henter publiserte Banebesøk-artikler."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT *
|
||||
FROM articles
|
||||
WHERE status = 'published'
|
||||
ORDER BY COALESCE(published_at, created_at) DESC, id DESC
|
||||
""")
|
||||
return [format_article_row(row) for row in rows]
|
||||
|
||||
|
||||
@app.get("/api/course-visits/{slug}")
|
||||
async def get_course_visit(slug: str):
|
||||
"""Henter én publisert Banebesøk-artikkel."""
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT *
|
||||
FROM articles
|
||||
WHERE slug = $1 AND status = 'published'
|
||||
""", slug)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
|
||||
return format_article_row(row)
|
||||
|
||||
# --- ADMIN ENDPOINTS ---
|
||||
|
||||
|
||||
@app.get("/api/admin/articles")
|
||||
async def get_admin_articles(status: Optional[str] = Query(default="all")):
|
||||
"""Henter artikler for admin med valgfritt statusfilter."""
|
||||
normalized_status = str(status or "all").strip().lower()
|
||||
if normalized_status not in {"all", "draft", "published"}:
|
||||
raise HTTPException(status_code=400, detail="Ugyldig statusfilter")
|
||||
|
||||
query = """
|
||||
SELECT *
|
||||
FROM articles
|
||||
{where_clause}
|
||||
ORDER BY COALESCE(published_at, created_at) DESC, updated_at DESC, id DESC
|
||||
"""
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
if normalized_status == "all":
|
||||
rows = await conn.fetch(query.format(where_clause=""))
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
query.format(where_clause="WHERE status = $1"),
|
||||
normalized_status,
|
||||
)
|
||||
return [format_article_row(row) for row in rows]
|
||||
|
||||
|
||||
@app.get("/api/admin/articles/{article_id}")
|
||||
async def get_admin_article(article_id: int):
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("SELECT * FROM articles WHERE id = $1", article_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
|
||||
return format_article_row(row)
|
||||
|
||||
|
||||
@app.post("/api/admin/articles")
|
||||
async def upsert_admin_article(request: ArticleUpsertRequest):
|
||||
status = normalize_article_status(request.status)
|
||||
published_at = parse_optional_datetime(request.published_at)
|
||||
updated_at = parse_optional_datetime(request.updated_at) or datetime.utcnow()
|
||||
if status == "published" and not published_at:
|
||||
published_at = datetime.utcnow()
|
||||
|
||||
hero_images = sanitize_hero_images(request.hero_images)
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO articles (
|
||||
slug, title, description, excerpt, eyebrow, location_label,
|
||||
facility_name, facility_slug, author_name, status, hero_images,
|
||||
content_html, source_url, source_label, published_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11::jsonb,
|
||||
$12, $13, $14, $15, $16
|
||||
)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
excerpt = EXCLUDED.excerpt,
|
||||
eyebrow = EXCLUDED.eyebrow,
|
||||
location_label = EXCLUDED.location_label,
|
||||
facility_name = EXCLUDED.facility_name,
|
||||
facility_slug = EXCLUDED.facility_slug,
|
||||
author_name = EXCLUDED.author_name,
|
||||
status = EXCLUDED.status,
|
||||
hero_images = EXCLUDED.hero_images,
|
||||
content_html = EXCLUDED.content_html,
|
||||
source_url = EXCLUDED.source_url,
|
||||
source_label = EXCLUDED.source_label,
|
||||
published_at = EXCLUDED.published_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
""",
|
||||
request.slug.strip(),
|
||||
request.title.strip(),
|
||||
(request.description or "").strip() or None,
|
||||
(request.excerpt or "").strip() or None,
|
||||
(request.eyebrow or "Banebesøk").strip(),
|
||||
(request.location_label or "").strip() or None,
|
||||
(request.facility_name or "").strip() or None,
|
||||
(request.facility_slug or "").strip() or None,
|
||||
(request.author_name or "TeeOff").strip(),
|
||||
status,
|
||||
json.dumps(hero_images),
|
||||
request.content_html or "",
|
||||
(request.source_url or "").strip() or None,
|
||||
(request.source_label or "").strip() or None,
|
||||
published_at,
|
||||
updated_at,
|
||||
)
|
||||
return format_article_row(row)
|
||||
|
||||
|
||||
@app.delete("/api/admin/articles/{article_id}")
|
||||
async def delete_admin_article(article_id: int):
|
||||
async with app.state.pool.acquire() as conn:
|
||||
deleted = await conn.fetchrow("DELETE FROM articles WHERE id = $1 RETURNING id", article_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@app.post("/api/admin/articles/seed-imported")
|
||||
async def seed_admin_articles_from_imported_json():
|
||||
imported_path = Path("/opt/teeoff/frontend/src/content/importedMeninger.json")
|
||||
if not imported_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Fant ikke importedMeninger.json")
|
||||
|
||||
try:
|
||||
imported_articles = json.loads(imported_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail="Kunne ikke lese importedMeninger.json") from exc
|
||||
|
||||
async with app.state.pool.acquire() as conn:
|
||||
facility_rows = await conn.fetch("SELECT slug, name, county FROM facilities")
|
||||
facility_lookup = {
|
||||
str(row["slug"]): {
|
||||
"name": row["name"],
|
||||
"county": row["county"],
|
||||
}
|
||||
for row in facility_rows
|
||||
}
|
||||
|
||||
upserted_count = 0
|
||||
async with conn.transaction():
|
||||
for item in imported_articles:
|
||||
facility_slug = item.get("primaryFacilitySlug") or ((item.get("facilitySlugs") or [None])[0])
|
||||
if not facility_slug:
|
||||
continue
|
||||
|
||||
facility = facility_lookup.get(str(facility_slug), {})
|
||||
content_html = str(item.get("contentHtml") or "")
|
||||
featured_image = item.get("featuredImage") or {}
|
||||
|
||||
hero_images: list[dict[str, str]] = []
|
||||
featured_url = str(featured_image.get("url") or "").strip()
|
||||
if featured_url:
|
||||
hero_images.append(
|
||||
{
|
||||
"src": featured_url,
|
||||
"alt": str(featured_image.get("alt") or item.get("title") or "").strip(),
|
||||
"caption": str(featured_image.get("caption") or item.get("title") or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
for url in extract_html_image_urls(content_html)[:5]:
|
||||
if any(existing["src"] == url for existing in hero_images):
|
||||
continue
|
||||
hero_images.append(
|
||||
{
|
||||
"src": url,
|
||||
"alt": str(item.get("title") or "").strip(),
|
||||
"caption": str(item.get("title") or "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
published_at = parse_optional_datetime(item.get("publishedAt"))
|
||||
updated_at = parse_optional_datetime(item.get("updatedAt")) or published_at or datetime.utcnow()
|
||||
|
||||
await conn.execute("""
|
||||
INSERT INTO articles (
|
||||
slug, title, description, excerpt, eyebrow, location_label,
|
||||
facility_name, facility_slug, author_name, status, hero_images,
|
||||
content_html, source_url, source_label, published_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, 'published', $10::jsonb,
|
||||
$11, $12, $13, $14, $15
|
||||
)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
excerpt = EXCLUDED.excerpt,
|
||||
eyebrow = EXCLUDED.eyebrow,
|
||||
location_label = EXCLUDED.location_label,
|
||||
facility_name = EXCLUDED.facility_name,
|
||||
facility_slug = EXCLUDED.facility_slug,
|
||||
author_name = EXCLUDED.author_name,
|
||||
status = EXCLUDED.status,
|
||||
hero_images = EXCLUDED.hero_images,
|
||||
content_html = EXCLUDED.content_html,
|
||||
source_url = EXCLUDED.source_url,
|
||||
source_label = EXCLUDED.source_label,
|
||||
published_at = EXCLUDED.published_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""",
|
||||
str(item.get("slug") or "").strip(),
|
||||
str(item.get("title") or "").strip(),
|
||||
str(item.get("excerpt") or "").strip() or None,
|
||||
str(item.get("excerpt") or "").strip() or None,
|
||||
"Banebesøk",
|
||||
str(facility.get("county") or "Norge"),
|
||||
str(facility.get("name") or humanize_slug(str(facility_slug))),
|
||||
str(facility_slug),
|
||||
str(((item.get("author") or {}).get("name")) or "TeeOff"),
|
||||
json.dumps(hero_images),
|
||||
content_html,
|
||||
str(item.get("link") or "").strip() or None,
|
||||
"Importert fra gamle TeeOff",
|
||||
published_at,
|
||||
updated_at,
|
||||
)
|
||||
upserted_count += 1
|
||||
|
||||
return {"status": "success", "count": upserted_count}
|
||||
|
||||
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
||||
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
|
||||
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ services:
|
|||
command: npm start
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend/public/uploads:/app/public/uploads
|
||||
# VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet
|
||||
depends_on:
|
||||
- api
|
||||
|
|
|
|||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
# production
|
||||
/build
|
||||
/public/uploads
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -1,7 +1,23 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "teeoff.no",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "www.teeoff.no",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "i.ytimg.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^3.22.3",
|
||||
"@tiptap/extension-link": "^3.22.3",
|
||||
"@tiptap/extension-placeholder": "^3.22.3",
|
||||
"@tiptap/extension-underline": "^3.22.3",
|
||||
"@tiptap/pm": "^3.22.3",
|
||||
"@tiptap/react": "^3.22.3",
|
||||
"@tiptap/starter-kit": "^3.22.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
|
|
|
|||
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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Innhold</div>
|
||||
<Link href="/admin/artikler" onClick={() => setShowMobileAdminMenu(false)} className="block rounded-2xl px-4 py-3 hover:bg-white/5 hover:text-white">
|
||||
Artikler
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-[9px] font-bold uppercase tracking-widest text-gray-500">Konto</div>
|
||||
<button
|
||||
|
|
@ -779,6 +786,12 @@ export default function AdminDashboard() {
|
|||
{isSidebarCollapsed ? 'V' : 'VTG'}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2 mt-6">
|
||||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Innhold</div>
|
||||
<Link href="/admin/artikler" className={`block hover:text-white cursor-pointer py-1 transition-colors ${isSidebarCollapsed ? 'pl-0 text-center text-xs' : 'pl-4 border-l-4 border-transparent'}`} title="Artikler">
|
||||
{isSidebarCollapsed ? 'A' : 'Artikler'}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2 mt-6">
|
||||
<div className="text-[8px] text-gray-500 font-bold uppercase tracking-widest pl-4 mb-2 opacity-50">Konto</div>
|
||||
<button
|
||||
|
|
@ -818,6 +831,12 @@ export default function AdminDashboard() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/admin/artikler"
|
||||
className="rounded-2xl border border-gray-200 bg-white px-5 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 shadow-sm transition-colors hover:border-[#ff5722] hover:text-[#11280f]"
|
||||
>
|
||||
Artikler / Banebesøk
|
||||
</Link>
|
||||
<button
|
||||
onClick={openTwoFactorModal}
|
||||
className="rounded-2xl border border-gray-200 bg-white px-5 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 shadow-sm transition-colors hover:border-[#8bc34a] hover:text-[#11280f]"
|
||||
|
|
|
|||
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 InfoPageShell from "@/components/InfoPageShell";
|
||||
import { getCourseVisits } from "@/content/courseVisits";
|
||||
import {
|
||||
createBreadcrumbJsonLd,
|
||||
createCollectionPageJsonLd,
|
||||
|
|
@ -8,22 +10,7 @@ import {
|
|||
|
||||
const pageTitle = "Banebesøk";
|
||||
const pageDescription =
|
||||
"Personlige artikler fra golfbaner TeeOff har spilt, med bilder, inntrykk og detaljer som er nyttige før ditt eget besøk.";
|
||||
|
||||
const articlePillars = [
|
||||
{
|
||||
title: "Baneguide med personlighet",
|
||||
text: "Hver artikkel skal være mer enn faktaark. Målet er å beskrive flyt, inntrykk og hva som faktisk gjør banen minneverdig.",
|
||||
},
|
||||
{
|
||||
title: "Foto og stemning",
|
||||
text: "Toppslider, utvalgte bilder og tydelige avsnitt gjør at banebesøk kan fungere både som inspirasjon og som planlegging.",
|
||||
},
|
||||
{
|
||||
title: "Norsk golfkontekst",
|
||||
text: "Banebesøk skal løfte fram særpreg ved norske anlegg i stedet for å bli en generisk reiseblogg.",
|
||||
},
|
||||
];
|
||||
"Redaksjonelle artikler fra norske golfbaner, bygget for lange historier, sterke bilder og nyttige lenker videre til TeeOffs baneprofiler.";
|
||||
|
||||
export const metadata = createPageMetadata({
|
||||
title: pageTitle,
|
||||
|
|
@ -31,12 +18,18 @@ export const metadata = createPageMetadata({
|
|||
path: "/banebesok",
|
||||
});
|
||||
|
||||
export default function CourseVisitsPage() {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CourseVisitsPage() {
|
||||
const articles = await getCourseVisits();
|
||||
const featuredArticle = articles[0];
|
||||
|
||||
const collectionJsonLd = createCollectionPageJsonLd({
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
path: "/banebesok",
|
||||
});
|
||||
|
||||
const breadcrumbJsonLd = createBreadcrumbJsonLd([
|
||||
{ name: "Hjem", path: "/" },
|
||||
{ name: "Banebesøk", path: "/banebesok" },
|
||||
|
|
@ -52,50 +45,145 @@ export default function CourseVisitsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||
/>
|
||||
|
||||
<InfoPageShell
|
||||
eyebrow="Banebesøk"
|
||||
title="Golfbaner fortalt som opplevelser"
|
||||
intro="Banebesøk blir TeeOffs redaksjonelle del: artikler om baner vi faktisk har spilt, med bilder, inntrykk og konkrete ting det er verdt å vite før man drar dit selv."
|
||||
intro="Banebesøk er TeeOffs redaksjonelle format for lange artikler om faktiske baner. Her kan dere kombinere stemning, bilder, nyttige råd og internlenker videre til den praktiske baneprofilen."
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[1.3fr,0.9fr]">
|
||||
<section className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||
Første versjon
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-black text-[#112015]">Seksjonen er klar til å fylles</h2>
|
||||
<p className="mt-4 max-w-3xl text-base leading-7 text-[#4F5F50]">
|
||||
Denne siden er satt opp som eget hjem for lange artikler, banebilder og personlige
|
||||
vurderinger. Neste steg er å koble på en faktisk artikkelmodell med egne URL-er per
|
||||
banebesøk.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
||||
{articlePillars.map((pillar) => (
|
||||
<article key={pillar.title} className="rounded-[1.5rem] border border-[#112015]/8 bg-[#F7F9F2] p-5">
|
||||
<h3 className="text-xl font-black text-[#112015]">{pillar.title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-[#5B675C]">{pillar.text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{featuredArticle ? (
|
||||
<section className="grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
||||
<article className="surface-card overflow-hidden rounded-[2rem]">
|
||||
<div className="relative aspect-[4/5] sm:aspect-[16/10]">
|
||||
<Image
|
||||
src={featuredArticle.heroImages[0].src}
|
||||
alt={featuredArticle.heroImages[0].alt}
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 768px) 100vw, 70vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#112015]/90 via-[#112015]/42 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-5 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#8BC34A]">
|
||||
{featuredArticle.eyebrow}
|
||||
</p>
|
||||
<h2 className="mt-4 max-w-3xl text-4xl font-black text-white sm:text-5xl">
|
||||
{featuredArticle.title}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-base leading-7 text-white/86">
|
||||
{featuredArticle.excerpt}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-[11px] font-black uppercase tracking-[0.16em] text-white/72">
|
||||
<span>{featuredArticle.locationLabel}</span>
|
||||
<span>{featuredArticle.readingTime}</span>
|
||||
<span>{featuredArticle.publishedAt}</span>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={`/banebesok/${featuredArticle.slug}`}
|
||||
className="inline-flex items-center rounded-full bg-[#FF5722] px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#C94F2D]"
|
||||
>
|
||||
Les artikkelen
|
||||
</Link>
|
||||
<Link
|
||||
href={`/golfbaner/${featuredArticle.facilitySlug}`}
|
||||
className="inline-flex items-center rounded-full border border-white/18 bg-white/10 px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-white/18"
|
||||
>
|
||||
Gå til baneprofil
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
|
||||
Neste anbefalte steg
|
||||
</p>
|
||||
<h2 className="mt-4 text-2xl font-black text-[#112015]">Bygg artikkelflyten før volumet</h2>
|
||||
<p className="mt-4 text-sm leading-6 text-[#4F5F50]">
|
||||
Start med et lite, redigerbart oppsett: ingress, slider, hovedtekst, faktaboks og
|
||||
bildegalleri. Da blir første publisering enkel å få ut uten å låse dere til et tungt
|
||||
CMS-spor med en gang.
|
||||
</p>
|
||||
<Link
|
||||
href="/kontakt"
|
||||
className="mt-6 inline-flex items-center rounded-full bg-[#112015] px-5 py-3 text-sm font-black uppercase tracking-[0.16em] text-white transition hover:bg-[#25312A]"
|
||||
>
|
||||
Planlegg første artikkel
|
||||
</Link>
|
||||
</aside>
|
||||
</div>
|
||||
<aside className="surface-card rounded-[2rem] p-6 sm:p-8">
|
||||
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-[#FF5722]">
|
||||
Dette er på plass nå
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-black text-[#112015]">
|
||||
Første publiseringsklare struktur
|
||||
</h2>
|
||||
<div className="mt-6 grid gap-3">
|
||||
{featuredArticle.highlights.map((highlight) => (
|
||||
<div
|
||||
key={highlight}
|
||||
className="rounded-[1.4rem] border border-[#112015]/8 bg-[#F7F9F2] px-4 py-4"
|
||||
>
|
||||
<p className="text-sm font-bold leading-6 text-[#334238]">{highlight}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-6 text-sm leading-6 text-[#536256]">
|
||||
Innholdet ligger foreløpig i kode, men modellen er laget for å kunne ta imot
|
||||
HTML eller editor-data senere uten at artikkelsidene 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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
import { API_URL } from "@/config/constants";
|
||||
import { getAvailablePlaceConfigs } from "@/app/facilityData";
|
||||
import { getCourseVisits } from "@/content/courseVisits";
|
||||
import { buildAbsoluteUrl } from "@/app/seo";
|
||||
|
||||
type SitemapFacility = {
|
||||
|
|
@ -95,5 +96,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...staticRoutes, ...placeRoutes, ...facilityRoutes];
|
||||
const articleRoutes = (await getCourseVisits()).map((article) => ({
|
||||
url: buildAbsoluteUrl(`/banebesok/${article.slug}`),
|
||||
lastModified: article.updatedAt || article.publishedAt,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.58,
|
||||
}));
|
||||
|
||||
return [...staticRoutes, ...placeRoutes, ...facilityRoutes, ...articleRoutes];
|
||||
}
|
||||
|
|
|
|||
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) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const session = request.cookies.get('admin_session');
|
||||
const isAdminPage = pathname.startsWith('/admin');
|
||||
const isAdminApi = pathname.startsWith('/api/admin');
|
||||
|
||||
// 1. Tillat alltid tilgang til innloggingssiden
|
||||
if (pathname.startsWith('/admin/login')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 2. Beskytt alle andre ruter under /admin
|
||||
if (pathname.startsWith('/admin')) {
|
||||
// 2. Beskytt interne admin-API-ruter med 401 i stedet for redirect
|
||||
if (isAdminApi) {
|
||||
if (!session) {
|
||||
return NextResponse.json({ detail: 'Admin-innlogging kreves' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Beskytt alle andre ruter under /admin
|
||||
if (isAdminPage) {
|
||||
if (pathname.startsWith('/admin/login')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Ingen sesjon funnet -> Send til innlogging
|
||||
const loginUrl = new URL('/admin/login', request.url);
|
||||
|
|
@ -32,5 +45,5 @@ export function middleware(request: NextRequest) {
|
|||
|
||||
// Definer hvilke ruter middleware skal kjøre på
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*'],
|
||||
};
|
||||
matcher: ['/admin/:path*', '/api/admin/:path*'],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue