Fikset bilder i publiseringsverktøyet.
This commit is contained in:
parent
d000c87324
commit
e8eb8774a1
6 changed files with 342 additions and 429 deletions
231
backend/main.py
231
backend/main.py
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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 nå 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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
51
frontend/src/app/uploads/[...path]/route.ts
Normal file
51
frontend/src/app/uploads/[...path]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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(/…/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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue