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_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)
|
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:
|
def is_google_login_configured() -> bool:
|
||||||
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
|
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:
|
def format_public_user_row(row: Any) -> dict[str, Any] | None:
|
||||||
if row is None:
|
if row is None:
|
||||||
return 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):
|
async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: str | None = None):
|
||||||
if job_type not in SCRAPE_JOB_TYPES:
|
if job_type not in SCRAPE_JOB_TYPES:
|
||||||
|
|
@ -2488,6 +2416,33 @@ async def get_admin_article(article_id: int):
|
||||||
return format_article_row(row)
|
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")
|
@app.post("/api/admin/articles")
|
||||||
async def upsert_admin_article(request: ArticleUpsertRequest):
|
async def upsert_admin_article(request: ArticleUpsertRequest):
|
||||||
section = normalize_article_section(request.section)
|
section = normalize_article_section(request.section)
|
||||||
|
|
@ -2587,138 +2542,6 @@ async def delete_admin_article(article_id: int):
|
||||||
return {"status": "success"}
|
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")
|
@app.patch("/api/admin/facilities/{facility_id}/scrape-settings")
|
||||||
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
|
async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate):
|
||||||
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
"""Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL)."""
|
||||||
|
|
|
||||||
|
|
@ -169,12 +169,20 @@ export default function AdminArticlesPage() {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isSeeding, setIsSeeding] = useState(false);
|
|
||||||
const [isUploadingHeroImages, setIsUploadingHeroImages] = useState(false);
|
const [isUploadingHeroImages, setIsUploadingHeroImages] = useState(false);
|
||||||
const [feedback, setFeedback] = useState<string>("");
|
const [feedback, setFeedback] = useState<string>("");
|
||||||
const [slugTouched, setSlugTouched] = useState(false);
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
const [newVideoUrl, setNewVideoUrl] = useState("");
|
const [newVideoUrl, setNewVideoUrl] = useState("");
|
||||||
const heroImageInputRef = useRef<HTMLInputElement | null>(null);
|
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 loadArticles = async () => {
|
||||||
const response = await adminFetch(`${API_URL}/admin/articles`);
|
const response = await adminFetch(`${API_URL}/admin/articles`);
|
||||||
|
|
@ -214,7 +222,8 @@ export default function AdminArticlesPage() {
|
||||||
setForm(articleToForm(article));
|
setForm(articleToForm(article));
|
||||||
setSlugTouched(true);
|
setSlugTouched(true);
|
||||||
setNewVideoUrl("");
|
setNewVideoUrl("");
|
||||||
setFeedback("");
|
setFeedback(`Redigerer «${article.title}».`);
|
||||||
|
focusEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNew = () => {
|
const handleCreateNew = () => {
|
||||||
|
|
@ -222,7 +231,8 @@ export default function AdminArticlesPage() {
|
||||||
setForm(createEmptyForm());
|
setForm(createEmptyForm());
|
||||||
setSlugTouched(false);
|
setSlugTouched(false);
|
||||||
setNewVideoUrl("");
|
setNewVideoUrl("");
|
||||||
setFeedback("");
|
setFeedback("Nytt artikkelutkast er klart.");
|
||||||
|
focusEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFieldChange = (field: keyof ArticleFormState, value: string) => {
|
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);
|
setIsSaving(true);
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
|
|
||||||
|
|
@ -413,6 +423,7 @@ export default function AdminArticlesPage() {
|
||||||
facility_name: form.facility_name.trim(),
|
facility_name: form.facility_name.trim(),
|
||||||
facility_slug: form.facility_slug.trim(),
|
facility_slug: form.facility_slug.trim(),
|
||||||
author_name: form.author_name.trim(),
|
author_name: form.author_name.trim(),
|
||||||
|
status: statusOverride || form.status,
|
||||||
content_html: form.content_html,
|
content_html: form.content_html,
|
||||||
source_url: form.source_url.trim(),
|
source_url: form.source_url.trim(),
|
||||||
source_label: form.source_label.trim(),
|
source_label: form.source_label.trim(),
|
||||||
|
|
@ -446,7 +457,11 @@ export default function AdminArticlesPage() {
|
||||||
setSelectedArticleId(savedArticle.id);
|
setSelectedArticleId(savedArticle.id);
|
||||||
setForm(articleToForm(savedArticle));
|
setForm(articleToForm(savedArticle));
|
||||||
setSlugTouched(true);
|
setSlugTouched(true);
|
||||||
setFeedback("Artikkelen er lagret.");
|
setFeedback(
|
||||||
|
savedArticle.status === "published"
|
||||||
|
? "Artikkelen er publisert."
|
||||||
|
: "Artikkelen er lagret som utkast.",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre artikkelen.");
|
setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre artikkelen.");
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<main className="min-h-screen bg-[#f3f6ee] px-4 py-8 sm:px-6 lg:px-8">
|
<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="mx-auto max-w-[1700px]">
|
||||||
|
|
@ -509,19 +503,11 @@ export default function AdminArticlesPage() {
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-3 text-4xl font-black tracking-tight text-[#11280f]">Artikler</h1>
|
<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]">
|
<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,
|
Denne editoren bruker Tiptap, lagrer HTML i databasen og lar deg opprette, redigere,
|
||||||
lagrer HTML i databasen og kan seede både Banebesøk og Meninger fra importfilen.
|
forhåndsvise, publisere, avpublisere og slette artikler.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateNew}
|
onClick={handleCreateNew}
|
||||||
|
|
@ -546,6 +532,9 @@ export default function AdminArticlesPage() {
|
||||||
Artikler
|
Artikler
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-2xl font-black text-[#112015]">{articles.length} totalt</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -558,7 +547,7 @@ export default function AdminArticlesPage() {
|
||||||
|
|
||||||
{!isLoading && articles.length === 0 ? (
|
{!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]">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -594,12 +583,40 @@ export default function AdminArticlesPage() {
|
||||||
<p className="mt-3 text-sm leading-6 text-[#536256]">
|
<p className="mt-3 text-sm leading-6 text-[#536256]">
|
||||||
{article.facility_name || "Uten koblet bane"}
|
{article.facility_name || "Uten koblet bane"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-3 text-xs font-black uppercase tracking-[0.16em] text-[#FF5722]">
|
||||||
|
Klikk for å redigere
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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">
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
<label className="flex flex-col gap-2">
|
<label className="flex flex-col gap-2">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Seksjon</span>
|
<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">
|
<label className="flex flex-col gap-2">
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Tittel</span>
|
<span className="text-[10px] font-black uppercase tracking-[0.18em] text-[#6A766C]">Tittel</span>
|
||||||
<input
|
<input
|
||||||
|
ref={titleInputRef}
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={(event) => handleTitleChange(event.target.value)}
|
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]"
|
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">
|
<div className="mt-5 grid gap-5">
|
||||||
<label className="flex flex-col gap-2">
|
<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
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(event) => handleFieldChange("description", event.target.value)}
|
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]"
|
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>
|
||||||
<label className="flex flex-col gap-2">
|
<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
|
<textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
value={form.excerpt}
|
value={form.excerpt}
|
||||||
onChange={(event) => handleFieldChange("excerpt", event.target.value)}
|
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]"
|
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>
|
||||||
<label className="flex flex-col gap-2">
|
<label className="flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
|
|
@ -944,12 +970,31 @@ export default function AdminArticlesPage() {
|
||||||
<div className="mt-6 flex flex-wrap gap-3">
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={() => handleSave()}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="btn btn-md btn-primary disabled:opacity-50"
|
className="btn btn-md btn-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSaving ? "Lagrer..." : "Lagre artikkel"}
|
{isSaving ? "Lagrer..." : "Lagre endringer"}
|
||||||
</button>
|
</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 ? (
|
{selectedArticleId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -960,14 +1005,24 @@ export default function AdminArticlesPage() {
|
||||||
{isDeleting ? "Sletter..." : "Slett artikkel"}
|
{isDeleting ? "Sletter..." : "Slett artikkel"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{form.slug ? (
|
{selectedArticleId && form.slug ? (
|
||||||
<Link
|
form.status === "published" ? (
|
||||||
href={`/${form.section}/${form.slug}`}
|
<Link
|
||||||
target="_blank"
|
href={`/${form.section}/${form.slug}`}
|
||||||
className="btn btn-md btn-secondary"
|
target="_blank"
|
||||||
>
|
className="btn btn-md btn-secondary"
|
||||||
Åpne offentlig side
|
>
|
||||||
</Link>
|
Å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}
|
) : null}
|
||||||
{form.facility_slug ? (
|
{form.facility_slug ? (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,15 @@ import {
|
||||||
|
|
||||||
type CourseVisitPageProps = {
|
type CourseVisitPageProps = {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<{ preview?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function isPreviewEnabled(value?: string) {
|
||||||
|
return value === "1" || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
function renderBlock(block: CourseVisitBodyBlock, index: number) {
|
function renderBlock(block: CourseVisitBodyBlock, index: number) {
|
||||||
if (block.type === "richText") {
|
if (block.type === "richText") {
|
||||||
return (
|
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 { slug } = await params;
|
||||||
const article = await getCourseVisitBySlug(slug);
|
const { preview } = await searchParams;
|
||||||
|
const article = await getCourseVisitBySlug(slug, { preview: isPreviewEnabled(preview) });
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return createPageMetadata({
|
return createPageMetadata({
|
||||||
|
|
@ -124,18 +130,32 @@ export async function generateMetadata({ params }: CourseVisitPageProps) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPageMetadata({
|
const metadata = createPageMetadata({
|
||||||
title: article.title,
|
title: article.title,
|
||||||
description: article.description,
|
description: article.description,
|
||||||
path: `/banebesok/${article.slug}`,
|
path: `/banebesok/${article.slug}`,
|
||||||
image: article.heroImages[0]?.src,
|
image: article.heroImages[0]?.src,
|
||||||
type: "article",
|
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 { slug } = await params;
|
||||||
const article = await getCourseVisitBySlug(slug);
|
const { preview } = await searchParams;
|
||||||
|
const article = await getCourseVisitBySlug(slug, { preview: isPreviewEnabled(preview) });
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|
@ -180,16 +200,22 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDraftPreview = article.isPreview || article.status !== "published";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
{!isDraftPreview ? (
|
||||||
type="application/ld+json"
|
<>
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
|
<script
|
||||||
/>
|
type="application/ld+json"
|
||||||
<script
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
|
||||||
type="application/ld+json"
|
/>
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
<script
|
||||||
/>
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<InfoPageShell
|
<InfoPageShell
|
||||||
eyebrow={article.eyebrow}
|
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="grid gap-6 xl:grid-cols-[1.25fr,0.75fr]">
|
||||||
<div className="space-y-6">
|
<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)} />
|
<CourseVisitGallery title={article.title} media={article.mediaGallery.slice(0, 1)} />
|
||||||
{article.blocks.map((block, index) => renderBlock(block, index))}
|
{article.blocks.map((block, index) => renderBlock(block, index))}
|
||||||
{article.mediaGallery.length > 1 ? (
|
{article.mediaGallery.length > 1 ? (
|
||||||
|
|
@ -218,7 +254,7 @@ export default async function CourseVisitPage({ params }: CourseVisitPageProps)
|
||||||
<CourseVisitGallery title={article.title} media={article.mediaGallery} />
|
<CourseVisitGallery title={article.title} media={article.mediaGallery} />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<ArticleComments slug={article.slug} section="banebesok" />
|
{!isDraftPreview ? <ArticleComments slug={article.slug} section="banebesok" /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="space-y-6">
|
<aside className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,15 @@ import {
|
||||||
|
|
||||||
type OpinionPageProps = {
|
type OpinionPageProps = {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<{ preview?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function isPreviewEnabled(value?: string) {
|
||||||
|
return value === "1" || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
function renderBlock(block: CourseVisitBodyBlock, index: number) {
|
function renderBlock(block: CourseVisitBodyBlock, index: number) {
|
||||||
if (block.type === "richText") {
|
if (block.type === "richText") {
|
||||||
return (
|
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 { slug } = await params;
|
||||||
const article = await getOpinionArticleBySlug(slug);
|
const { preview } = await searchParams;
|
||||||
|
const article = await getOpinionArticleBySlug(slug, { preview: isPreviewEnabled(preview) });
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
return createPageMetadata({
|
return createPageMetadata({
|
||||||
|
|
@ -124,18 +130,32 @@ export async function generateMetadata({ params }: OpinionPageProps) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return createPageMetadata({
|
const metadata = createPageMetadata({
|
||||||
title: article.title,
|
title: article.title,
|
||||||
description: article.description,
|
description: article.description,
|
||||||
path: `/meninger/${article.slug}`,
|
path: `/meninger/${article.slug}`,
|
||||||
image: article.heroImages[0]?.src,
|
image: article.heroImages[0]?.src,
|
||||||
type: "article",
|
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 { slug } = await params;
|
||||||
const article = await getOpinionArticleBySlug(slug);
|
const { preview } = await searchParams;
|
||||||
|
const article = await getOpinionArticleBySlug(slug, { preview: isPreviewEnabled(preview) });
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|
@ -180,16 +200,22 @@ export default async function OpinionPage({ params }: OpinionPageProps) {
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDraftPreview = article.isPreview || article.status !== "published";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
{!isDraftPreview ? (
|
||||||
type="application/ld+json"
|
<>
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
|
<script
|
||||||
/>
|
type="application/ld+json"
|
||||||
<script
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
|
||||||
type="application/ld+json"
|
/>
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
<script
|
||||||
/>
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<InfoPageShell
|
<InfoPageShell
|
||||||
eyebrow={article.eyebrow}
|
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="grid gap-6 xl:grid-cols-[1.25fr,0.75fr]">
|
||||||
<div className="space-y-6">
|
<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)} />
|
<CourseVisitGallery title={article.title} media={article.mediaGallery.slice(0, 1)} />
|
||||||
{article.blocks.map((block, index) => renderBlock(block, index))}
|
{article.blocks.map((block, index) => renderBlock(block, index))}
|
||||||
{article.mediaGallery.length > 1 ? (
|
{article.mediaGallery.length > 1 ? (
|
||||||
|
|
@ -218,7 +254,7 @@ export default async function OpinionPage({ params }: OpinionPageProps) {
|
||||||
<CourseVisitGallery title={article.title} media={article.mediaGallery} />
|
<CourseVisitGallery title={article.title} media={article.mediaGallery} />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<ArticleComments slug={article.slug} section="meninger" />
|
{!isDraftPreview ? <ArticleComments slug={article.slug} section="meninger" /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="space-y-6">
|
<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 { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
import { API_URL } from "@/config/constants";
|
import { API_URL } from "@/config/constants";
|
||||||
import importedMeninger from "@/content/importedMeninger.json";
|
|
||||||
|
|
||||||
export type ArticleSection = "banebesok" | "meninger";
|
export type ArticleSection = "banebesok" | "meninger";
|
||||||
|
|
||||||
|
|
@ -74,39 +74,14 @@ export type EditorialArticle = {
|
||||||
blocks: CourseVisitBodyBlock[];
|
blocks: CourseVisitBodyBlock[];
|
||||||
sourceUrl?: string;
|
sourceUrl?: string;
|
||||||
sourceLabel?: string;
|
sourceLabel?: string;
|
||||||
};
|
status: "draft" | "published";
|
||||||
|
isPreview?: boolean;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ArticleApiRecord = {
|
type ArticleApiRecord = {
|
||||||
id?: number;
|
id?: number;
|
||||||
section?: string | null;
|
section?: string | null;
|
||||||
|
status?: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
@ -156,6 +131,10 @@ function normalizeSection(value?: string | null): ArticleSection {
|
||||||
return String(value || "").trim().toLowerCase() === "meninger" ? "meninger" : "banebesok";
|
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) {
|
export function buildEditorialPath(section: ArticleSection, slug: string) {
|
||||||
return `/${section}/${slug}`;
|
return `/${section}/${slug}`;
|
||||||
}
|
}
|
||||||
|
|
@ -164,34 +143,6 @@ function getSectionLabel(section: ArticleSection) {
|
||||||
return section === "meninger" ? "Meninger" : "Banebesøk";
|
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) {
|
function decodeEntities(value: string) {
|
||||||
return value
|
return value
|
||||||
.replace(/…/g, "...")
|
.replace(/…/g, "...")
|
||||||
|
|
@ -551,78 +502,6 @@ function buildHighlights(section: ArticleSection, facilityName?: string) {
|
||||||
return highlights;
|
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 {
|
function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
|
||||||
const section = normalizeSection(entry.section);
|
const section = normalizeSection(entry.section);
|
||||||
const facilitySlug = String(entry.facility_slug || "").trim() || undefined;
|
const facilitySlug = String(entry.facility_slug || "").trim() || undefined;
|
||||||
|
|
@ -688,17 +567,11 @@ function mapApiArticle(entry: ArticleApiRecord): EditorialArticle {
|
||||||
html: preparedHtml,
|
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) {
|
async function fetchPublishedArticles(section: ArticleSection) {
|
||||||
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
|
const response = await fetch(`${API_URL}/articles?section=${section}`, { cache: "no-store" });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -720,44 +593,83 @@ async function fetchPublishedArticleBySlug(slug: string, section: ArticleSection
|
||||||
return mapApiArticle(data as ArticleApiRecord);
|
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) {
|
export async function getEditorialArticles(section: ArticleSection) {
|
||||||
try {
|
try {
|
||||||
const mapped = await fetchPublishedArticles(section);
|
const mapped = await fetchPublishedArticles(section);
|
||||||
if (mapped && mapped.length > 0) {
|
if (mapped) {
|
||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 {
|
try {
|
||||||
const mapped = await fetchPublishedArticleBySlug(slug, section);
|
const mapped = await fetchPublishedArticleBySlug(slug, section);
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
} catch {
|
} 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() {
|
export async function getCourseVisits() {
|
||||||
return getEditorialArticles("banebesok");
|
return getEditorialArticles("banebesok");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCourseVisitBySlug(slug: string) {
|
export async function getCourseVisitBySlug(slug: string, options?: { preview?: boolean }) {
|
||||||
return getEditorialArticleBySlug(slug, "banebesok");
|
return getEditorialArticleBySlug(slug, "banebesok", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOpinionArticles() {
|
export async function getOpinionArticles() {
|
||||||
return getEditorialArticles("meninger");
|
return getEditorialArticles("meninger");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOpinionArticleBySlug(slug: string) {
|
export async function getOpinionArticleBySlug(slug: string, options?: { preview?: boolean }) {
|
||||||
return getEditorialArticleBySlug(slug, "meninger");
|
return getEditorialArticleBySlug(slug, "meninger", options);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue