diff --git a/backend/import_meninger.py b/backend/import_meninger.py new file mode 100644 index 0000000..5cefe8f --- /dev/null +++ b/backend/import_meninger.py @@ -0,0 +1,366 @@ +import argparse +import json +import re +from html import unescape +from html.parser import HTMLParser +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import requests + +WP_API_BASE = "https://teeoff.no/wp-json/wp/v2/meninger" +DEFAULT_USER_AGENT = "TeeOff-Meninger-Importer/1.0" +DEFAULT_OUTPUT = Path("/opt/teeoff/frontend/src/content/importedMeninger.json") +DEFAULT_MEDIA_DIR = Path("/opt/teeoff/frontend/public/media/meninger") +INTERNAL_GOLF_COURSE_PATTERN = re.compile(r"https?://teeoff\.no/golfbaner/([^/?#]+)/?", re.IGNORECASE) +INTERNAL_TEEOFF_LINK_PATTERN = re.compile(r"https?://teeoff\.no/([^\"'#? ]+)", re.IGNORECASE) +IMG_SRC_PATTERN = re.compile(r"]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE) +DISALLOWED_INTERNAL_SEGMENTS = { + "wp-content", + "wp-json", + "meninger", + "category", + "author", + "tag", + "feed", +} + + +class TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + if data: + self.parts.append(data) + + def get_text(self) -> str: + return " ".join(part.strip() for part in self.parts if part.strip()) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Importer Meninger-artikler fra gammel TeeOff WordPress-instans." + ) + parser.add_argument( + "--output", + default=str(DEFAULT_OUTPUT), + help=f"Sti til JSON-filen som skal skrives. Standard: {DEFAULT_OUTPUT}", + ) + parser.add_argument( + "--media-dir", + default=str(DEFAULT_MEDIA_DIR), + help=f"Mappe for nedlastede bilder. Standard: {DEFAULT_MEDIA_DIR}", + ) + parser.add_argument( + "--per-page", + type=int, + default=100, + help="Antall artikler per API-kall. Standard: 100", + ) + parser.add_argument( + "--category", + default=None, + help="Filtrer på kategorislug, f.eks. 'banebesok'.", + ) + parser.add_argument( + "--download-media", + action="store_true", + help="Last ned featured media og inline-bilder lokalt og skriv om URL-er i HTML.", + ) + parser.add_argument( + "--draft", + action="store_true", + help="Ta med artikler som ikke er publisert dersom API-et returnerer dem.", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Maks antall artikler som skal skrives etter filtrering.", + ) + return parser.parse_args() + + +def fetch_json(url: str, params: dict[str, Any] | None = None) -> Any: + response = requests.get( + url, + params=params, + timeout=30, + headers={"User-Agent": DEFAULT_USER_AGENT}, + ) + response.raise_for_status() + return response.json() + + +def fetch_all_posts(per_page: int, limit: int | None = None) -> list[dict[str, Any]]: + page = 1 + posts: list[dict[str, Any]] = [] + + while True: + try: + data = fetch_json( + WP_API_BASE, + params={ + "per_page": per_page, + "page": page, + "_embed": "1", + }, + ) + except requests.HTTPError as exc: + response = exc.response + if response is not None and response.status_code == 400 and page > 1: + break + raise + if not data: + break + posts.extend(data) + if limit is not None and len(posts) >= limit: + return posts[:limit] + page += 1 + + return posts + + +def strip_tags(value: str | None) -> str: + if not value: + return "" + parser = TextExtractor() + parser.feed(unescape(value)) + parser.close() + return parser.get_text() + + +def ensure_directory(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def choose_media_url(media_entry: dict[str, Any]) -> str | None: + media_details = media_entry.get("media_details") or {} + sizes = media_details.get("sizes") or {} + for key in ("full", "1536x1536", "large", "medium"): + candidate = sizes.get(key, {}).get("source_url") + if candidate: + return candidate + return media_entry.get("source_url") + + +def download_file(url: str, target_dir: Path, basename: str) -> str | None: + ensure_directory(target_dir) + parsed = urlparse(url) + suffix = Path(parsed.path).suffix.lower() + if suffix not in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"}: + suffix = ".jpg" + + filename = f"{basename}{suffix}" + target_path = target_dir / filename + + if not target_path.exists(): + response = requests.get(url, timeout=60, headers={"User-Agent": DEFAULT_USER_AGENT}) + response.raise_for_status() + target_path.write_bytes(response.content) + + return "/" + str(target_path.relative_to(Path("/opt/teeoff/frontend/public"))).replace("\\", "/") + + +def extract_categories(post: dict[str, Any]) -> list[dict[str, str]]: + embedded_terms = (post.get("_embedded") or {}).get("wp:term") or [] + categories: list[dict[str, str]] = [] + for term_group in embedded_terms: + if not isinstance(term_group, list): + continue + for term in term_group: + if term.get("taxonomy") != "category": + continue + categories.append( + { + "id": str(term.get("id", "")), + "name": str(term.get("name", "")), + "slug": str(term.get("slug", "")), + } + ) + return categories + + +def detect_facility_slugs(html: str) -> list[str]: + found = INTERNAL_GOLF_COURSE_PATTERN.findall(html or "") + if html: + for raw_path in INTERNAL_TEEOFF_LINK_PATTERN.findall(html): + path = raw_path.strip("/").split("?")[0] + if not path: + continue + segments = [segment for segment in path.split("/") if segment] + if not segments: + continue + if segments[0] in DISALLOWED_INTERNAL_SEGMENTS: + continue + candidate = segments[-1] + if "golf" not in candidate: + continue + found.append(candidate) + seen: dict[str, None] = {} + for slug in found: + seen[slug] = None + return list(seen.keys()) + + +def collect_inline_image_urls(html: str) -> list[str]: + urls: list[str] = [] + for src in IMG_SRC_PATTERN.findall(html or ""): + if src.startswith("http"): + urls.append(src) + deduped: dict[str, None] = {} + for url in urls: + deduped[url] = None + return list(deduped.keys()) + + +def rewrite_html_media( + html: str, + post_slug: str, + target_dir: Path, + featured_url: str | None = None, +) -> tuple[str, list[str], str | None]: + downloaded_urls: list[str] = [] + rewrite_map: dict[str, str] = {} + image_index = 1 + + if featured_url: + local_featured = download_file(featured_url, target_dir, f"{post_slug}-featured") + if local_featured: + downloaded_urls.append(local_featured) + rewrite_map[featured_url] = local_featured + else: + local_featured = None + + for url in collect_inline_image_urls(html): + local_path = download_file(url, target_dir, f"{post_slug}-inline-{image_index:02d}") + image_index += 1 + if not local_path: + continue + rewrite_map[url] = local_path + downloaded_urls.append(local_path) + + rewritten_html = html or "" + for original, local in rewrite_map.items(): + rewritten_html = rewritten_html.replace(original, local) + + return rewritten_html, downloaded_urls, local_featured + + +def normalize_post( + post: dict[str, Any], + category_filter: str | None, + download_media: bool, + media_dir: Path, +) -> dict[str, Any] | None: + status = str(post.get("status") or "") + categories = extract_categories(post) + category_slugs = [entry["slug"] for entry in categories if entry.get("slug")] + if category_filter and category_filter not in category_slugs: + return None + + title_html = str((post.get("title") or {}).get("rendered") or "") + excerpt_html = str((post.get("excerpt") or {}).get("rendered") or "") + content_html = str((post.get("content") or {}).get("rendered") or "") + + embedded = post.get("_embedded") or {} + author_entry = ((embedded.get("author") or [None])[0]) or {} + featured_entry = ((embedded.get("wp:featuredmedia") or [None])[0]) or {} + featured_url = choose_media_url(featured_entry) if featured_entry else None + featured_alt = str(featured_entry.get("alt_text") or "") if featured_entry else "" + featured_caption = strip_tags(str((featured_entry.get("caption") or {}).get("rendered") or "")) + + if download_media: + content_html, downloaded_media, local_featured = rewrite_html_media( + content_html, + str(post.get("slug") or "mening"), + media_dir, + featured_url, + ) + featured_image = local_featured or featured_url + else: + downloaded_media = [] + featured_image = featured_url + + facility_slugs = detect_facility_slugs(content_html) + + return { + "id": post.get("id"), + "slug": post.get("slug"), + "status": status, + "type": post.get("type"), + "link": post.get("link"), + "title": strip_tags(title_html), + "titleHtml": title_html, + "excerpt": strip_tags(excerpt_html), + "excerptHtml": excerpt_html, + "contentHtml": content_html, + "publishedAt": post.get("date"), + "updatedAt": post.get("modified"), + "author": { + "id": author_entry.get("id"), + "name": author_entry.get("name"), + "slug": author_entry.get("slug"), + "link": author_entry.get("link"), + }, + "featuredImage": { + "url": featured_image, + "originalUrl": featured_url, + "alt": featured_alt, + "caption": featured_caption, + } + if featured_url or featured_image + else None, + "inlineMedia": downloaded_media, + "categories": categories, + "categorySlugs": category_slugs, + "facilitySlugs": facility_slugs, + "primaryFacilitySlug": facility_slugs[0] if facility_slugs else None, + } + + +def main() -> None: + args = parse_args() + output_path = Path(args.output) + media_dir = Path(args.media_dir) + + print("🚀 Starter import av Meninger fra WordPress...") + posts = fetch_all_posts(args.per_page, args.limit) + print(f"📦 Hentet {len(posts)} artikler fra {WP_API_BASE}") + + normalized_posts: list[dict[str, Any]] = [] + for post in posts: + if not args.draft and str(post.get("status") or "") != "publish": + continue + normalized = normalize_post( + post, + category_filter=args.category, + download_media=args.download_media, + media_dir=media_dir, + ) + if normalized is None: + continue + normalized_posts.append(normalized) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(normalized_posts, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + print(f"✅ Skrev {len(normalized_posts)} artikler til {output_path}") + if args.download_media: + print(f"🖼️ Bilder ble lagret under {media_dir}") + + if args.category: + print(f"🏷️ Kategorifilter brukt: {args.category}") + + linked_count = sum(1 for post in normalized_posts if post.get("primaryFacilitySlug")) + print(f"⛳ {linked_count} artikler fikk koblet minst én golfbane-slug fra internlenker.") + + +if __name__ == "__main__": + main() diff --git a/backend/main.py b/backend/main.py index 7ccf053..dfff67c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,9 @@ import asyncpg import json import pyotp import os +import re from datetime import datetime, date, timedelta +from pathlib import Path from jose import jwt, JWTError from passlib.context import CryptContext from dotenv import load_dotenv @@ -102,6 +104,25 @@ class BulkVtgRequest(BaseModel): class AdminPasswordConfirm(BaseModel): password: str + + +class ArticleUpsertRequest(BaseModel): + slug: str + title: str + description: Optional[str] = None + excerpt: Optional[str] = None + eyebrow: Optional[str] = None + location_label: Optional[str] = None + facility_name: Optional[str] = None + facility_slug: Optional[str] = None + author_name: Optional[str] = None + status: Optional[str] = "draft" + hero_images: Optional[List[dict[str, Any]]] = [] + content_html: Optional[str] = None + source_url: Optional[str] = None + source_label: Optional[str] = None + published_at: Optional[str] = None + updated_at: Optional[str] = None # --- FUNKSJONER --- def format_row(row): """ @@ -169,6 +190,90 @@ def generate_totp_qr_svg(provisioning_uri: str) -> str: ) return image.to_string(encoding="unicode") + +def format_article_row(row): + if row is None: + return None + + data = dict(row) + for key in ["published_at", "updated_at", "created_at"]: + if isinstance(data.get(key), (date, datetime)): + data[key] = data[key].isoformat() + + hero_images = data.get("hero_images") + if hero_images is None: + data["hero_images"] = [] + elif isinstance(hero_images, str): + try: + data["hero_images"] = json.loads(hero_images) + except Exception: + data["hero_images"] = [] + elif not isinstance(hero_images, list): + data["hero_images"] = [] + + return data + + +def normalize_article_status(status: str | None) -> str: + normalized = str(status or "draft").strip().lower() + if normalized not in {"draft", "published"}: + raise HTTPException(status_code=400, detail="Ugyldig artikkelstatus. Bruk 'draft' eller 'published'.") + return normalized + + +def parse_optional_datetime(value: str | None) -> datetime | None: + if value is None: + return None + trimmed = str(value).strip() + if not trimmed: + return None + try: + return datetime.fromisoformat(trimmed.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Ugyldig datoformat: {value}") from exc + + +def sanitize_hero_images(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + + sanitized: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, dict): + continue + src = str(item.get("src") or "").strip() + if not src: + continue + sanitized.append( + { + "src": src, + "alt": str(item.get("alt") or "").strip(), + "caption": str(item.get("caption") or "").strip(), + } + ) + return sanitized + + +def humanize_slug(slug: str | None) -> str: + if not slug: + return "Ukjent bane" + return " ".join(part.capitalize() for part in str(slug).split("-") if part) + + +ARTICLE_IMAGE_PATTERN = re.compile(r"]*\bsrc=['\"]([^'\"]+)['\"]", re.IGNORECASE) + + +def extract_html_image_urls(html: str | None) -> list[str]: + urls: list[str] = [] + for url in ARTICLE_IMAGE_PATTERN.findall(html or ""): + if not isinstance(url, str) or not url.strip(): + continue + urls.append(url.strip()) + deduped: dict[str, None] = {} + for url in urls: + deduped[url] = None + return list(deduped.keys()) + async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: str | None = None): if job_type not in SCRAPE_JOB_TYPES: raise HTTPException(status_code=400, detail=f"Ugyldig jobbtype: {job_type}") @@ -207,6 +312,33 @@ async def ensure_facility_columns(conn): """) +async def ensure_articles_table(conn): + await conn.execute(""" + CREATE TABLE IF NOT EXISTS articles ( + id SERIAL PRIMARY KEY, + slug VARCHAR(255) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + excerpt TEXT, + eyebrow VARCHAR(120) DEFAULT 'Banebesøk', + location_label VARCHAR(255), + facility_name VARCHAR(255), + facility_slug VARCHAR(255), + author_name VARCHAR(255), + status VARCHAR(32) NOT NULL DEFAULT 'draft', + hero_images JSONB NOT NULL DEFAULT '[]'::jsonb, + content_html TEXT, + source_url TEXT, + source_label VARCHAR(255), + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + await conn.execute("CREATE INDEX IF NOT EXISTS articles_status_idx ON articles (status)") + await conn.execute("CREATE INDEX IF NOT EXISTS articles_published_at_idx ON articles (published_at DESC)") + + @asynccontextmanager async def lifespan(app: FastAPI): # Opprett database-pool ved start @@ -220,6 +352,7 @@ async def lifespan(app: FastAPI): ) async with app.state.pool.acquire() as conn: await ensure_facility_columns(conn) + await ensure_articles_table(conn) await ensure_scrape_jobs_table(conn) print("✅ Database tilkoblet og pool opprettet") except Exception as e: @@ -387,8 +520,242 @@ async def get_facility(slug: str): return format_row(row) + +@app.get("/api/course-visits") +async def get_course_visits(): + """Henter publiserte Banebesøk-artikler.""" + async with app.state.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT * + FROM articles + WHERE status = 'published' + ORDER BY COALESCE(published_at, created_at) DESC, id DESC + """) + return [format_article_row(row) for row in rows] + + +@app.get("/api/course-visits/{slug}") +async def get_course_visit(slug: str): + """Henter én publisert Banebesøk-artikkel.""" + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT * + FROM articles + WHERE slug = $1 AND status = 'published' + """, slug) + if not row: + raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") + return format_article_row(row) + # --- ADMIN ENDPOINTS --- + +@app.get("/api/admin/articles") +async def get_admin_articles(status: Optional[str] = Query(default="all")): + """Henter artikler for admin med valgfritt statusfilter.""" + normalized_status = str(status or "all").strip().lower() + if normalized_status not in {"all", "draft", "published"}: + raise HTTPException(status_code=400, detail="Ugyldig statusfilter") + + query = """ + SELECT * + FROM articles + {where_clause} + ORDER BY COALESCE(published_at, created_at) DESC, updated_at DESC, id DESC + """ + + async with app.state.pool.acquire() as conn: + if normalized_status == "all": + rows = await conn.fetch(query.format(where_clause="")) + else: + rows = await conn.fetch( + query.format(where_clause="WHERE status = $1"), + normalized_status, + ) + return [format_article_row(row) for row in rows] + + +@app.get("/api/admin/articles/{article_id}") +async def get_admin_article(article_id: int): + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM articles WHERE id = $1", article_id) + if not row: + raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") + return format_article_row(row) + + +@app.post("/api/admin/articles") +async def upsert_admin_article(request: ArticleUpsertRequest): + status = normalize_article_status(request.status) + published_at = parse_optional_datetime(request.published_at) + updated_at = parse_optional_datetime(request.updated_at) or datetime.utcnow() + if status == "published" and not published_at: + published_at = datetime.utcnow() + + hero_images = sanitize_hero_images(request.hero_images) + + async with app.state.pool.acquire() as conn: + row = await conn.fetchrow(""" + INSERT INTO articles ( + slug, title, description, excerpt, eyebrow, location_label, + facility_name, facility_slug, author_name, status, hero_images, + content_html, source_url, source_label, published_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11::jsonb, + $12, $13, $14, $15, $16 + ) + ON CONFLICT (slug) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + excerpt = EXCLUDED.excerpt, + eyebrow = EXCLUDED.eyebrow, + location_label = EXCLUDED.location_label, + facility_name = EXCLUDED.facility_name, + facility_slug = EXCLUDED.facility_slug, + author_name = EXCLUDED.author_name, + status = EXCLUDED.status, + hero_images = EXCLUDED.hero_images, + content_html = EXCLUDED.content_html, + source_url = EXCLUDED.source_url, + source_label = EXCLUDED.source_label, + published_at = EXCLUDED.published_at, + updated_at = EXCLUDED.updated_at + RETURNING * + """, + request.slug.strip(), + request.title.strip(), + (request.description or "").strip() or None, + (request.excerpt or "").strip() or None, + (request.eyebrow or "Banebesøk").strip(), + (request.location_label or "").strip() or None, + (request.facility_name or "").strip() or None, + (request.facility_slug or "").strip() or None, + (request.author_name or "TeeOff").strip(), + status, + json.dumps(hero_images), + request.content_html or "", + (request.source_url or "").strip() or None, + (request.source_label or "").strip() or None, + published_at, + updated_at, + ) + return format_article_row(row) + + +@app.delete("/api/admin/articles/{article_id}") +async def delete_admin_article(article_id: int): + async with app.state.pool.acquire() as conn: + deleted = await conn.fetchrow("DELETE FROM articles WHERE id = $1 RETURNING id", article_id) + if not deleted: + raise HTTPException(status_code=404, detail="Artikkelen ble ikke funnet") + return {"status": "success"} + + +@app.post("/api/admin/articles/seed-imported") +async def seed_admin_articles_from_imported_json(): + imported_path = Path("/opt/teeoff/frontend/src/content/importedMeninger.json") + if not imported_path.exists(): + raise HTTPException(status_code=404, detail="Fant ikke importedMeninger.json") + + try: + imported_articles = json.loads(imported_path.read_text(encoding="utf-8")) + except Exception as exc: + raise HTTPException(status_code=500, detail="Kunne ikke lese importedMeninger.json") from exc + + async with app.state.pool.acquire() as conn: + facility_rows = await conn.fetch("SELECT slug, name, county FROM facilities") + facility_lookup = { + str(row["slug"]): { + "name": row["name"], + "county": row["county"], + } + for row in facility_rows + } + + upserted_count = 0 + async with conn.transaction(): + for item in imported_articles: + facility_slug = item.get("primaryFacilitySlug") or ((item.get("facilitySlugs") or [None])[0]) + if not facility_slug: + continue + + facility = facility_lookup.get(str(facility_slug), {}) + content_html = str(item.get("contentHtml") or "") + featured_image = item.get("featuredImage") or {} + + hero_images: list[dict[str, str]] = [] + featured_url = str(featured_image.get("url") or "").strip() + if featured_url: + hero_images.append( + { + "src": featured_url, + "alt": str(featured_image.get("alt") or item.get("title") or "").strip(), + "caption": str(featured_image.get("caption") or item.get("title") or "").strip(), + } + ) + + for url in extract_html_image_urls(content_html)[:5]: + if any(existing["src"] == url for existing in hero_images): + continue + hero_images.append( + { + "src": url, + "alt": str(item.get("title") or "").strip(), + "caption": str(item.get("title") or "").strip(), + } + ) + + published_at = parse_optional_datetime(item.get("publishedAt")) + updated_at = parse_optional_datetime(item.get("updatedAt")) or published_at or datetime.utcnow() + + await conn.execute(""" + INSERT INTO articles ( + slug, title, description, excerpt, eyebrow, location_label, + facility_name, facility_slug, author_name, status, hero_images, + content_html, source_url, source_label, published_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, 'published', $10::jsonb, + $11, $12, $13, $14, $15 + ) + ON CONFLICT (slug) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + excerpt = EXCLUDED.excerpt, + eyebrow = EXCLUDED.eyebrow, + location_label = EXCLUDED.location_label, + facility_name = EXCLUDED.facility_name, + facility_slug = EXCLUDED.facility_slug, + author_name = EXCLUDED.author_name, + status = EXCLUDED.status, + hero_images = EXCLUDED.hero_images, + content_html = EXCLUDED.content_html, + source_url = EXCLUDED.source_url, + source_label = EXCLUDED.source_label, + published_at = EXCLUDED.published_at, + updated_at = EXCLUDED.updated_at + """, + str(item.get("slug") or "").strip(), + str(item.get("title") or "").strip(), + str(item.get("excerpt") or "").strip() or None, + str(item.get("excerpt") or "").strip() or None, + "Banebesøk", + str(facility.get("county") or "Norge"), + str(facility.get("name") or humanize_slug(str(facility_slug))), + str(facility_slug), + str(((item.get("author") or {}).get("name")) or "TeeOff"), + json.dumps(hero_images), + content_html, + str(item.get("link") or "").strip() or None, + "Importert fra gamle TeeOff", + published_at, + updated_at, + ) + upserted_count += 1 + + return {"status": "success", "count": upserted_count} + @app.patch("/api/admin/facilities/{facility_id}/scrape-settings") async def update_scrape_settings(facility_id: int, settings: ScrapeSettingsUpdate): """Oppdaterer hvordan et anlegg skal skrapes (f.eks. slå på Gemini AI eller bytte URL).""" diff --git a/docker-compose.yml b/docker-compose.yml index ea5a310..5b9a40d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,8 @@ services: command: npm start ports: - "3000:3000" + volumes: + - ./frontend/public/uploads:/app/public/uploads # VIKTIG: Jeg har fjernet "- ./frontend:/app" her for å sikre stabilitet depends_on: - api diff --git a/frontend/.gitignore b/frontend/.gitignore index 5ef6a52..6c83f4f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -19,6 +19,7 @@ # production /build +/public/uploads # misc .DS_Store diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa30..f1cb1d9 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,23 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + formats: ["image/avif", "image/webp"], + remotePatterns: [ + { + protocol: "https", + hostname: "teeoff.no", + }, + { + protocol: "https", + hostname: "www.teeoff.no", + }, + { + protocol: "https", + hostname: "i.ytimg.com", + }, + ], + }, }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13319d8..f685583 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,22 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@tiptap/extension-image": "^3.22.3", + "@tiptap/extension-link": "^3.22.3", + "@tiptap/extension-placeholder": "^3.22.3", + "@tiptap/extension-underline": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "@tiptap/react": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", + "leaflet": "^1.9.4", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "react-leaflet": "^5.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -453,6 +463,34 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1226,6 +1264,23 @@ "node": ">=12.4.0" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1513,6 +1568,466 @@ "tailwindcss": "4.2.0" } }, + "node_modules/@tiptap/core": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.3.tgz", + "integrity": "sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.3.tgz", + "integrity": "sha512-IaUx3zh7yLHXzIXKL+fw/jzFhsIImdhJyw0lMhe8FfYrefFqXJFYW/sey6+L/e8B3AWvTksPA6VBwefzbH77JA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.3.tgz", + "integrity": "sha512-tysipHla2zCWr8XNIWRaW9O+7i7/SoEqnRqSRUUi2ailcJjlia+RBy3RykhkgyThrQDStu5KGBS/UvrXwA+O1A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.22.3.tgz", + "integrity": "sha512-Y6zQjh0ypDg32HWgICEvmPSKjGLr39k3aDxxt/H0uQEZSfw4smT0hxUyyyjVjx68C6t6MTnwdfz0hPI5lL68vQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.3.tgz", + "integrity": "sha512-xOmW/b1hgECIE6r3IeZvKn4VVlG3+dfTjCWE6lnnyLaqdNkNhKS1CwUmDZdYNLUS2ryIUtgz5ID1W/8A3PhbiA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.3.tgz", + "integrity": "sha512-wafWTDQOuMKtXpZEuk1PFQmzopabBciNLryL90MB9S03MNLaQQZYLnmYkDBlzAaLAbgF5QiC+2XZQEBQuTVjFQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.3.tgz", + "integrity": "sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.3.tgz", + "integrity": "sha512-MCSr1PFPtTd++lA3H1RNgqAczAE59XXJ5wUFIQf2F+/0DPY5q2SU4g5QsNJVxPPft5mrNT4C6ty8xBPrALFEdA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.3.tgz", + "integrity": "sha512-taXq9Tl5aybdFbptJtFRHX9LFJzbXphAbPp4/vutFyTrBu5meXDxuS+B9pEmE+Or0XcolTlW2nDZB0Tqnr18JQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.3.tgz", + "integrity": "sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.3.tgz", + "integrity": "sha512-L/Px4UeQEVG/D9WIlcAOIej+4wyIBCMUSYicSR+hW68UsObe4rxVbUas1QgidQKm6DOhoT7U7D4KQHA/Gdg/7A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.3.tgz", + "integrity": "sha512-J0v8I99y9tbvVmgKYKzKP/JYNsWaZYS7avn4rzLft2OhnyTfwt3OoY8DtpHmmi6apSUaCtoWHWta/TmoEfK1nQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.3.tgz", + "integrity": "sha512-XBHuhiEV2EEhZHpOLcplLqAmBIhJciU3I6AtwmqeEqDC0P114uMEfAO7JGlbBZdCYotNer26PKnu44TBTeNtkw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.3.tgz", + "integrity": "sha512-wI2bFzScs+KgWeBH/BtypcVKeYelCyqV0RG8nxsZMWtPrBhqixzNd0Oi3gEKtjSjKUqMQ/kjJAIRuESr5UzlHA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.22.3.tgz", + "integrity": "sha512-Qpp8c5LOQaNpHrzjqZtoxtIR+8sSqJ7k8v+8anmYw3nxjvt2kpfT28Vd7aWMX55ZS43LaxMx+MkZqbmgUmMP0w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.3.tgz", + "integrity": "sha512-LteA4cb4EGCiUtrK2JHvDF/Zg0/YqV4DUyHhAAho+oGEQDupZlsS6m0ia5wQcclkiTLzsoPrwcSNu6RDGQ16wQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.3.tgz", + "integrity": "sha512-S8/P2o9pv6B3kqLjH2TRWwSAximGbciNc6R8/QcN6HWLYxp0N0JoqN3rZHl9VWIBAGRWc4zkt80dhqrl2xmgfQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.3.tgz", + "integrity": "sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.3.tgz", + "integrity": "sha512-80CNf4oO5y8+LdckT4CyMe1t01EyhpRrQC9H45JW20P7559Nrchp5my3vvMtIAJbpTPPZtcB7LwdzWGKsG5drg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.3.tgz", + "integrity": "sha512-pKuyj5llu35zd/s2u/H9aydKZjmPRAIK5P1q/YXULhhCNln2RnmuRfQ5NklAqTD3yGciQ2lxDwwf7J6iw3ergA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.3.tgz", + "integrity": "sha512-orAghtmd+K4Euu4BgI1hG+iZDXBYOyl5YTwiLBc2mQn+pqtZ9LqaH2us4ETwEwNP3/IWXGSAimUZ19nuL+eM2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.3.tgz", + "integrity": "sha512-oO7rhfyhEuwm+50s9K3GZPjYyEEEvFAvm1wXopvZnhbkBLydIWImBfrZoC5IQh4/sRDlTIjosV2C+ji5y0tUSg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.22.3.tgz", + "integrity": "sha512-7vbtlDVO00odqCnsMSmA4b6wjL5PFdfExFsdsDO0K0VemqHZ/doIRx/tosNUD1VYSOyKQd8U7efUjkFyVoIPlg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.3.tgz", + "integrity": "sha512-jY2InoUlKkuk5KHoIDGdML1OCA2n6PRHAtxwHNkAmiYh0Khf0zaVPGFpx4dgQrN7W5Q1WE6oBZnjrvy6qb7w0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.3.tgz", + "integrity": "sha512-Q9R7JsTdomP5uUjtPjNKxHT1xoh/i9OJZnmgJLe7FcgZEaPOQ3bWxmKZoLZQfDfZjyB8BtH+Hc7nUvhCMOePxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.3.tgz", + "integrity": "sha512-Ch6CBWRa5w90yYSPUW6x9Py9JdrXMqk3pZ9OIlMYD8A7BqyZGfiHerX7XDMYDS09KjyK3U9XH60/zxYOzXdDLA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.3.tgz", + "integrity": "sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.3.tgz", + "integrity": "sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.22.3.tgz", + "integrity": "sha512-6MNr6z0PxwfJFs+BKhHcvPNvY+UV1PXgqzTiTM4Z9guml84iVZxv7ZOCSj1dFYTr3Bf1MiOs4hT1yvBFlTfIaQ==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.22.3", + "@tiptap/extension-floating-menu": "^3.22.3" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.3.tgz", + "integrity": "sha512-vdW/Oo1fdwTL1VOQ5YYbTov00ANeHLquBVEZyL/EkV7Xv5io9rXQsCysJfTSHhiQlyr2MtWFB4+CPGuwXjQWOQ==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.22.3", + "@tiptap/extension-blockquote": "^3.22.3", + "@tiptap/extension-bold": "^3.22.3", + "@tiptap/extension-bullet-list": "^3.22.3", + "@tiptap/extension-code": "^3.22.3", + "@tiptap/extension-code-block": "^3.22.3", + "@tiptap/extension-document": "^3.22.3", + "@tiptap/extension-dropcursor": "^3.22.3", + "@tiptap/extension-gapcursor": "^3.22.3", + "@tiptap/extension-hard-break": "^3.22.3", + "@tiptap/extension-heading": "^3.22.3", + "@tiptap/extension-horizontal-rule": "^3.22.3", + "@tiptap/extension-italic": "^3.22.3", + "@tiptap/extension-link": "^3.22.3", + "@tiptap/extension-list": "^3.22.3", + "@tiptap/extension-list-item": "^3.22.3", + "@tiptap/extension-list-keymap": "^3.22.3", + "@tiptap/extension-ordered-list": "^3.22.3", + "@tiptap/extension-paragraph": "^3.22.3", + "@tiptap/extension-strike": "^3.22.3", + "@tiptap/extension-text": "^3.22.3", + "@tiptap/extension-underline": "^3.22.3", + "@tiptap/extensions": "^3.22.3", + "@tiptap/pm": "^3.22.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1531,6 +2046,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1545,6 +2067,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", @@ -1559,7 +2113,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1569,12 +2122,17 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -2199,7 +2757,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -2639,6 +3196,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2658,7 +3221,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2849,6 +3411,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -3040,7 +3614,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3486,6 +4059,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -4547,6 +5129,12 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4822,6 +5410,21 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4878,6 +5481,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4888,6 +5508,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5231,6 +5857,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -5401,6 +6033,201 @@ "react-is": "^16.13.1" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", + "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", + "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5411,6 +6238,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5460,6 +6296,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5556,6 +6406,12 @@ "node": ">=0.10.0" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6328,6 +7184,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -6430,6 +7292,21 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5e6303e..673e0cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@tiptap/extension-image": "^3.22.3", + "@tiptap/extension-link": "^3.22.3", + "@tiptap/extension-placeholder": "^3.22.3", + "@tiptap/extension-underline": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "@tiptap/react": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", "leaflet": "^1.9.4", "next": "16.1.6", "react": "19.2.3", diff --git a/frontend/src/app/admin/artikler/page.tsx b/frontend/src/app/admin/artikler/page.tsx new file mode 100644 index 0000000..decee76 --- /dev/null +++ b/frontend/src/app/admin/artikler/page.tsx @@ -0,0 +1,709 @@ +"use client"; + +import Link from "next/link"; +import { type ChangeEvent, useEffect, useRef, useState } from "react"; +import { API_URL } from "@/config/constants"; +import { adminFetch } from "@/config/adminFetch"; +import TiptapHtmlEditor from "@/components/TiptapHtmlEditor"; + +type AdminArticle = { + id: number; + slug: string; + title: string; + description?: string | null; + excerpt?: string | null; + eyebrow?: string | null; + location_label?: string | null; + facility_name?: string | null; + facility_slug?: string | null; + author_name?: string | null; + status: "draft" | "published"; + hero_images?: Array<{ + src: string; + alt?: string; + caption?: string; + }>; + content_html?: string | null; + source_url?: string | null; + source_label?: string | null; + published_at?: string | null; + updated_at?: string | null; +}; + +type FacilityOption = { + slug: string; + name: string; + county?: string | null; +}; + +type ArticleFormState = { + slug: string; + title: string; + description: string; + excerpt: string; + eyebrow: string; + location_label: string; + facility_name: string; + facility_slug: string; + author_name: string; + status: "draft" | "published"; + heroImageUrls: string; + content_html: string; + source_url: string; + source_label: string; + published_at: string; +}; + +function slugify(value: string) { + return value + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function toDatetimeLocal(value?: string | null) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const offset = date.getTimezoneOffset(); + const local = new Date(date.getTime() - offset * 60_000); + return local.toISOString().slice(0, 16); +} + +function heroImagesToText(images?: AdminArticle["hero_images"]) { + if (!Array.isArray(images)) return ""; + return images.map((image) => image.src).filter(Boolean).join("\n"); +} + +function createEmptyForm(): ArticleFormState { + return { + slug: "", + title: "", + description: "", + excerpt: "", + eyebrow: "Banebesøk", + location_label: "", + facility_name: "", + facility_slug: "", + author_name: "TeeOff", + status: "draft", + heroImageUrls: "", + content_html: "", + source_url: "", + source_label: "", + published_at: "", + }; +} + +function articleToForm(article: AdminArticle): ArticleFormState { + return { + slug: article.slug || "", + title: article.title || "", + description: article.description || "", + excerpt: article.excerpt || "", + eyebrow: article.eyebrow || "Banebesøk", + location_label: article.location_label || "", + facility_name: article.facility_name || "", + facility_slug: article.facility_slug || "", + author_name: article.author_name || "TeeOff", + status: article.status || "draft", + heroImageUrls: heroImagesToText(article.hero_images), + content_html: article.content_html || "", + source_url: article.source_url || "", + source_label: article.source_label || "", + published_at: toDatetimeLocal(article.published_at), + }; +} + +function buildHeroImages(heroImageUrls: string, title: string) { + return heroImageUrls + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((src) => ({ + src, + alt: title, + caption: title, + })); +} + +export default function AdminArticlesPage() { + const [articles, setArticles] = useState([]); + const [facilities, setFacilities] = useState([]); + const [selectedArticleId, setSelectedArticleId] = useState(null); + const [form, setForm] = useState(createEmptyForm()); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isSeeding, setIsSeeding] = useState(false); + const [isUploadingHeroImages, setIsUploadingHeroImages] = useState(false); + const [feedback, setFeedback] = useState(""); + const [slugTouched, setSlugTouched] = useState(false); + const heroImageInputRef = useRef(null); + + const loadArticles = async () => { + const response = await adminFetch(`${API_URL}/admin/articles`); + const data = await response.json(); + setArticles(Array.isArray(data) ? (data as AdminArticle[]) : []); + }; + + const loadFacilities = async () => { + const response = await fetch(`${API_URL}/facilities`, { credentials: "include" }); + const data = await response.json(); + const mapped = Array.isArray(data) + ? data + .filter((item): item is FacilityOption => Boolean(item?.slug && item?.name)) + .map((item) => ({ + slug: item.slug, + name: item.name, + county: item.county || "", + })) + : []; + setFacilities(mapped); + }; + + useEffect(() => { + const init = async () => { + try { + await Promise.all([loadArticles(), loadFacilities()]); + } finally { + setIsLoading(false); + } + }; + + void init(); + }, []); + + const handleSelectArticle = (article: AdminArticle) => { + setSelectedArticleId(article.id); + setForm(articleToForm(article)); + setSlugTouched(true); + setFeedback(""); + }; + + const handleCreateNew = () => { + setSelectedArticleId(null); + setForm(createEmptyForm()); + setSlugTouched(false); + setFeedback(""); + }; + + const handleFieldChange = (field: keyof ArticleFormState, value: string) => { + setForm((current) => ({ ...current, [field]: value })); + }; + + const uploadArticleImage = async (file: File) => { + const payload = new FormData(); + payload.append("file", file); + + const response = await adminFetch("/api/admin/uploads/images", { + method: "POST", + body: payload, + credentials: "include", + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "Kunne ikke laste opp bildet." })); + throw new Error(error.detail || "Kunne ikke laste opp bildet."); + } + + const result = (await response.json()) as { url?: string }; + if (!result.url) { + throw new Error("Uploaden returnerte ingen bildeadresse."); + } + + return result.url; + }; + + const appendHeroImageUrls = (urls: string[]) => { + setForm((current) => { + const existingUrls = current.heroImageUrls + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const combined = [...existingUrls, ...urls].filter( + (url, index, list) => list.indexOf(url) === index, + ); + + return { + ...current, + heroImageUrls: combined.join("\n"), + }; + }); + }; + + const handleTitleChange = (value: string) => { + setForm((current) => ({ + ...current, + title: value, + slug: slugTouched ? current.slug : slugify(value), + })); + }; + + const handleFacilityChange = (facilitySlug: string) => { + const facility = facilities.find((entry) => entry.slug === facilitySlug); + setForm((current) => ({ + ...current, + facility_slug: facilitySlug, + facility_name: facility?.name || current.facility_name, + location_label: facility?.county || current.location_label, + })); + }; + + const handleHeroImageUpload = async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []); + event.target.value = ""; + + if (files.length === 0) return; + + setIsUploadingHeroImages(true); + setFeedback(""); + + try { + const uploadedUrls = await Promise.all(files.map((file) => uploadArticleImage(file))); + appendHeroImageUrls(uploadedUrls); + setFeedback( + `Lastet opp ${uploadedUrls.length} hero-bilde${uploadedUrls.length === 1 ? "" : "r"} som AVIF.`, + ); + } catch (error) { + setFeedback(error instanceof Error ? error.message : "Kunne ikke laste opp hero-bilder."); + } finally { + setIsUploadingHeroImages(false); + } + }; + + const handleSave = async () => { + setIsSaving(true); + setFeedback(""); + + try { + const payload = { + ...form, + slug: form.slug.trim(), + title: form.title.trim(), + description: form.description.trim(), + excerpt: form.excerpt.trim(), + eyebrow: form.eyebrow.trim(), + location_label: form.location_label.trim(), + facility_name: form.facility_name.trim(), + facility_slug: form.facility_slug.trim(), + author_name: form.author_name.trim(), + content_html: form.content_html, + source_url: form.source_url.trim(), + source_label: form.source_label.trim(), + published_at: form.published_at ? new Date(form.published_at).toISOString() : null, + hero_images: buildHeroImages(form.heroImageUrls, form.title.trim()), + }; + + const response = await adminFetch(`${API_URL}/admin/articles`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Kunne ikke lagre artikkelen" })); + throw new Error(error.detail || "Kunne ikke lagre artikkelen"); + } + + const savedArticle = (await response.json()) as AdminArticle; + await loadArticles(); + setSelectedArticleId(savedArticle.id); + setForm(articleToForm(savedArticle)); + setSlugTouched(true); + setFeedback("Artikkelen er lagret."); + } catch (error) { + setFeedback(error instanceof Error ? error.message : "Kunne ikke lagre artikkelen."); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (!selectedArticleId) return; + const confirmed = window.confirm("Vil du slette denne artikkelen?"); + if (!confirmed) return; + + setIsDeleting(true); + setFeedback(""); + try { + const response = await adminFetch(`${API_URL}/admin/articles/${selectedArticleId}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error("Kunne ikke slette artikkelen."); + } + await loadArticles(); + handleCreateNew(); + setFeedback("Artikkelen ble slettet."); + } catch (error) { + setFeedback(error instanceof Error ? error.message : "Kunne ikke slette artikkelen."); + } finally { + setIsDeleting(false); + } + }; + + const handleSeedImported = async () => { + setIsSeeding(true); + setFeedback(""); + try { + const response = await adminFetch(`${API_URL}/admin/articles/seed-imported`, { + method: "POST", + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: "Kunne ikke importere artiklene." })); + throw new Error(error.detail || "Kunne ikke importere artiklene."); + } + const result = (await response.json()) as { count?: number }; + await loadArticles(); + setFeedback(`Importerte eller oppdaterte ${result.count || 0} artikler fra importedMeninger.json.`); + } catch (error) { + setFeedback(error instanceof Error ? error.message : "Kunne ikke importere artiklene."); + } finally { + setIsSeeding(false); + } + }; + + return ( +
+
+
+
+ + ← Tilbake til admin + +

Artikler / Banebesøk

+

+ Første adminversjon for redaksjonelle artikler. Denne bruker nå Tiptap for innhold, + lagrer fortsatt HTML i databasen, og kan seedes fra de importerte Banebesøk-artiklene. +

+
+
+ + +
+
+ + {feedback ? ( +
+ {feedback} +
+ ) : null} + +
+ + +
+
+ + + + +
+ +
+ + +
+ +
+ + + +
+ +
+