diff --git a/backend/main.py b/backend/main.py index b462aaf..63513fc 100644 --- a/backend/main.py +++ b/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"]*\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).""" diff --git a/frontend/src/app/admin/artikler/page.tsx b/frontend/src/app/admin/artikler/page.tsx index cb7c07b..c089cd8 100644 --- a/frontend/src/app/admin/artikler/page.tsx +++ b/frontend/src/app/admin/artikler/page.tsx @@ -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(""); const [slugTouched, setSlugTouched] = useState(false); const [newVideoUrl, setNewVideoUrl] = useState(""); const heroImageInputRef = useRef(null); + const formSectionRef = useRef(null); + const titleInputRef = useRef(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 (
@@ -509,19 +503,11 @@ export default function AdminArticlesPage() {

Artikler

- 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.

-
@@ -558,7 +547,7 @@ export default function AdminArticlesPage() { {!isLoading && articles.length === 0 ? (
- Ingen artikler ennå. Seed importen eller opprett en ny. + Ingen artikler ennå. Opprett den første artikkelen.
) : null} @@ -594,12 +583,40 @@ export default function AdminArticlesPage() {

{article.facility_name || "Uten koblet bane"}

+

+ Klikk for å redigere +

))} -
+
+
+
+

+ {selectedArticleId ? "Rediger artikkel" : "Ny artikkel"} +

+

+ {form.title.trim() || (selectedArticleId ? "Uten tittel" : "Nytt utkast")} +

+

+ {selectedArticleId + ? "Endringer lagres i databasen og kan publiseres eller avpubliseres direkte herfra." + : "Fyll inn artikkelen og lagre når utkastet er klart."} +

+
+ + {form.status === "published" ? "Publisert" : "Utkast"} + +
+