Fikset bilder i publiseringsverktøyet.

This commit is contained in:
Erol Haagenrud 2026-04-20 10:04:12 +02:00
parent d000c87324
commit e8eb8774a1
6 changed files with 342 additions and 429 deletions

View file

@ -97,35 +97,6 @@ CONTACT_FORM_RATE_LIMIT_WINDOW_SECONDS = get_int_env("CONTACT_FORM_RATE_LIMIT_WI
CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS = get_int_env("CONTACT_FORM_RATE_LIMIT_MAX_SUBMISSIONS", 3)
CONTACT_FORM_MIN_FILL_SECONDS = get_int_env("CONTACT_FORM_MIN_FILL_SECONDS", 5)
def resolve_imported_meninger_path() -> Path:
candidates: list[Path] = []
env_path = os.getenv("IMPORTED_MENINGER_PATH")
if env_path:
candidates.append(Path(env_path))
candidates.extend(
[
Path("/opt/teeoff/frontend/src/content/importedMeninger.json"),
Path("/shared/frontend-content/importedMeninger.json"),
Path(__file__).resolve().parent.parent / "frontend" / "src" / "content" / "importedMeninger.json",
]
)
for candidate in candidates:
if candidate.exists():
return candidate
raise HTTPException(
status_code=404,
detail=(
"Fant ikke importedMeninger.json. Sjekk at frontend/src/content er tilgjengelig "
"for API-et eller sett IMPORTED_MENINGER_PATH."
),
)
def is_google_login_configured() -> bool:
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
@ -1082,36 +1053,6 @@ def build_hero_images_from_media_gallery(
]
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)
def resolve_imported_article_section(item: dict[str, Any]) -> tuple[str, str]:
category_slugs = {
str(slug).strip().lower()
for slug in (item.get("categorySlugs") or [])
if str(slug or "").strip()
}
categories = item.get("categories") or []
if "banebesok" in category_slugs:
return "banebesok", "Banebesøk"
if "siste-nytt" in category_slugs:
return "meninger", "Siste nytt"
for category in categories:
if not isinstance(category, dict):
continue
label = str(category.get("name") or "").strip()
if label:
return "meninger", label
return "meninger", "Meninger"
def format_public_user_row(row: Any) -> dict[str, Any] | None:
if row is None:
return None
@ -1342,19 +1283,6 @@ async def fetch_facility_slugs(conn, facility_ids: list[int]) -> list[str]:
]
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:
@ -2488,6 +2416,33 @@ async def get_admin_article(article_id: int):
return format_article_row(row)
@app.get("/api/admin/articles/by-slug/{slug}")
async def get_admin_article_by_slug(slug: str, section: Optional[str] = Query(default="all")):
normalized_section = normalize_article_section(section, allow_all=True)
query = """
SELECT *
FROM articles
WHERE slug = $1
{section_clause}
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 1
"""
async with app.state.pool.acquire() as conn:
if normalized_section == "all":
row = await conn.fetchrow(query.format(section_clause=""), slug)
else:
row = await conn.fetchrow(
query.format(section_clause="AND section = $2"),
slug,
normalized_section,
)
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):
section = normalize_article_section(request.section)
@ -2587,138 +2542,6 @@ async def delete_admin_article(article_id: int):
return {"status": "success"}
@app.post("/api/admin/articles/seed-imported")
async def seed_admin_articles_from_imported_json():
imported_path = resolve_imported_meninger_path()
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
submitted_urls: list[str] = []
async with conn.transaction():
for item in imported_articles:
facility_slug = item.get("primaryFacilitySlug") or ((item.get("facilitySlugs") or [None])[0])
facility = facility_lookup.get(str(facility_slug), {})
content_html = str(item.get("contentHtml") or "")
featured_image = item.get("featuredImage") or {}
section, eyebrow = resolve_imported_article_section(item)
media_gallery: list[dict[str, str]] = []
featured_url = str(featured_image.get("url") or "").strip()
if featured_url:
media_gallery.append(
{
"id": build_article_media_id("image", featured_url),
"type": "image",
"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(),
"poster": "",
}
)
for url in extract_html_image_urls(content_html)[:5]:
if any(existing["src"] == url for existing in media_gallery):
continue
media_gallery.append(
{
"id": build_article_media_id("image", url),
"type": "image",
"src": url,
"alt": str(item.get("title") or "").strip(),
"caption": str(item.get("title") or "").strip(),
"poster": "",
}
)
sanitized_media_gallery = sanitize_article_media(media_gallery, str(item.get("title") or "").strip())
featured_media_id = sanitize_featured_media_id(
sanitized_media_gallery[0]["id"] if sanitized_media_gallery else None,
sanitized_media_gallery,
)
hero_images = build_hero_images_from_media_gallery(
sanitized_media_gallery,
[],
featured_media_id,
)
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 (
section, slug, title, description, excerpt, eyebrow, location_label,
facility_name, facility_slug, author_name, status, hero_images,
media_gallery, featured_media_id, content_html, source_url, source_label, published_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, 'published', $11::jsonb,
$12::jsonb, $13, $14, $15, $16, $17, $18
)
ON CONFLICT (slug) DO UPDATE SET
section = EXCLUDED.section,
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,
media_gallery = EXCLUDED.media_gallery,
featured_media_id = EXCLUDED.featured_media_id,
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
""",
section,
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,
eyebrow,
str(facility.get("county") or "Norge") if facility_slug else "Norge",
str(facility.get("name") or humanize_slug(str(facility_slug))) if facility_slug else None,
str(facility_slug) if facility_slug else None,
str(((item.get("author") or {}).get("name")) or "TeeOff"),
json.dumps(hero_images),
json.dumps(sanitized_media_gallery),
featured_media_id,
content_html,
str(item.get("link") or "").strip() or None,
"Importert fra gamle TeeOff",
published_at,
updated_at,
)
upserted_count += 1
article_url = build_article_public_url(section, str(item.get("slug") or "").strip())
if article_url:
submitted_urls.append(article_url)
section_url = build_absolute_public_url(f"/{section}")
if section_url:
submitted_urls.append(section_url)
schedule_indexnow_submission(dedupe_strings(submitted_urls), reason="admin article seed import")
return {"status": "success", "count": upserted_count}
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""

View file

@ -169,12 +169,20 @@ export default function AdminArticlesPage() {
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 [newVideoUrl, setNewVideoUrl] = useState("");
const heroImageInputRef = useRef<HTMLInputElement | null>(null);
const formSectionRef = useRef<HTMLElement | null>(null);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const focusEditor = () => {
window.requestAnimationFrame(() => {
formSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
titleInputRef.current?.focus();
});
};
const loadArticles = async () => {
const response = await adminFetch(`${API_URL}/admin/articles`);
@ -214,7 +222,8 @@ export default function AdminArticlesPage() {
setForm(articleToForm(article));
setSlugTouched(true);
setNewVideoUrl("");
setFeedback("");
setFeedback(`Redigerer «${article.title}».`);
focusEditor();
};
const handleCreateNew = () => {
@ -222,7 +231,8 @@ export default function AdminArticlesPage() {
setForm(createEmptyForm());
setSlugTouched(false);
setNewVideoUrl("");
setFeedback("");
setFeedback("Nytt artikkelutkast er klart.");
focusEditor();
};
const handleFieldChange = (field: keyof ArticleFormState, value: string) => {
@ -396,7 +406,7 @@ export default function AdminArticlesPage() {
}
};
const handleSave = async () => {
const handleSave = async (statusOverride?: ArticleFormState["status"]) => {
setIsSaving(true);
setFeedback("");
@ -413,6 +423,7 @@ export default function AdminArticlesPage() {
facility_name: form.facility_name.trim(),
facility_slug: form.facility_slug.trim(),
author_name: form.author_name.trim(),
status: statusOverride || form.status,
content_html: form.content_html,
source_url: form.source_url.trim(),
source_label: form.source_label.trim(),
@ -446,7 +457,11 @@ export default function AdminArticlesPage() {
setSelectedArticleId(savedArticle.id);
setForm(articleToForm(savedArticle));
setSlugTouched(true);
setFeedback("Artikkelen er lagret.");
setFeedback(
savedArticle.status === "published"
? "Artikkelen er publisert."
: "Artikkelen er lagret som utkast.",
);
} catch (error) {
setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre artikkelen.");
} finally {
@ -478,27 +493,6 @@ export default function AdminArticlesPage() {
}
};
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]">
@ -509,19 +503,11 @@ export default function AdminArticlesPage() {
</Link>
<h1 className="mt-3 text-4xl font-black tracking-tight text-[#11280f]">Artikler</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#536256]">
Redaksjonelle artikler kan ligge i egne seksjoner. Denne editoren bruker Tiptap,
lagrer HTML i databasen og kan seede både Banebesøk og Meninger fra importfilen.
Denne editoren bruker Tiptap, lagrer HTML i databasen og lar deg opprette, redigere,
forhåndsvise, publisere, avpublisere og slette artikler.
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleSeedImported}
disabled={isSeeding}
className="btn btn-md btn-secondary disabled:opacity-50"
>
{isSeeding ? "Importerer..." : "Seed fra import"}
</button>
<button
type="button"
onClick={handleCreateNew}
@ -546,6 +532,9 @@ export default function AdminArticlesPage() {
Artikler
</p>
<p className="mt-2 text-2xl font-black text-[#112015]">{articles.length} totalt</p>
<p className="mt-2 text-sm font-bold leading-6 text-[#536256]">
Klikk en artikkel for å redigere den, eller start et nytt utkast.
</p>
</div>
</div>
@ -558,7 +547,7 @@ export default function AdminArticlesPage() {
{!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.
Ingen artikler ennå. Opprett den første artikkelen.
</div>
) : null}
@ -594,12 +583,40 @@ export default function AdminArticlesPage() {
<p className="mt-3 text-sm leading-6 text-[#536256]">
{article.facility_name || "Uten koblet bane"}
</p>
<p className="mt-3 text-xs font-black uppercase tracking-[0.16em] text-[#FF5722]">
Klikk for å redigere
</p>
</button>
))}
</div>
</aside>
<section className="surface-card rounded-[2rem] p-5 sm:p-8">
<section ref={formSectionRef} className="surface-card rounded-[2rem] p-5 sm:p-8">
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
{selectedArticleId ? "Rediger artikkel" : "Ny artikkel"}
</p>
<h2 className="mt-2 text-3xl font-black text-[#112015]">
{form.title.trim() || (selectedArticleId ? "Uten tittel" : "Nytt utkast")}
</h2>
<p className="mt-2 text-sm leading-6 text-[#536256]">
{selectedArticleId
? "Endringer lagres i databasen og kan publiseres eller avpubliseres direkte herfra."
: "Fyll inn artikkelen og lagre når utkastet er klart."}
</p>
</div>
<span
className={`rounded-full px-4 py-2 text-[10px] font-black uppercase tracking-[0.16em] ${
form.status === "published"
? "bg-[#edf6e3] text-[#11280f]"
: "bg-[#f1f3f5] text-[#5d6a60]"
}`}
>
{form.status === "published" ? "Publisert" : "Utkast"}
</span>
</div>
<div className="grid gap-5 md:grid-cols-3">
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Seksjon</span>
@ -615,6 +632,7 @@ export default function AdminArticlesPage() {
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Tittel</span>
<input
ref={titleInputRef}
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]"
@ -708,22 +726,30 @@ export default function AdminArticlesPage() {
<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>
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Meta-beskrivelse</span>
<textarea
rows={3}
value={form.description}
onChange={(event) => handleFieldChange("description", event.target.value)}
placeholder="Kort SEO-/delingsbeskrivelse av artikkelen"
className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
<p className="text-sm leading-6 text-[#536256]">
Brukes primært som beskrivelse i metadata, delinger og søk.
</p>
</label>
<label className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Ingress / excerpt</span>
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Ingress</span>
<textarea
rows={4}
value={form.excerpt}
onChange={(event) => handleFieldChange("excerpt", event.target.value)}
placeholder="Synlig introtekst som vises i artikkelen og i artikkelkort"
className="rounded-[1.3rem] border border-[#112015]/10 bg-white px-4 py-3 text-base text-[#112015] outline-none focus:border-[#8BC34A]"
/>
<p className="text-sm leading-6 text-[#536256]">
Dette er den synlige introteksten leseren møter i artikkelen og i lister.
</p>
</label>
<label className="flex flex-col gap-2">
<input
@ -944,12 +970,31 @@ export default function AdminArticlesPage() {
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
onClick={handleSave}
onClick={() => handleSave()}
disabled={isSaving}
className="btn btn-md btn-primary disabled:opacity-50"
>
{isSaving ? "Lagrer..." : "Lagre artikkel"}
{isSaving ? "Lagrer..." : "Lagre endringer"}
</button>
{form.status === "draft" ? (
<button
type="button"
onClick={() => handleSave("published")}
disabled={isSaving}
className="btn btn-md btn-secondary disabled:opacity-50"
>
{isSaving ? "Publiserer..." : "Publiser"}
</button>
) : (
<button
type="button"
onClick={() => handleSave("draft")}
disabled={isSaving}
className="btn btn-md btn-secondary disabled:opacity-50"
>
{isSaving ? "Avpubliserer..." : "Avpubliser"}
</button>
)}
{selectedArticleId ? (
<button
type="button"
@ -960,14 +1005,24 @@ export default function AdminArticlesPage() {
{isDeleting ? "Sletter..." : "Slett artikkel"}
</button>
) : null}
{form.slug ? (
<Link
href={`/${form.section}/${form.slug}`}
target="_blank"
className="btn btn-md btn-secondary"
>
Åpne offentlig side
</Link>
{selectedArticleId && form.slug ? (
form.status === "published" ? (
<Link
href={`/${form.section}/${form.slug}`}
target="_blank"
className="btn btn-md btn-secondary"
>
Åpne offentlig side
</Link>
) : (
<Link
href={`/${form.section}/${form.slug}?preview=1`}
target="_blank"
className="btn btn-md btn-secondary"
>
Forhåndsvis utkast
</Link>
)
) : null}
{form.facility_slug ? (
<Link

View file

@ -13,10 +13,15 @@ import {
type CourseVisitPageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ preview?: string }>;
};
export const dynamic = "force-dynamic";
function isPreviewEnabled(value?: string) {
return value === "1" || value === "true";
}
function renderBlock(block: CourseVisitBodyBlock, index: number) {
if (block.type === "richText") {
return (
@ -111,9 +116,10 @@ function renderBlock(block: CourseVisitBodyBlock, index: number) {
);
}
export async function generateMetadata({ params }: CourseVisitPageProps) {
export async function generateMetadata({ params, searchParams }: CourseVisitPageProps) {
const { slug } = await params;
const article = await getCourseVisitBySlug(slug);
const { preview } = await searchParams;
const article = await getCourseVisitBySlug(slug, { preview: isPreviewEnabled(preview) });
if (!article) {
return createPageMetadata({
@ -124,18 +130,32 @@ export async function generateMetadata({ params }: CourseVisitPageProps) {
});
}
return createPageMetadata({
const metadata = createPageMetadata({
title: article.title,
description: article.description,
path: `/banebesok/${article.slug}`,
image: article.heroImages[0]?.src,
type: "article",
});
if (article.isPreview) {
return {
...metadata,
title: `${article.title} | Utkastforhåndsvisning`,
robots: {
index: false,
follow: false,
},
};
}
return metadata;
}
export default async function CourseVisitPage({ params }: CourseVisitPageProps) {
export default async function CourseVisitPage({ params, searchParams }: CourseVisitPageProps) {
const { slug } = await params;
const article = await getCourseVisitBySlug(slug);
const { preview } = await searchParams;
const article = await getCourseVisitBySlug(slug, { preview: isPreviewEnabled(preview) });
if (!article) {
notFound();
@ -180,16 +200,22 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
: {}),
};
const isDraftPreview = article.isPreview || article.status !== "published";
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
{!isDraftPreview ? (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
</>
) : null}
<InfoPageShell
eyebrow={article.eyebrow}
@ -198,6 +224,16 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
>
<div className="grid gap-6 xl:grid-cols-[1.25fr,0.75fr]">
<div className="space-y-6">
{isDraftPreview ? (
<section className="rounded-[2rem] border border-[#FF5722]/20 bg-[#FFF4EF] px-6 py-5">
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-[#FF5722]">
Administratorvisning
</p>
<p className="mt-2 text-sm font-bold leading-6 text-[#6A3826]">
Dette er en forhåndsvisning av et utkast. Siden er ikke offentlig synlig og blir ikke indeksert.
</p>
</section>
) : null}
<CourseVisitGallery title={article.title} media={article.mediaGallery.slice(0, 1)} />
{article.blocks.map((block, index) => renderBlock(block, index))}
{article.mediaGallery.length > 1 ? (
@ -218,7 +254,7 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
<CourseVisitGallery title={article.title} media={article.mediaGallery} />
</section>
) : null}
<ArticleComments slug={article.slug} section="banebesok" />
{!isDraftPreview ? <ArticleComments slug={article.slug} section="banebesok" /> : null}
</div>
<aside className="space-y-6">

View file

@ -13,10 +13,15 @@ import {
type OpinionPageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ preview?: string }>;
};
export const dynamic = "force-dynamic";
function isPreviewEnabled(value?: string) {
return value === "1" || value === "true";
}
function renderBlock(block: CourseVisitBodyBlock, index: number) {
if (block.type === "richText") {
return (
@ -111,9 +116,10 @@ function renderBlock(block: CourseVisitBodyBlock, index: number) {
);
}
export async function generateMetadata({ params }: OpinionPageProps) {
export async function generateMetadata({ params, searchParams }: OpinionPageProps) {
const { slug } = await params;
const article = await getOpinionArticleBySlug(slug);
const { preview } = await searchParams;
const article = await getOpinionArticleBySlug(slug, { preview: isPreviewEnabled(preview) });
if (!article) {
return createPageMetadata({
@ -124,18 +130,32 @@ export async function generateMetadata({ params }: OpinionPageProps) {
});
}
return createPageMetadata({
const metadata = createPageMetadata({
title: article.title,
description: article.description,
path: `/meninger/${article.slug}`,
image: article.heroImages[0]?.src,
type: "article",
});
if (article.isPreview) {
return {
...metadata,
title: `${article.title} | Utkastforhåndsvisning`,
robots: {
index: false,
follow: false,
},
};
}
return metadata;
}
export default async function OpinionPage({ params }: OpinionPageProps) {
export default async function OpinionPage({ params, searchParams }: OpinionPageProps) {
const { slug } = await params;
const article = await getOpinionArticleBySlug(slug);
const { preview } = await searchParams;
const article = await getOpinionArticleBySlug(slug, { preview: isPreviewEnabled(preview) });
if (!article) {
notFound();
@ -180,16 +200,22 @@ export default async function OpinionPage({ params }: OpinionPageProps) {
: {}),
};
const isDraftPreview = article.isPreview || article.status !== "published";
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
{!isDraftPreview ? (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
</>
) : null}
<InfoPageShell
eyebrow={article.eyebrow}
@ -198,6 +224,16 @@ export default async function OpinionPage({ params }: OpinionPageProps) {
>
<div className="grid gap-6 xl:grid-cols-[1.25fr,0.75fr]">
<div className="space-y-6">
{isDraftPreview ? (
<section className="rounded-[2rem] border border-[#FF5722]/20 bg-[#FFF4EF] px-6 py-5">
<p className="text-[11px] font-black uppercase tracking-[0.22em] text-[#FF5722]">
Administratorvisning
</p>
<p className="mt-2 text-sm font-bold leading-6 text-[#6A3826]">
Dette er en forhåndsvisning av et utkast. Siden er ikke offentlig synlig og blir ikke indeksert.
</p>
</section>
) : null}
<CourseVisitGallery title={article.title} media={article.mediaGallery.slice(0, 1)} />
{article.blocks.map((block, index) => renderBlock(block, index))}
{article.mediaGallery.length > 1 ? (
@ -218,7 +254,7 @@ export default async function OpinionPage({ params }: OpinionPageProps) {
<CourseVisitGallery title={article.title} media={article.mediaGallery} />
</section>
) : null}
<ArticleComments slug={article.slug} section="meninger" />
{!isDraftPreview ? <ArticleComments slug={article.slug} section="meninger" /> : null}
</div>
<aside className="space-y-6">

View file

@ -0,0 +1,51 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const MIME_BY_EXTENSION: Record<string, string> = {
".avif": "image/avif",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".tif": "image/tiff",
".tiff": "image/tiff",
".webp": "image/webp",
};
function resolveMimeType(filePath: string) {
return MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] || "application/octet-stream";
}
export async function GET(
_request: Request,
context: { params: Promise<{ path: string[] }> },
) {
const { path: segments } = await context.params;
const relativePath = segments.filter(Boolean);
if (relativePath.length === 0) {
return NextResponse.json({ detail: "Fant ingen fil." }, { status: 404 });
}
const uploadsRoot = path.resolve(process.cwd(), "public", "uploads");
const absolutePath = path.resolve(uploadsRoot, ...relativePath);
if (!absolutePath.startsWith(`${uploadsRoot}${path.sep}`) && absolutePath !== uploadsRoot) {
return NextResponse.json({ detail: "Ugyldig filsti." }, { status: 400 });
}
try {
const fileBuffer = await readFile(absolutePath);
return new NextResponse(fileBuffer, {
status: 200,
headers: {
"Content-Type": resolveMimeType(absolutePath),
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch {
return NextResponse.json({ detail: "Filen ble ikke funnet." }, { status: 404 });
}
}

View file

@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { cookies } from "next/headers";
import { API_URL } from "@/config/constants";
import importedMeninger from "@/content/importedMeninger.json";
export type ArticleSection = "banebesok" | "meninger";
@ -74,39 +74,14 @@ export type EditorialArticle = {
blocks: CourseVisitBodyBlock[];
sourceUrl?: string;
sourceLabel?: string;
};
type ImportedCategory = {
name?: string | null;
slug?: string | null;
};
type ImportedMeningerRecord = {
id: number;
slug: string;
title: string;
excerpt: string;
contentHtml: string;
publishedAt: string;
updatedAt?: string;
link?: string;
author?: {
name?: string | null;
};
featuredImage?: {
url?: string | null;
alt?: string | null;
caption?: string | null;
} | null;
categories?: ImportedCategory[];
categorySlugs?: string[];
facilitySlugs?: string[];
primaryFacilitySlug?: string | null;
status: "draft" | "published";
isPreview?: boolean;
};
type ArticleApiRecord = {
id?: number;
section?: string | null;
status?: string | null;
slug: string;
title: string;
description?: string | null;
@ -156,6 +131,10 @@ function normalizeSection(value?: string | null): ArticleSection {
return String(value || "").trim().toLowerCase() === "meninger" ? "meninger" : "banebesok";
}
function normalizeStatus(value?: string | null): "draft" | "published" {
return String(value || "").trim().toLowerCase() === "published" ? "published" : "draft";
}
export function buildEditorialPath(section: ArticleSection, slug: string) {
return `/${section}/${slug}`;
}
@ -164,34 +143,6 @@ function getSectionLabel(section: ArticleSection) {
return section === "meninger" ? "Meninger" : "Banebesøk";
}
function resolveImportedSection(entry: ImportedMeningerRecord): {
section: ArticleSection;
eyebrow: string;
} {
const slugSet = new Set(
(entry.categorySlugs || [])
.map((slug) => String(slug || "").trim().toLowerCase())
.filter(Boolean),
);
if (slugSet.has("banebesok")) {
return { section: "banebesok", eyebrow: "Banebesøk" };
}
if (slugSet.has("siste-nytt")) {
return { section: "meninger", eyebrow: "Siste nytt" };
}
const categoryLabel = (entry.categories || [])
.map((category) => String(category?.name || "").trim())
.find(Boolean);
return {
section: "meninger",
eyebrow: categoryLabel || "Meninger",
};
}
function decodeEntities(value: string) {
return value
.replace(/&#8230;/g, "...")
@ -551,78 +502,6 @@ function buildHighlights(section: ArticleSection, facilityName?: string) {
return highlights;
}
function mapImportedArticle(entry: ImportedMeningerRecord): EditorialArticle {
const { section, eyebrow } = resolveImportedSection(entry);
const facilitySlug = entry.primaryFacilitySlug || entry.facilitySlugs?.[0] || undefined;
const facilityMeta = facilitySlug ? getFacilityMeta(facilitySlug) : null;
const facilityName = facilityMeta?.name;
const locationLabel = facilityMeta?.region || "Norge";
const normalizedHtml = normalizeInternalLinks(entry.contentHtml || "");
const preparedHtml = prepareRichTextHtml(normalizedHtml, entry.title);
const extractedMedia = extractMediaFromHtml(normalizedHtml, entry.title);
const featuredMedia = entry.featuredImage?.url
? [
{
id: buildMediaId("image", entry.featuredImage.url),
type: "image" as const,
src: entry.featuredImage.url,
alt: entry.featuredImage.alt || entry.title,
caption: entry.featuredImage.caption || entry.title,
poster: "",
},
]
: [];
const mediaGallery = sanitizeMediaGallery([...featuredMedia, ...extractedMedia], entry.title).slice(0, 24);
const featuredMediaId = mediaGallery.find((item) => item.type === "image")?.id;
const heroImages = buildHeroImagesFromMedia(mediaGallery, entry.title, featuredMediaId).slice(0, 6);
const excerpt = entry.excerpt || stripHtml(normalizedHtml).slice(0, 220);
return {
section,
slug: entry.slug,
eyebrow,
title: entry.title,
description: excerpt,
excerpt,
locationLabel,
facilityName,
facilitySlug,
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
readingTime: getReadingTime(preparedHtml),
heroImages:
heroImages.length > 0
? heroImages
: [
{
src: "/Toppbilde-standard.jpg",
alt: entry.title,
caption: entry.title,
},
],
mediaGallery,
featuredMediaId,
quickFacts: buildQuickFacts({
facilityName,
facilitySlug,
publishedAt: entry.publishedAt,
authorName: entry.author?.name || undefined,
}),
highlights: [
"Originalartikkel importert fra gamle TeeOff.",
...buildHighlights(section, facilityName),
],
blocks: [
{
type: "richText",
html: preparedHtml,
},
],
};
}
function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
const section = normalizeSection(entry.section);
const facilitySlug = String(entry.facility_slug || "").trim() || undefined;
@ -688,17 +567,11 @@ function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
html: preparedHtml,
},
],
status: normalizeStatus(entry.status),
isPreview: false,
};
}
const fallbackEditorialArticles = (importedMeninger as ImportedMeningerRecord[])
.map(mapImportedArticle)
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
function getFallbackArticles(section: ArticleSection) {
return fallbackEditorialArticles.filter((article) => article.section === section);
}
async function fetchPublishedArticles(section: ArticleSection) {
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
if (!response.ok) {
@ -720,44 +593,83 @@ async function fetchPublishedArticleBySlug(slug: string, section: ArticleSection
return mapApiArticle(data as ArticleApiRecord);
}
async function fetchAdminPreviewArticleBySlug(slug: string, section: ArticleSection) {
const cookieStore = await cookies();
const adminSession = cookieStore.get("admin_session")?.value;
if (!adminSession) {
return null;
}
const response = await fetch(`${API_URL}/admin/articles/by-slug/${slug}?section=${section}`, {
cache: "no-store",
headers: {
Cookie: `admin_session=${adminSession}`,
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
return {
...mapApiArticle(data as ArticleApiRecord),
isPreview: true,
};
}
export async function getEditorialArticles(section: ArticleSection) {
try {
const mapped = await fetchPublishedArticles(section);
if (mapped && mapped.length > 0) {
if (mapped) {
return mapped;
}
} catch {
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
// Returnerer tom liste dersom API-et ikke er tilgjengelig.
}
return getFallbackArticles(section);
return [];
}
export async function getEditorialArticleBySlug(slug: string, section: ArticleSection) {
export async function getEditorialArticleBySlug(
slug: string,
section: ArticleSection,
options?: { preview?: boolean },
) {
if (options?.preview) {
try {
const previewArticle = await fetchAdminPreviewArticleBySlug(slug, section);
if (previewArticle) {
return previewArticle;
}
} catch {
// Fortsetter til publisert oppslag dersom preview-oppslaget feiler.
}
}
try {
const mapped = await fetchPublishedArticleBySlug(slug, section);
if (mapped) {
return mapped;
}
} catch {
// Faller tilbake til importerte artikler dersom DB/API ikke er klar.
// Returnerer null dersom API-et ikke er tilgjengelig.
}
return getFallbackArticles(section).find((article) => article.slug === slug);
return null;
}
export async function getCourseVisits() {
return getEditorialArticles("banebesok");
}
export async function getCourseVisitBySlug(slug: string) {
return getEditorialArticleBySlug(slug, "banebesok");
export async function getCourseVisitBySlug(slug: string, options?: { preview?: boolean }) {
return getEditorialArticleBySlug(slug, "banebesok", options);
}
export async function getOpinionArticles() {
return getEditorialArticles("meninger");
}
export async function getOpinionArticleBySlug(slug: string) {
return getEditorialArticleBySlug(slug, "meninger");
export async function getOpinionArticleBySlug(slug: string, options?: { preview?: boolean }) {
return getEditorialArticleBySlug(slug, "meninger", options);
}