diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..cafbd372087e6a6a45eb56dd5a739e1fe8bcbc29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Dependencies +node_modules/ +venv/ + +# Python +__pycache__/ +*.pyc +*.egg-info/ + +# Environment +.env + +# Data / generated files +saved_articles.json +benchmark_results/ + +# Next.js build output +.next/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..ec7114f5fc5515947e8c9b352216c6c6665a9e2f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TruthScan AI is a master's thesis full-stack application for real-time news credibility analysis. It fetches articles from RSS feeds, runs them through HuggingFace Transformer models (sentiment analysis + fake news detection), and presents results in a bilingual (PL/EN) Next.js dashboard. + +## Commands + +### Backend (`TruthScan AI_backend/`) + +```bash +# Install dependencies +cd "TruthScan AI_backend" +pip install -r requirements.txt + +# Start dev server (http://127.0.0.1:8000) +python -m uvicorn app.main:app + +# Run tests +python truthscan_test.py +``` + +Swagger UI available at `http://localhost:8000/docs`. + +### Frontend (`TruthScan AI_frontend/`) + +```bash +# Install dependencies +cd "TruthScan AI_frontend" +npm install + +# Start dev server (http://localhost:3000) +npm run dev + +# Build for production +npm run build + +# Lint +npm run lint +``` + +## Architecture + +### Data Flow + +``` +Browser (Next.js :3000) + → HTTP/SSE → FastAPI (:8000) + → RSS fetch → feedparser + BeautifulSoup + → NLP pipeline → HuggingFace Transformers + → JSON/SSE response → React UI +``` + +### Backend (`app/`) + +- **`config.py`** — All constants: 10 RSS sources (BBC, CNN, NYTimes, Guardian, AlJazeera, PolsatNews, etc.), CORS settings, sentiment label mappings (PL/EN), cache TTLs. +- **`nlp_service.py`** — Plug-in NLP pipeline: + - Abstract base class `ModelAdapter` with `analyze_sentiment()` and `analyze_fake_news()`. + - Three adapters: `RoBERTaAdapter` (en), `XLMRoBERTaAdapter` (pl/en/no), `NorBERTAdapter` (no). + - All adapters lazy-load their pipelines on first use. + - Global registry `_REGISTRY`; active adapter changed via `set_active_adapter(name)`. + - `register_adapter(adapter)` adds custom/fine-tuned checkpoints at runtime. + - Public functions `analyze_news()` / `analyze_news_batch()` preserve the original interface — `routes/news.py` requires no changes. + - Uses `ThreadPoolExecutor` (max 3 workers) for parallel batch processing. +- **`rss_utils.py`** — RSS fetching with 7s timeout, HTML stripping via BeautifulSoup, simple in-memory TTL cache. +- **`storage.py`** — Thread-safe JSON file persistence for saved articles (`saved_articles.json`). +- **`routes/news.py`** — Main endpoints: `GET /news/{source}` (5 articles with NLP), `GET /stream-news/{source}` (SSE streaming), `GET /emotion-stats/{source}`, `GET /charts-data`. +- **`routes/saved.py`** — CRUD for saved articles. +- **`routes/misc.py`** — `GET /sources` lists available RSS sources. + +### Frontend (`app/` + `components/` + `lib/`) + +- **Routing**: Next.js App Router — pages at `app/page.tsx` (home), `app/dashboard/page.tsx`, `app/saved/page.tsx`. +- **State**: Zustand store in `stores/newsCache.tsx` — caches articles per source/language with 5-min TTL, persisted to `localStorage` (key: `truthscan_news_cache_v1`). +- **API client**: `lib/fetchNews.ts` — `fetchOneSource()` and `fetchAllNews()` (concurrent, 3 workers default), plus `normalizeArticle()`. +- **SSE streaming**: `hooks/useNewsStream.ts` consumes `GET /stream-news/{source}`, tracks progress and collects articles, writes to Zustand cache on completion. +- **i18n**: `lib/locales.js` holds all PL/EN UI strings. Language state managed in `hooks/useLanguage.ts`, synced via `localStorage` and `app:langchange` custom event. +- **Charts**: Recharts via `hooks/useDashboardCharts.ts` and `hooks/useCachedEmotionStats.ts`. +- **PDF export**: `hooks/usePDFExport.ts` (jsPDF) + `components/PDFGenerator.tsx` (react-to-print). +- **Dark mode**: Tailwind `.dark` class toggle, CSS variables in `styles/globals.css`. + +## Master's Thesis Goals + +The thesis extends TruthScan AI by comparing NLP models across three languages: **Polish**, **Norwegian**, and **English**. Models under comparison: **XLM-RoBERTa**, **NorBERT 3**, **HerBERT**. + +Planned work items: + +1. **`nlp_service.py` plug-in architecture** — replace the hardcoded model with a swappable interface so each model (XLM-RoBERTa, NorBERT 3, HerBERT) can be loaded and hot-swapped without changing route logic. + +2. **Norwegian RSS sources in `config.py`** — add NRK, VG, and Dagbladet alongside the existing 10 sources. + +3. **`SENTIMENT_MAP` extension** — add a `"no"` (Norwegian) key to the sentiment label mapping in `config.py`, parallel to the existing `"pl"` and `"en"` keys. + +4. **Benchmarking module** — new module that measures **F1**, **accuracy**, and **inference time** per model/language combination and exposes results via a dedicated API endpoint (or offline report). + +5. **PostgreSQL migration** — replace `storage.py` / `saved_articles.json` with a PostgreSQL backend (SQLAlchemy or asyncpg). `storage.py` read/write interface should be preserved so routes need minimal changes. + +6. **Public deployment** — frontend on **Vercel**, backend + models on **Hugging Face Spaces**. + +### Environment + +- Backend API base URL: `process.env.NEXT_PUBLIC_API_URL` (defaults to `http://127.0.0.1:8000`). +- Backend CORS is open (`"*"`) — intentional for thesis/dev use. +- NLP models are downloaded automatically by HuggingFace on first run (can be slow). diff --git a/TruthScan AI_backend/app/__init__.py b/TruthScan AI_backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/TruthScan AI_backend/app/benchmark.py b/TruthScan AI_backend/app/benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..203ce3fbcb658b3a4cbb4837152748873373e8b1 --- /dev/null +++ b/TruthScan AI_backend/app/benchmark.py @@ -0,0 +1,240 @@ +""" +Moduł benchmarkowania modeli NLP. + +Mierzy czas inferencji, rozkład sentymentów i prawdopodobieństwo fake news +dla każdego adaptera (roberta, xlm-roberta, norbert) na próbce tekstów +z trzech grup językowych (en, pl, no). + +Użycie standalone: + python -m app.benchmark + +Użycie z API: + GET /benchmark + GET /benchmark?adapters=roberta,xlm-roberta&langs=en,pl +""" + +import csv +import json +import time +from collections import Counter +from pathlib import Path +from typing import Dict, List, Optional + +from .nlp_service import get_adapter, ModelAdapter, analyze_news + +# --------------------------------------------------------------------------- +# Próbka tekstów testowych +# --------------------------------------------------------------------------- + +SAMPLE_TEXTS: Dict[str, List[str]] = { + "en": [ + "The government announced new economic reforms to boost growth and reduce unemployment.", + "Flooding devastated coastal towns overnight, leaving thousands homeless.", + "Scientists discover a new vaccine that shows 95% efficacy against the virus.", + "Stock markets surged to record highs after positive inflation data.", + "A major scandal erupted as leaked documents exposed corporate corruption.", + "The peace talks collapsed after both sides failed to reach an agreement.", + "Renewable energy investments hit an all-time high this quarter.", + "Crime rates in the capital have dropped significantly over the past year.", + ], + "pl": [ + "Rząd ogłosił nowe reformy gospodarcze mające na celu pobudzenie wzrostu.", + "Powódź zniszczyła nadmorskie miejscowości, tysiące osób zostało bez dachu.", + "Naukowcy odkryli szczepionkę o 95-procentowej skuteczności przeciw wirusowi.", + "Giełda osiągnęła rekordowe poziomy po pozytywnych danych o inflacji.", + "Wybuchł wielki skandal po ujawnieniu dokumentów o korupcji korporacyjnej.", + "Rozmowy pokojowe załamały się po niepowodzeniu negocjacji.", + "Inwestycje w energię odnawialną osiągnęły historyczny rekord w tym kwartale.", + "Wskaźniki przestępczości w stolicy znacząco spadły w ciągu ostatniego roku.", + ], + "no": [ + "Regjeringen kunngjorde nye økonomiske reformer for å øke veksten.", + "Flom ødela kystbyer over natten og etterlot tusenvis uten hjem.", + "Forskere oppdaget en vaksine med 95 prosent effektivitet mot viruset.", + "Aksjemarkedene steg til rekordhøyder etter positive inflasjonsdata.", + "En stor skandale brøt ut da lekkede dokumenter avslørte korrupsjon.", + "Fredssamtalene brøt sammen etter at begge sider ikke klarte å bli enige.", + "Investeringer i fornybar energi nådde en historisk topp dette kvartalet.", + "Kriminalitetsratene i hovedstaden har falt betydelig det siste året.", + ], +} + +# --------------------------------------------------------------------------- +# Typy wyników +# --------------------------------------------------------------------------- + +BenchmarkResult = Dict # TypedDict zastąpiony zwykłym Dict dla czytelności + +# --------------------------------------------------------------------------- +# Funkcje benchmarkowania +# --------------------------------------------------------------------------- + +def _run_single( + adapter: ModelAdapter, + text: str, + lang: str, +) -> Dict: + """Uruchamia analyze_news dla jednego tekstu i mierzy czas.""" + start = time.perf_counter() + result = analyze_news(text, lang=lang, adapter=adapter) + elapsed_ms = (time.perf_counter() - start) * 1000 + return {**result, "inference_time_ms": elapsed_ms} + + +def run_benchmark( + adapter_names: Optional[List[str]] = None, + langs: Optional[List[str]] = None, +) -> List[BenchmarkResult]: + """ + Uruchamia benchmark dla wskazanych adapterów i języków. + + Args: + adapter_names: Lista nazw adapterów; None = wszystkie trzy. + langs: Lista kodów języków; None = ['en', 'pl', 'no']. + + Returns: + Lista słowników z wynikami — jeden wpis na kombinację adapter × język. + """ + if adapter_names is None: + adapter_names = ["roberta", "xlm-roberta", "norbert"] + if langs is None: + langs = ["en", "pl", "no"] + + results: List[BenchmarkResult] = [] + + for adapter_name in adapter_names: + try: + adapter = get_adapter(adapter_name) + except ValueError as exc: + results.append({ + "adapter_name": adapter_name, + "error": str(exc), + }) + continue + + for lang in langs: + texts = SAMPLE_TEXTS.get(lang, []) + if not texts: + continue + + per_text: List[Dict] = [] + for text in texts: + try: + per_text.append(_run_single(adapter, text, lang)) + except Exception as exc: + per_text.append({ + "sentiment": None, + "fake_probability": None, + "sentiment_score": None, + "inference_time_ms": None, + "error": str(exc), + }) + + # Agregacja + valid = [r for r in per_text if r.get("inference_time_ms") is not None] + times = [r["inference_time_ms"] for r in valid] + fakes = [r["fake_probability"] for r in valid if r.get("fake_probability") is not None] + sentiments = [r["sentiment"] for r in valid if r.get("sentiment")] + + results.append({ + "adapter_name": adapter_name, + "language": lang, + "sample_size": len(texts), + "successful_runs": len(valid), + "avg_inference_time_ms": round(sum(times) / len(times), 2) if times else None, + "min_inference_time_ms": round(min(times), 2) if times else None, + "max_inference_time_ms": round(max(times), 2) if times else None, + "avg_fake_probability": round(sum(fakes) / len(fakes), 2) if fakes else None, + "sentiments_distribution": dict(Counter(sentiments)), + "per_text": per_text, + }) + + return results + + +# --------------------------------------------------------------------------- +# Eksport wyników +# --------------------------------------------------------------------------- + +def export_json(results: List[BenchmarkResult], path: Path) -> None: + """Zapisuje pełne wyniki (z per_text) do pliku JSON.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(results, fh, ensure_ascii=False, indent=2) + + +def export_csv(results: List[BenchmarkResult], path: Path) -> None: + """ + Zapisuje wyniki zbiorcze (bez per_text) do pliku CSV. + Jeden wiersz = jedna kombinacja adapter × język. + """ + path.parent.mkdir(parents=True, exist_ok=True) + summary_fields = [ + "adapter_name", "language", "sample_size", "successful_runs", + "avg_inference_time_ms", "min_inference_time_ms", "max_inference_time_ms", + "avg_fake_probability", "sentiments_distribution", + ] + with open(path, "w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=summary_fields, extrasaction="ignore") + writer.writeheader() + for row in results: + if "error" in row: + continue + flat = {k: row.get(k) for k in summary_fields} + # Rozkład sentymentów jako string JSON w komórce CSV + flat["sentiments_distribution"] = json.dumps( + row.get("sentiments_distribution", {}), ensure_ascii=False + ) + writer.writerow(flat) + + +def _summary_only(results: List[BenchmarkResult]) -> List[BenchmarkResult]: + """Zwraca wyniki bez pola per_text (lżejsza odpowiedź HTTP).""" + return [{k: v for k, v in r.items() if k != "per_text"} for r in results] + + +# --------------------------------------------------------------------------- +# Uruchomienie standalone +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="TruthScan NLP benchmark") + parser.add_argument( + "--adapters", default="roberta,xlm-roberta,norbert", + help="Przecinkowa lista adapterów (domyślnie: wszystkie)", + ) + parser.add_argument( + "--langs", default="en,pl,no", + help="Przecinkowa lista języków (domyślnie: en,pl,no)", + ) + parser.add_argument( + "--out-dir", default="benchmark_results", + help="Katalog wyjściowy dla plików JSON i CSV", + ) + args = parser.parse_args() + + adapter_names = [a.strip() for a in args.adapters.split(",")] + langs = [l.strip() for l in args.langs.split(",")] + out_dir = Path(args.out_dir) + + print(f"Uruchamiam benchmark: adaptery={adapter_names}, języki={langs}") + results = run_benchmark(adapter_names=adapter_names, langs=langs) + + json_path = out_dir / "benchmark.json" + csv_path = out_dir / "benchmark.csv" + export_json(results, json_path) + export_csv(results, csv_path) + + print(f"Wyniki zapisane: {json_path}, {csv_path}") + for r in _summary_only(results): + if "error" in r: + print(f" [{r['adapter_name']}] BŁĄD: {r['error']}") + else: + print( + f" [{r['adapter_name']:12s} / {r['language']}] " + f"avg={r['avg_inference_time_ms']} ms " + f"fake={r['avg_fake_probability']}% " + f"sentiments={r['sentiments_distribution']}" + ) diff --git a/TruthScan AI_backend/app/config.py b/TruthScan AI_backend/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..218f634f6a89c5bb594f6fa64bcf2db6f6d67c8b --- /dev/null +++ b/TruthScan AI_backend/app/config.py @@ -0,0 +1,50 @@ +""" +Centralna konfiguracja aplikacji oraz stałe wykorzystywane w wielu modułach. +""" + +import os +from pathlib import Path + +# Konfiguracja CORS +CORS_ALLOW_ORIGINS = ["*"] +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_METHODS = ["*"] +CORS_ALLOW_HEADERS = ["*"] + +# Lista obsługiwanych źródeł RSS +NEWS_FEEDS = { + # Angielskie + "BBC": "https://feeds.bbci.co.uk/news/rss.xml", + "CNN": "http://rss.cnn.com/rss/edition.rss", + "NYTimes": "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml", + "Guardian": "https://www.theguardian.com/world/rss", + "AlJazeera": "https://www.aljazeera.com/xml/rss/all.xml", + # Polskie + "Money": "https://www.money.pl/rss/", + "PolsatNews": "https://www.polsatnews.pl/rss/wszystkie.xml", + "GazetaPrawna": "https://www.gazetaprawna.pl/rss.xml", + "SpidersWeb": "https://spidersweb.pl/feed", + "Bankier": "https://www.bankier.pl/rss/wiadomosci.xml", + # Norweskie + "NRK": "https://www.nrk.no/toppsaker.rss", + "VG": "https://www.vg.no/rss/feed/?limit=10", + "Dagbladet": "https://www.dagbladet.no/rss", + "Aftenposten": "https://www.aftenposten.no/rss", +} + +# Mapowanie wyników analizy sentymentu na etykiety językowe +SENTIMENT_MAP = { + "negative": {"pl": "Negatywne", "en": "Negative", "no": "Negativt"}, + "neutral": {"pl": "Neutralne", "en": "Neutral", "no": "Nøytralt"}, + "positive": {"pl": "Pozytywne", "en": "Positive", "no": "Positivt"}, +} + +# Ścieżka do pliku z zapisanymi artykułami +SAVED_FILE = Path("saved_articles.json") + +# Czas życia cache (sekundy) +CACHE_TTL_SECONDS = 120 + +# Konfiguracja Redis (jeśli używany jako backend cache) +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") +CACHE_TTL = 300 diff --git a/TruthScan AI_backend/app/main.py b/TruthScan AI_backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..59a8a3f418099c9fe229569acec4a3fa423c1719 --- /dev/null +++ b/TruthScan AI_backend/app/main.py @@ -0,0 +1,57 @@ +""" +Główna konfiguracja i uruchomienie aplikacji FastAPI. +""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend + +# Konfiguracja CORS (źródła, metody, nagłówki itp.) +from .config import ( + CORS_ALLOW_ORIGINS, + CORS_ALLOW_CREDENTIALS, + CORS_ALLOW_METHODS, + CORS_ALLOW_HEADERS +) + +# Routery aplikacji +from .routes import misc, news, saved + +# Inicjalizacja aplikacji FastAPI +app = FastAPI() + +# Middleware CORS – umożliwia dostęp do API z innych domen +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ALLOW_ORIGINS, + allow_credentials=CORS_ALLOW_CREDENTIALS, + allow_methods=CORS_ALLOW_METHODS, + allow_headers=CORS_ALLOW_HEADERS, +) + +# Logika wykonywana przy starcie aplikacji +@app.on_event("startup") +async def startup(): + # Inicjalizacja cache w pamięci (np. do cache’owania newsów) + FastAPICache.init(InMemoryBackend(), prefix="news-cache") + print("✓ Cache initialized (5 minut TTL)") + +# Rejestracja routerów +app.include_router(misc.router) +app.include_router(news.router) +app.include_router(saved.router) + +# Endpoint główny – informacja o stanie API +@app.get("/") +async def root(): + return { + "message": "ThruScan API", + "status": "running", + "cached": True + } + +# Endpoint zdrowia – używany np. przez monitoring / load balancer +@app.get("/health") +async def health_check(): + return {"status": "healthy"} diff --git a/TruthScan AI_backend/app/models.py b/TruthScan AI_backend/app/models.py new file mode 100644 index 0000000000000000000000000000000000000000..dd3ce29d5ebc2b2c474b837717d899f03513809f --- /dev/null +++ b/TruthScan AI_backend/app/models.py @@ -0,0 +1,16 @@ +""" +Modele danych wykorzystywane do walidacji i serializacji artykułów. +""" + +from pydantic import BaseModel + + +class Article(BaseModel): + # Model artykułu wykorzystywany w komunikacji API + title: str + link: str + summary: str + published: str + sentiment: str + fake_probability: float + source: str diff --git a/TruthScan AI_backend/app/nlp_service.py b/TruthScan AI_backend/app/nlp_service.py new file mode 100644 index 0000000000000000000000000000000000000000..894250cb3a22c8184f454b7a66f49942f04acf21 --- /dev/null +++ b/TruthScan AI_backend/app/nlp_service.py @@ -0,0 +1,459 @@ +""" +Serwis NLP z architekturą plug-in. + +Każdy model jest reprezentowany przez adapter dziedziczący z ModelAdapter. +Publiczne API (analyze_news, analyze_news_batch) pozostaje niezmienione, +więc routes/news.py nie wymaga modyfikacji. +""" + +from abc import ABC, abstractmethod +from typing import Dict, Any, List, Optional +from concurrent.futures import ThreadPoolExecutor + +from transformers import pipeline + +from .config import SENTIMENT_MAP + + +# --------------------------------------------------------------------------- +# Klasa bazowa +# --------------------------------------------------------------------------- + +class ModelAdapter(ABC): + """ + Abstrakcyjny adapter modelu NLP. + + Każdy konkretny adapter musi zaimplementować: + - analyze_sentiment(text) -> {"label": str, "score": float} + - analyze_fake_news(text) -> {"labels": List[str], "scores": List[float]} + """ + + @property + @abstractmethod + def name(self) -> str: + """Unikalny identyfikator adaptera (np. 'roberta', 'xlm-roberta').""" + ... + + @property + @abstractmethod + def supported_languages(self) -> List[str]: + """Kody języków obsługiwanych przez model (np. ['pl', 'en', 'no']).""" + ... + + @abstractmethod + def analyze_sentiment(self, text: str) -> Dict[str, Any]: + """ + Analizuje sentyment tekstu. + + Zwraca: + {"label": str, "score": float} + gdzie label to jedna z wartości: 'positive' | 'negative' | 'neutral' + """ + ... + + @abstractmethod + def analyze_fake_news(self, text: str) -> Dict[str, Any]: + """ + Klasyfikuje tekst jako real/fake. + + Zwraca: + {"labels": List[str], "scores": List[float]} + """ + ... + + +# --------------------------------------------------------------------------- +# Adaptery +# --------------------------------------------------------------------------- + +def _normalize_sentiment_label(raw_label: str) -> str: + """ + Normalizuje etykietę sentymentu z modelu do jednego z trzech wariantów: + 'positive' | 'negative' | 'neutral'. + + Obsługuje różne konwencje nazewnictwa stosowane przez modele HuggingFace. + """ + label = (raw_label or "").strip().lower() + + _POSITIVE = {"positive", "pos", "label_2", "2", "very positive"} + _NEGATIVE = {"negative", "neg", "label_0", "0", "very negative"} + + if label in _POSITIVE or label.startswith("pos"): + return "positive" + if label in _NEGATIVE or label.startswith("neg"): + return "negative" + return "neutral" + + +class RoBERTaAdapter(ModelAdapter): + """ + Adapter dla modeli anglojęzycznych (domyślny, zachowuje obecne zachowanie): + - sentyment : cardiffnlp/twitter-roberta-base-sentiment-latest + - fake news : facebook/bart-large-mnli (zero-shot) + """ + + def __init__(self) -> None: + self._sentiment_pipe = None + self._fake_pipe = None + + def _load(self) -> None: + if self._sentiment_pipe is None: + self._sentiment_pipe = pipeline( + "text-classification", + model="cardiffnlp/twitter-roberta-base-sentiment-latest", + return_all_scores=False, + ) + if self._fake_pipe is None: + self._fake_pipe = pipeline( + "zero-shot-classification", + model="facebook/bart-large-mnli", + ) + + @property + def name(self) -> str: + return "roberta" + + @property + def supported_languages(self) -> List[str]: + return ["en"] + + def analyze_sentiment(self, text: str) -> Dict[str, Any]: + self._load() + result = self._sentiment_pipe(text)[0] + return { + "label": _normalize_sentiment_label(result.get("label", "")), + "score": float(result.get("score", 0.0)), + } + + def analyze_fake_news(self, text: str) -> Dict[str, Any]: + self._load() + result = self._fake_pipe(text, candidate_labels=["real", "fake"]) + return {"labels": result["labels"], "scores": result["scores"]} + + +class XLMRoBERTaAdapter(ModelAdapter): + """ + Adapter wielojęzyczny (pl, en, no): + - sentyment : cardiffnlp/twitter-xlm-roberta-base-sentiment + - fake news : facebook/bart-large-mnli (zero-shot, transfer między językami) + """ + + def __init__(self) -> None: + self._sentiment_pipe = None + self._fake_pipe = None + + def _load(self) -> None: + if self._sentiment_pipe is None: + self._sentiment_pipe = pipeline( + "text-classification", + model="cardiffnlp/twitter-xlm-roberta-base-sentiment", + return_all_scores=False, + ) + if self._fake_pipe is None: + self._fake_pipe = pipeline( + "zero-shot-classification", + model="facebook/bart-large-mnli", + ) + + @property + def name(self) -> str: + return "xlm-roberta" + + @property + def supported_languages(self) -> List[str]: + return ["pl", "en", "no"] + + def analyze_sentiment(self, text: str) -> Dict[str, Any]: + self._load() + result = self._sentiment_pipe(text)[0] + return { + "label": _normalize_sentiment_label(result.get("label", "")), + "score": float(result.get("score", 0.0)), + } + + def analyze_fake_news(self, text: str) -> Dict[str, Any]: + self._load() + result = self._fake_pipe(text, candidate_labels=["real", "fake"]) + return {"labels": result["labels"], "scores": result["scores"]} + + +class HerBERTAdapter(ModelAdapter): + """ + Adapter dla języka polskiego (HerBERT): + - sentyment : allegro/herbert-base-cased + UWAGA: to model bazowy – wymaga fine-tuningu na zbiorze sentymentu + (np. PolEmo 2.0). Aby podmienić checkpoint, przekaż sentiment_model + w konstruktorze lub zmień SENTIMENT_MODEL przed pierwszym użyciem. + - fake news : facebook/bart-large-mnli (zero-shot, transfer EN→PL) + """ + + SENTIMENT_MODEL: str = "allegro/herbert-base-cased" + + def __init__(self, sentiment_model: Optional[str] = None) -> None: + self._sentiment_model_id = sentiment_model or self.SENTIMENT_MODEL + self._sentiment_pipe = None + self._fake_pipe = None + + def _load(self) -> None: + if self._sentiment_pipe is None: + self._sentiment_pipe = pipeline( + "text-classification", + model=self._sentiment_model_id, + return_all_scores=False, + ) + if self._fake_pipe is None: + self._fake_pipe = pipeline( + "zero-shot-classification", + model="facebook/bart-large-mnli", + ) + + @property + def name(self) -> str: + return "herbert" + + @property + def supported_languages(self) -> List[str]: + return ["pl"] + + def analyze_sentiment(self, text: str) -> Dict[str, Any]: + self._load() + result = self._sentiment_pipe(text)[0] + return { + "label": _normalize_sentiment_label(result.get("label", "")), + "score": float(result.get("score", 0.0)), + } + + def analyze_fake_news(self, text: str) -> Dict[str, Any]: + self._load() + result = self._fake_pipe(text, candidate_labels=["real", "fake"]) + return {"labels": result["labels"], "scores": result["scores"]} + + +class NorBERTAdapter(ModelAdapter): + """ + Adapter dla języka norweskiego (NorBERT 3): + - sentyment : ltgoslo/norbert3-base + UWAGA: to model bazowy – wymaga fine-tuningu na zbiorze sentymentu + (np. NoReC). Aby podmienić checkpoint, przekaż sentiment_model + w konstruktorze lub zmień SENTIMENT_MODEL przed pierwszym użyciem. + - fake news : facebook/bart-large-mnli (zero-shot, transfer EN→NO) + """ + + SENTIMENT_MODEL: str = "ltgoslo/norbert3-base" + + def __init__(self, sentiment_model: Optional[str] = None) -> None: + self._sentiment_model_id = sentiment_model or self.SENTIMENT_MODEL + self._sentiment_pipe = None + self._fake_pipe = None + + def _load(self) -> None: + if self._sentiment_pipe is None: + self._sentiment_pipe = pipeline( + "text-classification", + model=self._sentiment_model_id, + return_all_scores=False, + ) + if self._fake_pipe is None: + self._fake_pipe = pipeline( + "zero-shot-classification", + model="facebook/bart-large-mnli", + ) + + @property + def name(self) -> str: + return "norbert" + + @property + def supported_languages(self) -> List[str]: + return ["no"] + + def analyze_sentiment(self, text: str) -> Dict[str, Any]: + self._load() + result = self._sentiment_pipe(text)[0] + return { + "label": _normalize_sentiment_label(result.get("label", "")), + "score": float(result.get("score", 0.0)), + } + + def analyze_fake_news(self, text: str) -> Dict[str, Any]: + self._load() + result = self._fake_pipe(text, candidate_labels=["real", "fake"]) + return {"labels": result["labels"], "scores": result["scores"]} + + +# --------------------------------------------------------------------------- +# Rejestr adapterów — lazy initialization +# --------------------------------------------------------------------------- + +# Przy imporcie pusty — żaden adapter nie jest tworzony ani ładowany. +# Adaptery są instancjonowane dopiero przy pierwszym wywołaniu get_adapter(). +_REGISTRY: Dict[str, ModelAdapter] = {} + +# Adapter aktywny globalnie; None oznacza „jeszcze nie wybrano". +_active_adapter: Optional[ModelAdapter] = None + +# Executor do równoległej analizy (ograniczenie obciążenia CPU) +executor = ThreadPoolExecutor(max_workers=3) + +# Zbiór nazw wbudowanych adapterów — służy do walidacji przed inicjalizacją +_BUILTIN_ADAPTERS = {"roberta", "xlm-roberta", "herbert", "norbert"} + + +def _init_registry() -> None: + """ + Tworzy wbudowane adaptery i wpisuje je do rejestru. + + Wywoływana leniwie przy pierwszym get_adapter() lub set_active_adapter(). + Kolejne wywołania są bezoperacyjne (idempotentna). + """ + if _REGISTRY: + return + _REGISTRY["roberta"] = RoBERTaAdapter() + _REGISTRY["xlm-roberta"] = XLMRoBERTaAdapter() + _REGISTRY["herbert"] = HerBERTAdapter() + _REGISTRY["norbert"] = NorBERTAdapter() + + +def get_adapter(name: str) -> ModelAdapter: + """ + Zwraca adapter o podanej nazwie. + + Przy pierwszym wywołaniu inicjalizuje rejestr (bez ładowania wag modeli). + Rzuca ValueError dla nieznanej nazwy. + """ + _init_registry() + if name not in _REGISTRY: + raise ValueError( + f"Nieznany adapter: '{name}'. Dostępne: {list(_REGISTRY.keys())}" + ) + return _REGISTRY[name] + + +def set_active_adapter(name: str) -> None: + """ + Ustawia aktywny adapter dla całej aplikacji. + + Przykład: + from app.nlp_service import set_active_adapter + set_active_adapter("xlm-roberta") + """ + global _active_adapter + _active_adapter = get_adapter(name) + + +def register_adapter(adapter: ModelAdapter) -> None: + """ + Rejestruje nowy adapter (np. fine-tuned checkpoint) pod jego nazwą. + Może być wywołane przed lub po _init_registry(). + + Przykład: + norbert_ft = NorBERTAdapter(sentiment_model="user/norbert3-norec") + register_adapter(norbert_ft) + set_active_adapter("norbert") + """ + _init_registry() + _REGISTRY[adapter.name] = adapter + + +def _get_active_adapter() -> ModelAdapter: + """ + Zwraca aktualnie aktywny adapter. + Jeśli nie ustawiono, domyślnie inicjalizuje i zwraca RoBERTaAdapter. + """ + global _active_adapter + if _active_adapter is None: + _active_adapter = get_adapter("roberta") + return _active_adapter + + +# --------------------------------------------------------------------------- +# Publiczne API – interfejs niezmieniony względem poprzedniej wersji +# --------------------------------------------------------------------------- + +def analyze_news( + text: str, + lang: str = "pl", + adapter: Optional[ModelAdapter] = None, +) -> dict: + """ + Analizuje pojedynczy tekst pod kątem sentymentu i fake news. + + Args: + text: Tekst do analizy. + lang: Kod języka wynikowych etykiet ('pl' | 'en' | 'no'). + adapter: Opcjonalny adapter; jeśli None, używa _active_adapter. + + Returns: + {"sentiment": str, "fake_probability": float, "sentiment_score": float} + """ + _neutral = SENTIMENT_MAP["neutral"].get(lang, "Neutral") + + if not text or len(text.strip()) < 10: + return {"sentiment": _neutral, "fake_probability": 0.0, "sentiment_score": 0.0} + + _adapter = adapter or _get_active_adapter() + + try: + sentiment_result = _adapter.analyze_sentiment(text) + fake_result = _adapter.analyze_fake_news(text) + except Exception: + return {"sentiment": _neutral, "fake_probability": 0.0, "sentiment_score": 0.0} + + label = sentiment_result.get("label", "neutral") + sentiment_translated = SENTIMENT_MAP.get(label, {}).get(lang, _neutral) + + fake_score = 0.0 + for lbl, score in zip(fake_result["labels"], fake_result["scores"]): + if lbl == "fake": + fake_score = score + break + + return { + "sentiment": sentiment_translated, + "fake_probability": round(fake_score * 100, 2), + "sentiment_score": round(float(sentiment_result.get("score", 0.0)), 2), + } + + +def analyze_news_batch( + texts: List[str], + lang: str = "pl", + adapter: Optional[ModelAdapter] = None, +) -> List[Dict[str, Any]]: + """ + Analiza wielu tekstów w trybie batch z użyciem ThreadPoolExecutor. + + Args: + texts: Lista tekstów do analizy. + lang: Kod języka wynikowych etykiet. + adapter: Opcjonalny adapter; jeśli None, używa _active_adapter. + """ + if not texts: + return [] + + _adapter = adapter or _get_active_adapter() + results: List[Dict[str, Any]] = [] + batch_size = 3 + + batches = [texts[i:i + batch_size] for i in range(0, len(texts), batch_size)] + + for batch in batches: + futures = [ + executor.submit(analyze_news, text, lang, _adapter) + for text in batch + ] + for future in futures: + try: + results.append(future.result()) + except Exception: + _neutral = SENTIMENT_MAP["neutral"].get(lang, "Neutral") + results.append( + {"sentiment": _neutral, "fake_probability": 0.0, "sentiment_score": 0.0} + ) + + return results + + +def analyze_news_single(text: str, lang: str) -> Dict[str, Any]: + """Wrapper dla analizy pojedynczego tekstu (używany w batch processing).""" + return analyze_news(text, lang) diff --git a/TruthScan AI_backend/app/routes/__init__.py b/TruthScan AI_backend/app/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/TruthScan AI_backend/app/routes/misc.py b/TruthScan AI_backend/app/routes/misc.py new file mode 100644 index 0000000000000000000000000000000000000000..4a6f7e63981c0bd3e06dc8d9310e2e37d9d53f70 --- /dev/null +++ b/TruthScan AI_backend/app/routes/misc.py @@ -0,0 +1,68 @@ +""" +Endpointy związane z obsługą dostępnych źródeł wiadomości oraz narzędziami deweloperskimi. +""" + +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Query + +from ..config import NEWS_FEEDS +from ..benchmark import run_benchmark, export_json, export_csv, _summary_only + +router = APIRouter() + +# Katalog, do którego endpoint zapisuje artefakty benchmarku +_BENCHMARK_OUT = Path("benchmark_results") + + +@router.get("/sources") +def get_sources(): + return list(NEWS_FEEDS.keys()) + + +@router.get("/benchmark") +def benchmark( + adapters: Optional[str] = Query( + default=None, + description="Przecinkowa lista adapterów: roberta,xlm-roberta,norbert", + ), + langs: Optional[str] = Query( + default=None, + description="Przecinkowa lista języków: en,pl,no", + ), + save: bool = Query( + default=True, + description="Czy zapisać wyniki do JSON i CSV w katalogu benchmark_results/", + ), + full: bool = Query( + default=False, + description="Czy zwrócić szczegółowe wyniki per_text (domyślnie tylko podsumowanie)", + ), +): + """ + Uruchamia benchmark NLP i zwraca wyniki. + + Czas odpowiedzi zależy od liczby adapterów i języków — może wynosić kilkadziesiąt sekund + przy pierwszym uruchomieniu (lazy-loading modeli HuggingFace). + + Przykłady: + - GET /benchmark + - GET /benchmark?adapters=roberta,xlm-roberta&langs=en,pl + - GET /benchmark?full=true&save=false + """ + adapter_names = [a.strip() for a in adapters.split(",")] if adapters else None + lang_list = [l.strip() for l in langs.split(",")] if langs else None + + results = run_benchmark(adapter_names=adapter_names, langs=lang_list) + + if save: + export_json(results, _BENCHMARK_OUT / "benchmark.json") + export_csv(results, _BENCHMARK_OUT / "benchmark.csv") + + return { + "results": results if full else _summary_only(results), + "saved": save, + "output_dir": str(_BENCHMARK_OUT) if save else None, + } + diff --git a/TruthScan AI_backend/app/routes/news.py b/TruthScan AI_backend/app/routes/news.py new file mode 100644 index 0000000000000000000000000000000000000000..88bdb0d894f45855ab4283abbb779253cec7bc41 --- /dev/null +++ b/TruthScan AI_backend/app/routes/news.py @@ -0,0 +1,234 @@ +""" +Endpointy związane z pobieraniem, analizą i strumieniowaniem wiadomości. +""" + +import asyncio +import json +from typing import List + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from fastapi_cache.decorator import cache + +from ..config import CACHE_TTL, NEWS_FEEDS +from ..rss_utils import fetch_feed, clean_html, get_from_cache, set_to_cache +from ..nlp_service import analyze_news + +router = APIRouter() + + +@router.get("/news/{source}") +def get_news(source: str, lang: str = "pl"): + # Walidacja źródła + if source not in NEWS_FEEDS: + raise HTTPException(status_code=404, detail="Źródło nieobsługiwane") + + url = NEWS_FEEDS[source] + + # Pobranie RSS + try: + feed = fetch_feed(url) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Błąd pobierania newsów: {str(e)}") + + if not feed.entries: + return {"source": source, "articles": []} + + MAX_ARTICLES = 5 + articles: List[dict] = [] + + # Przetwarzanie i analiza artykułów + for entry in feed.entries[:MAX_ARTICLES]: + summary = clean_html(entry.get("summary", "Brak opisu")) + text_to_analyze = (summary or "").strip() or entry.get("title", "") + analysis = analyze_news(text_to_analyze, lang) + + articles.append({ + "title": entry.get("title", "Bez tytułu"), + "link": entry.get("link", ""), + "summary": summary, + "published": entry.get("published", "Brak daty"), + "source": source, + **analysis + }) + + return {"source": source, "articles": articles} + + +# Strumieniowanie newsów przez SSE +@router.get("/stream-news/{source}") +async def stream_news(source: str, lang: str = "pl"): + if source not in NEWS_FEEDS: + raise HTTPException(status_code=404, detail="Źródło nieobsługiwane") + + async def event_generator(): + # Próba pobrania RSS z cache + try: + cached = get_from_cache(source) + if cached: + feed = cached + else: + feed = fetch_feed(NEWS_FEEDS[source]) + set_to_cache(source, feed) + except Exception as e: + yield f"event: backend_error\ndata: {json.dumps({'message': str(e)})}\n\n" + yield f"event: done\ndata: {json.dumps({'count': 0})}\n\n" + return + + entries = feed.entries or [] + MAX_ARTICLES = 5 + to_send = entries[:MAX_ARTICLES] + + # Metadane dla klienta + yield f"event: meta\ndata: {json.dumps({'total': len(to_send)})}\n\n" + + sent = 0 + for entry in to_send: + summary = clean_html(entry.get("summary", "Brak opisu")) + text_to_analyze = (summary or "").strip() or entry.get("title", "") + + # Analiza NLP uruchamiana w executorze (CPU-bound) + loop = asyncio.get_event_loop() + analysis = await loop.run_in_executor( + None, lambda: analyze_news(text_to_analyze, lang) + ) + + article = { + "title": entry.get("title", "Bez tytułu"), + "link": entry.get("link", ""), + "summary": summary, + "published": entry.get("published", "Brak daty"), + "source": source, + **analysis, + } + + yield f"data: {json.dumps(article, ensure_ascii=False)}\n\n" + sent += 1 + await asyncio.sleep(0.3) + + yield f"event: done\ndata: {json.dumps({'count': sent})}\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") + + +@router.get("/api/charts/summary") +@cache(expire=CACHE_TTL) +async def get_charts_summary(lang: str = "pl"): + """ + Szybkie statystyki zbiorcze dla wszystkich źródeł + (uproszczona analiza oparta na tytułach). + """ + sources = [ + "BBC", "CNN", "NYTimes", "Guardian", "AlJazeera", + "PolsatNews", "Money", "Bankier", "SpidersWeb", "GazetaPrawna" + ] + + summary_data = {} + + for source in sources: + if source not in NEWS_FEEDS: + continue + + try: + feed = fetch_feed(NEWS_FEEDS[source]) + + if not feed.entries: + summary_data[source] = { + "count": 0, + "emotions": {"Pozytywne": 0, "Negatywne": 0, "Neutralne": 0} + } + continue + + # Analiza tylko kilku tytułów (szybko) + articles = feed.entries[:3] + emotion_counts = {"Pozytywne": 0, "Negatywne": 0, "Neutralne": 0} + + for entry in articles: + title = entry.get("title", "").lower() + if any(word in title for word in ["good", "positive", "gain", "up", "success", "dobry", "wzrost", "zysk"]): + emotion_counts["Pozytywne"] += 1 + elif any(word in title for word in ["bad", "negative", "fall", "down", "loss", "crisis", "zły", "spadek", "kryzys"]): + emotion_counts["Negatywne"] += 1 + else: + emotion_counts["Neutralne"] += 1 + + summary_data[source] = { + "count": len(feed.entries), + "analyzed": len(articles), + "emotions": emotion_counts, + "latest_title": articles[0].get("title", "")[:50] if articles else "" + } + + except Exception: + summary_data[source] = { + "count": 0, + "emotions": {"Pozytywne": 0, "Negatywne": 0, "Neutralne": 0} + } + + return { + "summary": summary_data, + "total_sources": len(summary_data), + "cache_ttl": CACHE_TTL + } + + +@router.get("/emotion-stats/{source}") +@cache(expire=CACHE_TTL) +def get_emotion_stats(source: str, lang: str = "pl"): + # Statystyki emocji dla jednego źródła + if source not in NEWS_FEEDS: + raise HTTPException(status_code=404, detail="Źródło nieobsługiwane") + + try: + news_data = get_news(source, lang) + articles = news_data.get("articles", []) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Błąd generowania statystyk: {str(e)}") + + emotion_counts = {"Pozytywne": 0, "Negatywne": 0, "Neutralne": 0} + + for article in articles: + sentiment = article.get("sentiment", "Neutralne") + if sentiment in emotion_counts: + emotion_counts[sentiment] += 1 + + total = len(articles) + emotion_percentages = { + emotion: (round((count / total) * 100, 2) if total > 0 else 0) + for emotion, count in emotion_counts.items() + } + + return { + "source": source, + "total_articles": total, + "emotion_counts": emotion_counts, + "emotion_percentages": emotion_percentages + } + + +@router.get("/charts-data") +@cache(expire=CACHE_TTL) +async def get_all_charts_data(lang: str = "pl"): + """ + Kompatybilność ze starszym frontendem – agreguje dane ze wszystkich źródeł. + """ + sources = [ + "BBC", "CNN", "NYTimes", "Guardian", "AlJazeera", + "PolsatNews", "Money", "Bankier", "SpidersWeb", "GazetaPrawna" + ] + + async def fetch_stats(source): + try: + return {source: await get_emotion_stats(source, lang)} + except Exception: + return {source: None} + + tasks = [fetch_stats(source) for source in sources] + results = await asyncio.gather(*tasks, return_exceptions=True) + + all_data = {} + for result in results: + if isinstance(result, dict): + all_data.update(result) + + return {"charts": all_data} diff --git a/TruthScan AI_backend/app/routes/saved.py b/TruthScan AI_backend/app/routes/saved.py new file mode 100644 index 0000000000000000000000000000000000000000..626f164028f5f50d023be04e8e25f18732ca6b97 --- /dev/null +++ b/TruthScan AI_backend/app/routes/saved.py @@ -0,0 +1,34 @@ +""" +Endpointy związane z zapisywaniem i zarządzaniem zapisanymi artykułami. +""" + +from fastapi import APIRouter, HTTPException, Request + +from ..storage import read_all, append_article, delete_by_title +from ..models import Article + +router = APIRouter() + + +@router.get("/saved-articles") +def get_saved_articles(): + # Zwraca listę wszystkich zapisanych artykułów + return read_all() + + +@router.post("/save-article") +def save_article(article: Article): + # Zapisuje nowy artykuł do magazynu danych + return append_article(article.dict()) + + +@router.delete("/delete-article") +async def delete_article(request: Request): + # Usuwa artykuł na podstawie tytułu przekazanego w body requestu + data = await request.json() + title = data.get("title") + + if not title: + raise HTTPException(status_code=400, detail="Brak pola 'title'") + + return delete_by_title(title) diff --git a/TruthScan AI_backend/app/rss_utils.py b/TruthScan AI_backend/app/rss_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..3e9bcc7ee09e74198cc9cbfc3a057a306c2a2b9e --- /dev/null +++ b/TruthScan AI_backend/app/rss_utils.py @@ -0,0 +1,47 @@ +""" +Narzędzia pomocnicze do pobierania i przetwarzania kanałów RSS oraz cache w pamięci. +""" + +import time +import requests +import feedparser +from bs4 import BeautifulSoup +from typing import Dict, Any + +from .config import CACHE_TTL_SECONDS + +# Prosty cache w pamięci (key -> (value, timestamp)) +_cache_data: Dict[str, tuple[Any, float]] = {} + + +def clean_html(text: str) -> str: + # Usuwa znaczniki HTML z treści RSS + return BeautifulSoup(text or "", "html.parser").get_text() + + +def get_from_cache(key: str): + # Pobiera dane z cache, jeśli nie przekroczyły TTL + entry = _cache_data.get(key) + if not entry: + return None + + value, ts = entry + if time.time() - ts > CACHE_TTL_SECONDS: + del _cache_data[key] + return None + + return value + + +def set_to_cache(key: str, value): + # Zapisuje dane do cache wraz z timestampem + _cache_data[key] = (value, time.time()) + + +def fetch_feed(url: str): + # Pobiera i parsuje kanał RSS z ustawionym User-Agent + headers = {"User-Agent": "Mozilla/5.0 (compatible; ThruScanBot/1.0)"} + resp = requests.get(url, timeout=7, headers=headers) + resp.raise_for_status() + + return feedparser.parse(resp.content) diff --git a/TruthScan AI_backend/app/storage.py b/TruthScan AI_backend/app/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..70dd2750865ee94fb2537fbf8f38c5f37bd29def --- /dev/null +++ b/TruthScan AI_backend/app/storage.py @@ -0,0 +1,60 @@ +""" +Warstwa dostępu do danych dla zapisanych artykułów (plik JSON). +""" + +import json +import threading +from fastapi import HTTPException + +from .config import SAVED_FILE + +# Blokada wątków dla operacji zapisu/odczytu +_lock = threading.Lock() + + +def ensure_file(): + # Tworzy plik danych, jeśli nie istnieje + if not SAVED_FILE.exists(): + SAVED_FILE.write_text("[]", encoding="utf-8") + + +def read_all(): + # Zwraca wszystkie zapisane artykuły + ensure_file() + try: + return json.loads(SAVED_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return [] + + +def append_article(article: dict): + # Dodaje nowy artykuł do pliku JSON + ensure_file() + try: + with _lock, open(SAVED_FILE, "r+", encoding="utf-8") as f: + data = json.load(f) + data.append(article) + f.seek(0) + json.dump(data, f, ensure_ascii=False, indent=4) + + return {"message": "Artykuł zapisany pomyślnie."} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +def delete_by_title(title: str): + # Usuwa artykuł na podstawie tytułu + ensure_file() + try: + with _lock, open(SAVED_FILE, "r+", encoding="utf-8") as f: + saved = json.load(f) + new_saved = [ + a for a in saved if a.get("title") != title + ] + f.seek(0) + f.truncate() + json.dump(new_saved, f, ensure_ascii=False, indent=4) + + return {"message": "Artykuł usunięty."} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/TruthScan AI_backend/requirements.txt b/TruthScan AI_backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..55f91d0008251da32ed77e9156c8efae17d16805 --- /dev/null +++ b/TruthScan AI_backend/requirements.txt @@ -0,0 +1,22 @@ +# Zależności backendu aplikacji ThruScan + +# Framework API i serwer ASGI +fastapi==0.115.12 +uvicorn==0.34.3 +starlette==0.46.2 + +# Walidacja danych i modele +pydantic==2.11.7 +pydantic_core==2.33.2 +annotated-types==0.7.0 +typing-extensions>=4.12.2 + +# NLP i uczenie maszynowe +transformers==4.44.2 +tokenizers==0.19.1 +torch==2.3.1 + +# Przetwarzanie RSS i HTML +feedparser==6.0.11 +beautifulsoup4==4.12.3 +requests==2.31.0 diff --git a/TruthScan AI_backend/setup_test_env.bat b/TruthScan AI_backend/setup_test_env.bat new file mode 100644 index 0000000000000000000000000000000000000000..3078aac6859d609e1840778d3894e28865219024 --- /dev/null +++ b/TruthScan AI_backend/setup_test_env.bat @@ -0,0 +1,21 @@ +@echo off +echo ======================================== +echo 🛠 Przygotowanie środowiska testowego +echo ======================================== + +echo. +echo 📦 Instalowanie wymaganych bibliotek... +pip install requests pandas + +echo. +echo 🔍 Sprawdzanie czy backend działa... +timeout /t 3 /nobreak > nul + +echo. +echo 🚀 Uruchamianie testów... +python test_truthscan.py + +echo. +echo 📊 Testy zakończone! +echo Otwórz raport: truthscan_test_report.html +pause \ No newline at end of file diff --git a/TruthScan AI_backend/truthscan_report.html b/TruthScan AI_backend/truthscan_report.html new file mode 100644 index 0000000000000000000000000000000000000000..f3471edd8536dab5c849fe718d2169cbcd222a64 --- /dev/null +++ b/TruthScan AI_backend/truthscan_report.html @@ -0,0 +1,417 @@ + + + + + Raport porównawczy TruthScan AI - BBC vs Gazeta Prawna + + + + +
+
+

📊 RAPORT PORÓWNAWCZY TRUTHSCAN AI

+

BBC vs Gazeta Prawna - Analiza systemu detekcji dezinformacji

+
Data testów: 20.12.2025 08:56:33
+
+ +
+
+

BBC

+

Artykułów: 5

+

Fake: 25.5%

+

Czas: 5.22s

+
+
+

Gazeta Prawna

+

Artykułów: 5

+

Fake: 17.2%

+

Czas: 15.03s

+
+
+ +
+
+

📈 Średnie ryzyko dezinformacji

+
+ +
+
+ +
+

⚡ Wydajność systemu

+
+ +
+
+
+ +
+
+

🎭 Rozkład sentymentu - BBC

+
+ +
+
+ +
+

🎭 Rozkład sentymentu - Gazeta Prawna

+
+ +
+
+
+ +
+

🔍 Kluczowe różnice:

+

• Różnica w ryzyku dezinformacji: 8.3%

+

• Różnica w czasie analizy: 9.81s

+

• ✅ Spójna skuteczność detekcji między językami

+
+ +
+

📰 Przykładowe artykuły z analizą

+ +
BBC:

1. Who and what is in the Epstein files?...

2. David Walliams denies inappropriate behaviour after publisher drops him...

+
Gazeta Prawna:

1. Fałszywe oskarżenia na policji mogą zrujnować życie – sprawdź, kiedy grozi za ni...

2. Polacy za granicą: w tym kraju mieszka ich tylko dwoje. Zamiast polskiej zimy ma...

+
+ +

📋 Wyniki testów (7/7 przepuszczonych)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TestStatusSzczegółyCzas [s]
API Availability✅ PASSSwagger UI dostępny (200)2.07
GET /sources✅ PASSZnaleziono 10 źródeł | ✅ BBC dostępne | ✅ Gazeta Prawna dostępne2.06
GET /news/BBC✅ PASSPobrano 5 artykułów | Źródło: BBC | Pola: title, sentiment, fake_probability, summary, link, published | Przykład: 'Who and what is in the Epstein files?'5.22
GET /news/GazetaPrawna✅ PASSPobrano 5 artykułów | Źródło: GazetaPrawna | Pola: title, sentiment, fake_probability, summary, link, published | Przykład: 'Fałszywe oskarżenia na policji mogą zrujnować życi...'15.03
Source Comparison✅ PASSBBC: 5 art, 25.5% fake, 5.2s | Gazeta: 5 art, 17.2% fake, 15.0s-
CRUD Operations - BBC✅ PASSZapis: ✅ (2.07s) | Odczyt: ✅ (15 artykułów, 2.09s) | Usuwanie: ✅ (Status: 200)-
CRUD Operations - GazetaPrawna✅ PASSZapis: ✅ (2.06s) | Odczyt: ✅ (15 artykułów, 2.11s) | Usuwanie: ✅ (Status: 200)-
+ +
+

Wnioski i rekomendacje

+ +
Kluczowe wnioski:
+ + +
Rekomendacje:
+
    +
  1. Fine-tuning modeli NLP na polskich danych fact-checkingowych
  2. +
  3. Optymalizacja parsowania polskich znaków diakrytycznych
  4. +
  5. Implementacja cache'owania wyników dla często analizowanych źródeł
  6. +
  7. Rozszerzenie testów o więcej polskich źródeł informacji
  8. +
  9. Przeprowadzenie testów z rzeczywistymi użytkownikami
  10. +
+
+
+ + + + + + + \ No newline at end of file diff --git a/TruthScan AI_backend/truthscan_report.txt b/TruthScan AI_backend/truthscan_report.txt new file mode 100644 index 0000000000000000000000000000000000000000..fc978f075b0408a13528b83862391cc9662de825 --- /dev/null +++ b/TruthScan AI_backend/truthscan_report.txt @@ -0,0 +1,97 @@ + +================================================================================ +RAPORT PORÓWNAWCZY TRUTHSCAN AI - BBC vs GAZETA PRAWNA +================================================================================ +Data wykonania: 2025-12-20 08:56:33 +Backend URL: http://localhost:8000 +Frontend URL: http://localhost:3000 +================================================================================ + +PODSUMOWANIE TESTOW: +================================================================================ +Wszystkie testy: 7 +Przepuszczone: 7 +Nieudane: 0 +Ostrzeżenia: 0 +Wskaźnik sukcesu: 100.0% + +================================================================================ +WYNIKI PORÓWNANIA: +================================================================================ + +BBC: + • Artykułów: 5 + • Średnie fake_probability: 25.47% + • Czas odpowiedzi: 5.22s + • Rozkład sentymentu: {"Neutralne": 3, "Pozytywne": 1, "Negatywne": 1} + +Gazeta Prawna: + • Artykułów: 5 + • Średnie fake_probability: 17.16% + • Czas odpowiedzi: 15.03s + • Rozkład sentymentu: {"Neutralne": 5} + +ANALIZA RÓŻNIC: + • Różnica w fake_probability: 8.31% + • Różnica w czasie odpowiedzi: 9.81s + +================================================================================ +WYNIKI SZCZEGÓŁOWE: +================================================================================ +✅ API Availability [2.07s] + Swagger UI dostępny (200) + Czas: 08:55:32 + +✅ GET /sources [2.06s] + Znaleziono 10 źródeł | ✅ BBC dostępne | ✅ Gazeta Prawna dostępne + Czas: 08:55:35 + +✅ GET /news/BBC [5.22s] + Pobrano 5 artykułów | Źródło: BBC | Pola: title, sentiment, fake_probability, summary, link, published | Przykład: 'Who and what is in the Epstein files?' + Czas: 08:55:40 + +✅ GET /news/GazetaPrawna [15.03s] + Pobrano 5 artykułów | Źródło: GazetaPrawna | Pola: title, sentiment, fake_probability, summary, link, published | Przykład: 'Fałszywe oskarżenia na policji mogą zrujnować życi...' + Czas: 08:55:55 + +✅ Source Comparison + BBC: 5 art, 25.5% fake, 5.2s | Gazeta: 5 art, 17.2% fake, 15.0s + Czas: 08:55:55 + +✅ CRUD Operations - BBC + Zapis: ✅ (2.07s) | Odczyt: ✅ (15 artykułów, 2.09s) | Usuwanie: ✅ (Status: 200) + Czas: 08:56:10 + +✅ CRUD Operations - GazetaPrawna + Zapis: ✅ (2.06s) | Odczyt: ✅ (15 artykułów, 2.11s) | Usuwanie: ✅ (Status: 200) + Czas: 08:56:33 + + +================================================================================ +PRZYKŁADOWE ARTYKUŁY: +================================================================================ +BBC: + 1. Who and what is in the Epstein files? + 2. David Walliams denies inappropriate behaviour after publisher drops him + 3. US carries out 'massive' strike against IS in Syria + +Gazeta Prawna: + 1. Fałszywe oskarżenia na policji mogą zrujnować życie – sprawdź, kiedy grozi za ni + 2. Polacy za granicą: w tym kraju mieszka ich tylko dwoje. Zamiast polskiej zimy ma + 3. Okrągły Stół porwany przez nurt Historii [FELIETON] + +================================================================================ +WNIOSKI I REKOMENDACJE: +================================================================================ +1. System działa poprawnie +2. Obsługa języka polskiego: SPRAWNIE +3. Średni czas odpowiedzi: 6.09s +4. Główne problemy: 0 błędów +5. Gotowość do dalszych testów: TAK + +REKOMENDACJE: +1. Wszystko działa poprawnie +2. Przeprowadzić testy manualne interfejsu +3. Przetestować więcej źródeł RSS +4. Sprawdzić działanie na różnych przeglądarkach +5. Modele działają spójnie dla obu języków diff --git a/TruthScan AI_backend/truthscan_test.py b/TruthScan AI_backend/truthscan_test.py new file mode 100644 index 0000000000000000000000000000000000000000..561de6ca7b30998d85a3208f2679c21b7819bed4 --- /dev/null +++ b/TruthScan AI_backend/truthscan_test.py @@ -0,0 +1,1135 @@ +""" +SKRYPT TESTOWY TRUTHSCAN AI - TEST PORÓWNAWCZY BBC I GAZETA PRAWNA +""" + +import requests +import json +import time +import statistics +import os +from datetime import datetime +from collections import defaultdict +from typing import Dict, List, Any, Optional + +class TruthScanComparativeTester: + def __init__(self, base_url="http://localhost:8000", frontend_url="http://localhost:3000"): + self.base_url = base_url + self.frontend_url = frontend_url + self.results = [] + self.errors = [] + self.performance_data = [] + self.bbc_results = {} + self.gazeta_results = {} + self.comparison_data = {} + + def log_test(self, test_name: str, status: str, details: str = "", duration: float = None): + """Zapisuje wynik testu""" + result = { + "test_name": test_name, + "status": status, + "timestamp": datetime.now().isoformat(), + "details": details, + "duration": duration + } + self.results.append(result) + + status_symbol = "✅" if status == "PASS" else "❌" if status == "FAIL" else "⚠️" + print(f"{status_symbol} {test_name}: {details}") + + if status == "FAIL": + self.errors.append(result) + +########## +# Test dostępności API +########## + + def test_api_availability(self): + test_name = "API Availability" + start = time.time() + + try: + response = requests.get(f"{self.base_url}/docs", timeout=10) + duration = time.time() - start + + if response.status_code == 200: + self.log_test(test_name, "PASS", + f"Swagger UI dostępny ({response.status_code})", duration) + return True + else: + self.log_test(test_name, "FAIL", + f"Status code: {response.status_code}", duration) + return False + except Exception as e: + self.log_test(test_name, "FAIL", f"Błąd połączenia: {str(e)}", time.time() - start) + return False + +############ +# Test: Sprawdzenie dostępnych źródeł +############ + + def test_sources_endpoint(self): + + test_name = "GET /sources" + start = time.time() + + try: + response = requests.get(f"{self.base_url}/sources", timeout=15) + duration = time.time() - start + + if response.status_code == 200: + data = response.json() + + if isinstance(data, list): + details = f"Znaleziono {len(data)} źródeł" + + # Sprawdzenie czy oba źródła są dostępne + sources_lower = [s.lower() for s in data] + bbc_available = "bbc" in sources_lower or any("bbc" in s.lower() for s in data) + gazeta_available = "gazetaprawna" in sources_lower or any("gazeta" in s.lower() for s in data) + + if not bbc_available: + details += " | ❌ BBC niedostępne" + status = "WARNING" + elif not gazeta_available: + details += " | ❌ Gazeta Prawna niedostępne" + status = "WARNING" + else: + details += " | ✅ BBC dostępne | ✅ Gazeta Prawna dostępne" + status = "PASS" + + self.log_test(test_name, status, details, duration) + return data + else: + self.log_test(test_name, "FAIL", f"Nieoczekiwany typ danych: {type(data)}", duration) + return None + else: + self.log_test(test_name, "FAIL", + f"Status code: {response.status_code}", duration) + return None + except Exception as e: + self.log_test(test_name, "FAIL", f"Błąd: {str(e)}", time.time() - start) + return None + + def test_single_source_detailed(self, source: str, source_name: str): + print(f"\n{'='*60}") + print(f"🔍 SZCZEGÓŁOWY TEST: {source_name} ({source})") + print(f"{'='*60}") + + test_results = { + "source": source, + "source_name": source_name, + "articles": [], + "sentiment_distribution": defaultdict(int), + "fake_scores": [], + "performance": 0, + "errors": [], + "sample_titles": [] + } + +#### Test 1: Pobieranie artykułów + test_name = f"GET /news/{source}" + start = time.time() + + try: + response = requests.get(f"{self.base_url}/news/{source}", timeout=30) + duration = time.time() - start + test_results["performance"] = duration + + if response.status_code != 200: + self.log_test(test_name, "FAIL", + f"Status code: {response.status_code}", duration) + test_results["errors"].append(f"HTTP {response.status_code}") + return test_results + + data = response.json() + + if not data: + self.log_test(test_name, "FAIL", "Brak danych w odpowiedzi", duration) + return test_results + + if isinstance(data, dict) and "articles" in data: + articles = data["articles"] + source_from_response = data.get("source", source) + test_results["articles"] = articles + + details = f"Pobrano {len(articles)} artykułów | Źródło: {source_from_response}" + + if len(articles) > 0: + + first_article = articles[0] + available_fields = [field for field in ["title", "sentiment", "fake_probability", "summary", "link", "published"] + if field in first_article] + + details += f" | Pola: {', '.join(available_fields)}" + + title = first_article.get("title", "Brak tytułu") + if len(title) > 50: + title = title[:50] + "..." + details += f" | Przykład: '{title}'" + + for i, article in enumerate(articles[:3]): + title = article.get("title", "") + if title: + test_results["sample_titles"].append(title[:80]) + + self.log_test(test_name, "PASS", details, duration) + + # Zapis danych wydajności + self.performance_data.append({ + "endpoint": test_name, + "duration": duration, + "source": source + }) + + else: + self.log_test(test_name, "WARNING", + f"Nieoczekiwany format danych: {type(data)}", duration) + return test_results + + except Exception as e: + self.log_test(test_name, "FAIL", f"Błąd: {str(e)}", time.time() - start) + test_results["errors"].append(str(e)) + return test_results + +#### Test 2: Szczegółowa analiza NLP dla każdego artykułu + if test_results["articles"]: + print(f"\n📊 ANALIZA NLP DLA {source_name}:") + + for i, article in enumerate(test_results["articles"][:5]): + print(f"\n 📄 Artykuł {i+1}:") + + title = article.get("title", "Brak tytułu") + if len(title) > 60: + title_display = title[:60] + "..." + else: + title_display = title + print(f" Tytuł: {title_display}") + +#### Sentyment + sentiment = article.get("sentiment", "Nieznany") + sentiment_score = article.get("sentiment_score", 0) + print(f" Sentyment: {sentiment} ({sentiment_score:.2f})") + +#### Fake probability + fake_prob = article.get("fake_probability", 0) + if isinstance(fake_prob, (int, float)): + print(f" Fake probability: {fake_prob}%") + +#### Kategoryzacja ryzyka + if fake_prob < 15: + risk = "NISKIE" + elif fake_prob < 30: + risk = "ŚREDNIE" + else: + risk = "WYSOKIE" + print(f" Ryzyko dezinformacji: {risk}") + + test_results["fake_scores"].append(fake_prob) + + test_results["sentiment_distribution"][sentiment] += 1 + +#### Link i data + link = article.get("link", "") + if link: + domain = link.split('/')[2] if len(link.split('/')) > 2 else link + print(f" Źródło: {domain}") + + published = article.get("published", "Brak daty") + print(f" Data publikacji: {published}") + +#### Test 3: Analiza statystyczna + if test_results["fake_scores"]: + avg_fake = statistics.mean(test_results["fake_scores"]) + min_fake = min(test_results["fake_scores"]) + max_fake = max(test_results["fake_scores"]) + + print(f"\n 📈 STATYSTYKI {source_name}:") + print(f" Średnie fake_probability: {avg_fake:.2f}%") + print(f" Zakres: {min_fake:.2f}% - {max_fake:.2f}%") + print(f" Czas odpowiedzi: {test_results['performance']:.2f}s") + + # Analiza rozkładu sentymentu + if test_results["sentiment_distribution"]: + print(f" Rozkład sentymentu:") + for sentiment, count in test_results["sentiment_distribution"].items(): + percentage = (count / len(test_results["articles"])) * 100 + print(f" {sentiment}: {count} ({percentage:.1f}%)") + + return test_results + +########### +# Porównanie wyników BBC i Gazety Prawnej +########### + def compare_sources(self, bbc_data: Dict, gazeta_data: Dict): + + print(f"\n{'='*60}") + print(f"🔄 PORÓWNANIE BBC vs GAZETA PRAWNA") + print(f"{'='*60}") + + # Obliczanie średnich - z obsługą pustych list + bbc_fake_scores = bbc_data.get("fake_scores", []) + gazeta_fake_scores = gazeta_data.get("fake_scores", []) + + bbc_avg_fake = statistics.mean(bbc_fake_scores) if bbc_fake_scores else 0 + gazeta_avg_fake = statistics.mean(gazeta_fake_scores) if gazeta_fake_scores else 0 + + comparison = { + "source_count": { + "BBC": len(bbc_data.get("articles", [])), + "Gazeta Prawna": len(gazeta_data.get("articles", [])) + }, + "avg_fake_score": { + "BBC": bbc_avg_fake, + "Gazeta Prawna": gazeta_avg_fake + }, + "sentiment_distribution": { + "BBC": dict(bbc_data.get("sentiment_distribution", {})), + "Gazeta Prawna": dict(gazeta_data.get("sentiment_distribution", {})) + }, + "performance": { + "BBC": bbc_data.get("performance", 0), + "Gazeta Prawna": gazeta_data.get("performance", 0) + }, + "sample_titles": { + "BBC": bbc_data.get("sample_titles", []), + "Gazeta Prawna": gazeta_data.get("sample_titles", []) + } + } + +#### Wyświetlanie wyników porównania + print(f"\n📊 LICZBA ARTYKUŁÓW:") + print(f" BBC: {comparison['source_count']['BBC']}") + print(f" Gazeta Prawna: {comparison['source_count']['Gazeta Prawna']}") + + print(f"\n📊 ŚREDNIE RYZYKO DEZINFORMACJI:") + print(f" BBC: {comparison['avg_fake_score']['BBC']:.2f}%") + print(f" Gazeta Prawna: {comparison['avg_fake_score']['Gazeta Prawna']:.2f}%") + +#### Analiza różnic + if comparison['avg_fake_score']['BBC'] > 0 and comparison['avg_fake_score']['Gazeta Prawna'] > 0: + fake_diff = abs(comparison['avg_fake_score']['BBC'] - comparison['avg_fake_score']['Gazeta Prawna']) + print(f" Różnica: {fake_diff:.2f}%") + + if fake_diff > 10: + print(f" ⚠️ Znacząca różnica w ryzyku dezinformacji") + + print(f"\n📊 ROZKŁAD SENTYMENTU:") + + for source in ["BBC", "Gazeta Prawna"]: + print(f"\n {source}:") + dist = comparison['sentiment_distribution'][source] + total = sum(dist.values()) if dist else 1 + + if dist: + for sentiment, count in dist.items(): + percentage = (count / total) * 100 if total > 0 else 0 + print(f" {sentiment}: {count} ({percentage:.1f}%)") + else: + print(" Brak danych o sentymencie") + + print(f"\n📊 WYDANOŚĆ:") + print(f" BBC: {comparison['performance']['BBC']:.2f}s") + print(f" Gazeta Prawna: {comparison['performance']['Gazeta Prawna']:.2f}s") + + if comparison['performance']['BBC'] > 0 and comparison['performance']['Gazeta Prawna'] > 0: + perf_diff = comparison['performance']['Gazeta Prawna'] - comparison['performance']['BBC'] + if perf_diff > 1: + print(f" ⏱️ Gazeta Prawna wolniejsza o {perf_diff:.2f}s (język polski)") + elif perf_diff < -1: + print(f" ⏱️ BBC wolniejsze o {abs(perf_diff):.2f}s") + else: + print(f" ⚡ Porównywalna wydajność") + +#### Logowanie testu porównawczego + details = (f"BBC: {comparison['source_count']['BBC']} art, " + f"{comparison['avg_fake_score']['BBC']:.1f}% fake, " + f"{comparison['performance']['BBC']:.1f}s | " + f"Gazeta: {comparison['source_count']['Gazeta Prawna']} art, " + f"{comparison['avg_fake_score']['Gazeta Prawna']:.1f}% fake, " + f"{comparison['performance']['Gazeta Prawna']:.1f}s") + + self.log_test("Source Comparison", "PASS", details) + + self.comparison_data = comparison + return comparison + +################ +#Test operacji CRUD z artykułami z danego źródła +############## + + def test_crud_operations_for_source(self, source: str): + + test_name = f"CRUD Operations - {source}" + +##### Najpierw pobierz artykuły ze źródła + + try: + response = requests.get(f"{self.base_url}/news/{source}", timeout=20) + if response.status_code != 200: + self.log_test(test_name, "FAIL", f"Nie można pobrać artykułów: {response.status_code}") + return None + + data = response.json() + articles = data.get("articles", []) if isinstance(data, dict) else data + + if not articles: + self.log_test(test_name, "WARNING", "Brak artykułów do testu CRUD") + return None + +##### Użyj pierwszego artykułu do testu + test_article = articles[0] + +#### Dostosuj artykuł do formatu zapisu############# + article_to_save = { + "title": f"[TEST {source}] {test_article.get('title', 'Testowy artykuł')}", + "link": test_article.get("link", "https://example.com/test"), + "summary": test_article.get("summary", "Testowy artykuł do weryfikacji systemu."), + "published": datetime.now().isoformat(), + "sentiment": test_article.get("sentiment", "Neutral"), + "fake_probability": test_article.get("fake_probability", 15.5), + "source": source + } + + operations = [] + +#### 1. Zapis artykułu############################# + try: + start = time.time() + response = requests.post( + f"{self.base_url}/save-article", + json=article_to_save, + timeout=10 + ) + save_time = time.time() - start + + if response.status_code in [200, 201]: + operations.append(("Zapis", "✅", f"{save_time:.2f}s")) + else: + operations.append(("Zapis", "❌", f"Status: {response.status_code}")) + except Exception as e: + operations.append(("Zapis", "❌", f"Błąd: {str(e)[:30]}")) + +#### 2. Odczyt zapisanych artykułów############ + try: + start = time.time() + response = requests.get(f"{self.base_url}/saved-articles", timeout=10) + fetch_time = time.time() - start + + if response.status_code == 200: + data = response.json() + if isinstance(data, list): + found = any(isinstance(a, dict) and source in a.get("title", "") for a in data) + operations.append(("Odczyt", "✅" if found else "⚠️", + f"{len(data)} artykułów, {fetch_time:.2f}s")) + else: + operations.append(("Odczyt", "❌", f"Niewłaściwy format: {type(data)}")) + else: + operations.append(("Odczyt", "❌", f"Status: {response.status_code}")) + except Exception as e: + operations.append(("Odczyt", "❌", f"Błąd: {str(e)[:30]}")) + + +#### 3. Usuwanie testowego artykułu############## + + try: + response = requests.delete( + f"{self.base_url}/delete-article", + json={"title": article_to_save["title"]}, + timeout=5 + ) + operations.append(("Usuwanie", "✅" if response.status_code == 200 else "⚠️", + f"Status: {response.status_code}")) + except Exception as e: + operations.append(("Usuwanie", "SKIP", f"Błąd: {str(e)[:30]}")) + + details = " | ".join([f"{op[0]}: {op[1]} ({op[2]})" for op in operations]) + success_ops = sum(1 for op in operations if op[1] in ["✅", "SKIP"]) + status = "PASS" if success_ops >= 2 else "FAIL" + + self.log_test(test_name, status, details) + return operations + + except Exception as e: + self.log_test(test_name, "FAIL", f"Błąd ogólny: {str(e)}") + return None + +##################### Generuje raport tekstowy z porównaniem ########## + + def generate_text_report(self, filename: str = "truthscan_report.txt"): + + total_tests = len(self.results) + passed = sum(1 for r in self.results if r["status"] == "PASS") + + report = f""" +{'='*80} +RAPORT PORÓWNAWCZY TRUTHSCAN AI - BBC vs GAZETA PRAWNA +{'='*80} +Data wykonania: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +Backend URL: {self.base_url} +Frontend URL: {self.frontend_url} +{'='*80} + +PODSUMOWANIE TESTOW: +{'='*80} +Wszystkie testy: {total_tests} +Przepuszczone: {passed} +Nieudane: {sum(1 for r in self.results if r["status"] == "FAIL")} +Ostrzeżenia: {sum(1 for r in self.results if r["status"] == "WARNING")} +Wskaźnik sukcesu: {passed/total_tests*100:.1f}% + +{'='*80} +WYNIKI PORÓWNANIA: +{'='*80} +""" + + if self.comparison_data: + report += f""" +BBC: + • Artykułów: {self.comparison_data['source_count']['BBC']} + • Średnie fake_probability: {self.comparison_data['avg_fake_score']['BBC']:.2f}% + • Czas odpowiedzi: {self.comparison_data['performance']['BBC']:.2f}s + • Rozkład sentymentu: {json.dumps(self.comparison_data['sentiment_distribution']['BBC'], ensure_ascii=False)} + +Gazeta Prawna: + • Artykułów: {self.comparison_data['source_count']['Gazeta Prawna']} + • Średnie fake_probability: {self.comparison_data['avg_fake_score']['Gazeta Prawna']:.2f}% + • Czas odpowiedzi: {self.comparison_data['performance']['Gazeta Prawna']:.2f}s + • Rozkład sentymentu: {json.dumps(self.comparison_data['sentiment_distribution']['Gazeta Prawna'], ensure_ascii=False)} + +ANALIZA RÓŻNIC: + • Różnica w fake_probability: {abs(self.comparison_data['avg_fake_score']['BBC'] - self.comparison_data['avg_fake_score']['Gazeta Prawna']):.2f}% + • Różnica w czasie odpowiedzi: {abs(self.comparison_data['performance']['BBC'] - self.comparison_data['performance']['Gazeta Prawna']):.2f}s +""" + + report += f""" +{'='*80} +WYNIKI SZCZEGÓŁOWE: +{'='*80} +""" + + for result in self.results: + status_icon = "✅" if result["status"] == "PASS" else "❌" if result["status"] == "FAIL" else "⚠️" + duration = f"[{result['duration']:.2f}s]" if result["duration"] else "" + report += f"{status_icon} {result['test_name']} {duration}\n" + report += f" {result['details']}\n" + report += f" Czas: {result['timestamp'][11:19]}\n\n" + + # Przykładowe artykuły + if self.bbc_results.get("sample_titles") or self.gazeta_results.get("sample_titles"): + report += f""" +{'='*80} +PRZYKŁADOWE ARTYKUŁY: +{'='*80} +""" + + if self.bbc_results.get("sample_titles"): + report += "BBC:\n" + for i, title in enumerate(self.bbc_results["sample_titles"][:3]): + report += f" {i+1}. {title}\n" + + if self.gazeta_results.get("sample_titles"): + report += "\nGazeta Prawna:\n" + for i, title in enumerate(self.gazeta_results["sample_titles"][:3]): + report += f" {i+1}. {title}\n" + + report += f""" +{'='*80} +WNIOSKI I REKOMENDACJE: +{'='*80} +1. System {'działa poprawnie' if passed > total_tests/2 else 'wymaga poprawy'} +2. Obsługa języka polskiego: {'SPRAWNIE' if self.gazeta_results.get('articles') else 'PROBLEMY'} +3. Średni czas odpowiedzi: {statistics.mean([r['duration'] for r in self.results if r.get('duration')]):.2f}s +4. Główne problemy: {len(self.errors)} błędów +5. Gotowość do dalszych testów: {'TAK' if len(self.errors) < 3 else 'NIE'} + +REKOMENDACJE: +1. {'Naprawić wykryte błędy' if self.errors else 'Wszystko działa poprawnie'} +2. Przeprowadzić testy manualne interfejsu +3. Przetestować więcej źródeł RSS +4. Sprawdzić działanie na różnych przeglądarkach +5. {'Wymagany fine-tuning modeli dla języka polskiego' + if self.comparison_data and abs(self.comparison_data['avg_fake_score']['BBC'] - self.comparison_data['avg_fake_score']['Gazeta Prawna']) > 15 + else 'Modele działają spójnie dla obu języków'} +""" + + with open(filename, 'w', encoding='utf-8') as f: + f.write(report) + + print(f"📝 Raport tekstowy zapisany jako: {filename}") + + return report + + """Generuje szczegółowy raport HTML z porównaniem źródeł""" + + def generate_comparative_html_report(self, filename: str = "truthscan_report.html"): + + total_tests = len(self.results) + passed = sum(1 for r in self.results if r["status"] == "PASS") + + # Przygotowanie danych do wykresów + if self.comparison_data: + sources = ["BBC", "Gazeta Prawna"] + fake_scores = [ + self.comparison_data['avg_fake_score']['BBC'], + self.comparison_data['avg_fake_score']['Gazeta Prawna'] + ] + performance_times = [ + self.comparison_data['performance']['BBC'], + self.comparison_data['performance']['Gazeta Prawna'] + ] + + # Dane sentymentu + bbc_sentiments = self.comparison_data['sentiment_distribution']['BBC'] + gazeta_sentiments = self.comparison_data['sentiment_distribution']['Gazeta Prawna'] + + # Przygotowanie etykiet i wartości dla wykresów sentymentu + bbc_sentiment_labels = list(bbc_sentiments.keys()) if bbc_sentiments else ['Neutralny', 'Pozytywny', 'Negatywny'] + bbc_sentiment_values = list(bbc_sentiments.values()) if bbc_sentiments else [1, 1, 1] + + gazeta_sentiment_labels = list(gazeta_sentiments.keys()) if gazeta_sentiments else ['Neutralny', 'Pozytywny', 'Negatywny'] + gazeta_sentiment_values = list(gazeta_sentiments.values()) if gazeta_sentiments else [1, 1, 1] + else: + sources = ["BBC", "Gazeta Prawna"] + fake_scores = [0, 0] + performance_times = [0, 0] + bbc_sentiment_labels = ['Neutralny', 'Pozytywny', 'Negatywny'] + bbc_sentiment_values = [1, 1, 1] + gazeta_sentiment_labels = ['Neutralny', 'Pozytywny', 'Negatywny'] + gazeta_sentiment_values = [1, 1, 1] + + html = f""" + + + + Raport porównawczy TruthScan AI - BBC vs Gazeta Prawna + + + + +
+
+

📊 RAPORT PORÓWNAWCZY TRUTHSCAN AI

+

BBC vs Gazeta Prawna - Analiza systemu detekcji dezinformacji

+
Data testów: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}
+
+ +
+
+

BBC

+

Artykułów: {self.comparison_data.get('source_count', {}).get('BBC', 0)}

+

Fake: {fake_scores[0]:.1f}%

+

Czas: {performance_times[0]:.2f}s

+
+
+

Gazeta Prawna

+

Artykułów: {self.comparison_data.get('source_count', {}).get('Gazeta Prawna', 0)}

+

Fake: {fake_scores[1]:.1f}%

+

Czas: {performance_times[1]:.2f}s

+
+
+ +
+
+

📈 Średnie ryzyko dezinformacji

+
+ +
+
+ +
+

⚡ Wydajność systemu

+
+ +
+
+
+ +
+
+

🎭 Rozkład sentymentu - BBC

+
+ +
+
+ +
+

🎭 Rozkład sentymentu - Gazeta Prawna

+
+ +
+
+
+ +
+

🔍 Kluczowe różnice:

+

• Różnica w ryzyku dezinformacji: {abs(fake_scores[0] - fake_scores[1]):.1f}%

+

• Różnica w czasie analizy: {abs(performance_times[0] - performance_times[1]):.2f}s

+ {"

• ⚠️ Znacząca różnica w wykrywaniu dezinformacji między językami

" + if abs(fake_scores[0] - fake_scores[1]) > 10 else + "

• ✅ Spójna skuteczność detekcji między językami

"} +
+ +
+

📰 Przykładowe artykuły z analizą

+ +
BBC:
""" + +#### Przykładowe artykuły BBC + if self.bbc_results.get("sample_titles"): + for i, title in enumerate(self.bbc_results["sample_titles"][:2]): + html += f"

{i+1}. {title}...

" + else: + html += "

Brak przykładowych artykułów

" + + html += """ +
Gazeta Prawna:
""" + +#### Przykładowe artykuły Gazeta Prawna + if self.gazeta_results.get("sample_titles"): + for i, title in enumerate(self.gazeta_results["sample_titles"][:2]): + html += f"

{i+1}. {title}...

" + else: + html += "

Brak przykładowych artykułów

" + + html += f""" +
+ +

📋 Wyniki testów ({passed}/{total_tests} przepuszczonych)

+ + + + + + + + + + """ + + for result in self.results: + status_class = "pass" if result["status"] == "PASS" else "fail" if result["status"] == "FAIL" else "warn" + status_display = {"PASS": "✅", "FAIL": "❌", "WARNING": "⚠️"}.get(result["status"], "?") + duration = f"{result['duration']:.2f}" if result["duration"] else "-" + + html += f""" + + + + + + """ + + html += f""" + +
TestStatusSzczegółyCzas [s]
{result['test_name']}{status_display} {result['status']}{result['details']}{duration}
+ +
+

Wnioski i rekomendacje

+ +
Kluczowe wnioski:
+ + +
Rekomendacje:
+
    +
  1. Fine-tuning modeli NLP na polskich danych fact-checkingowych
  2. +
  3. Optymalizacja parsowania polskich znaków diakrytycznych
  4. +
  5. Implementacja cache'owania wyników dla często analizowanych źródeł
  6. +
  7. Rozszerzenie testów o więcej polskich źródeł informacji
  8. +
  9. Przeprowadzenie testów z rzeczywistymi użytkownikami
  10. +
+
+
+ + + + + + +""" + + with open(filename, 'w', encoding='utf-8') as f: + f.write(html) + + print(f"\n📄 Raport porównawczy HTML zapisany jako: {filename}") + print(f" Otwórz w przeglądarce: file://{os.path.abspath(filename)}") + + return filename + + def run_comparative_tests(self): + """Uruchamia pełne testy porównawcze""" + print("=" * 70) + print("🎯 TRUTHSCAN AI - TESTY PORÓWNAWCZE BBC vs GAZETA PRAWNA") + print("=" * 70) + print("Ten skrypt przeprowadzi szczegółowe testy obu źródeł") + print("i porówna ich wyniki analizy NLP.") + print("=" * 70) + + # Sprawdzenie dostępności API + print("\n1️⃣ Sprawdzanie dostępności systemu...") + if not self.test_api_availability(): + print("❌ API niedostępne! Sprawdź czy backend działa.") + return False + + # Test źródeł + print("\n2️⃣ Weryfikacja dostępnych źródeł...") + sources = self.test_sources_endpoint() + + if not sources: + print("⚠️ Nie udało się pobrać źródeł, używam domyślnych...") + sources = ["BBC", "GazetaPrawna"] + else: + print(f"✅ Znaleziono {len(sources)} źródeł") + + # Test BBC + print("\n3️⃣ Testowanie źródła BBC...") + self.bbc_results = self.test_single_source_detailed("BBC", "BBC News") + + # Test Gazety Prawnej + print("\n4️⃣ Testowanie źródła Gazeta Prawna...") + self.gazeta_results = self.test_single_source_detailed("GazetaPrawna", "Gazeta Prawna") + + # Porównanie wyników + if self.bbc_results.get("articles") and self.gazeta_results.get("articles"): + print("\n5️⃣ Porównywanie wyników BBC i Gazety Prawnej...") + self.compare_sources(self.bbc_results, self.gazeta_results) + else: + print("⚠️ Brak danych do porównania") + + # Testy CRUD dla obu źródeł + print("\n6️⃣ Testy operacji na danych...") + self.test_crud_operations_for_source("BBC") + self.test_crud_operations_for_source("GazetaPrawna") + + # Generowanie raportów + print("\n" + "=" * 70) + print("📊 GENEROWANIE RAPORTÓW") + print("=" * 70) + + html_report = self.generate_comparative_html_report() + text_report = self.generate_text_report() + + # Podsumowanie + passed = sum(1 for r in self.results if r["status"] == "PASS") + total = len(self.results) + + print(f"\n{'='*70}") + print(f"📋 PODSUMOWANIE TESTOW:") + print(f" Przepuszczono: {passed}/{total} ({passed/total*100:.1f}%)") + + if self.errors: + print(f" Błędy: {len(self.errors)}") + + if self.comparison_data: + fake_diff = abs(self.comparison_data['avg_fake_score']['BBC'] - self.comparison_data['avg_fake_score']['Gazeta Prawna']) + time_diff = abs(self.comparison_data['performance']['BBC'] - self.comparison_data['performance']['Gazeta Prawna']) + + print(f"\n🔍 WNIOSKI Z PORÓWNANIA:") + print(f" • Różnica w ryzyku dezinformacji: {fake_diff:.1f}%") + print(f" • Różnica w czasie analizy: {time_diff:.2f}s") + + if fake_diff > 10: + print(f" • ⚠️ Znacząca różnica w NLP między językami") + if time_diff > 2: + print(f" • ⏱️ Analiza polskiego języka wymaga więcej czasu") + + print(f"\n📁 Raporty wygenerowane:") + print(f" HTML: {html_report}") + print(f" Tekst: {text_report}") + print(f"{'='*70}") + + return passed > total * 0.7 + + +def main(): + """Główna funkcja""" + print("🎯 TruthScan AI - Testy porównawcze BBC vs Gazeta Prawna") + print("=" * 70) + + tester = TruthScanComparativeTester() + + # Uruchom testy + success = tester.run_comparative_tests() + + print("\n" + "=" * 70) + if success: + print("✅ TESTY ZAKOŃCZONE SUKCESEM!") + else: + print("⚠️ TESTY WYKAZAŁY PROBLEMY - sprawdź raport") + print("=" * 70) + + print("\n📋 Otwórz raport HTML w przeglądarce:") + print(f" file://{os.path.abspath('truthscan_comparative_report.html')}") + + return success + + +if __name__ == "__main__": + try: + import requests + except ImportError: + print("❌ Brak biblioteki 'requests'") + print("💡 Zainstaluj: pip install requests") + exit(1) + + main() \ No newline at end of file diff --git a/TruthScan AI_frontend/.gitignore b/TruthScan AI_frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5ef6a520780202a1d6addd833d800ccb1ecac0bb --- /dev/null +++ b/TruthScan AI_frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/TruthScan AI_frontend/README.md b/TruthScan AI_frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e215bc4ccf138bbc38ad58ad57e92135484b3c0f --- /dev/null +++ b/TruthScan AI_frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/TruthScan AI_frontend/Uruchamianie.md b/TruthScan AI_frontend/Uruchamianie.md new file mode 100644 index 0000000000000000000000000000000000000000..c962e734dac2fbf56af338c069e7a129859c25dd --- /dev/null +++ b/TruthScan AI_frontend/Uruchamianie.md @@ -0,0 +1,30 @@ +ThruScan AI - Instrukcja uruchomienia + + Wymagania wstępne +- Windows 10+ +- Node.js 18+ +- Python 3.9+ + +Backend +- py -3.11 -m pip install –opgrade pip +- py -3.11 -m pip install -r requirements.txt + +Uruchomienie aplikacji w wierszu polecenia (przykładowa ścieżka) D:\>Studia\Semestr 7\PRACA DYPLOMOWA\TruthScan AI\TruthScan AI_backend +python -m uvicorn app.main:app + +Test API w przeglądarce lub w programie Postman: +http://127.0.0.1:8000/news/BBC +http://127.0.0.1:8000/sources - zwraca słownik wszystkich źródeł RSS +http://127.0.0.1:8000/news/BBC - pobiera 5 najnowszych artykułów +http://127.0.0.1:8000/emotion-stats/BBC - oblicza rozkład sentymentu +http://127.0.0.1:8000/stream-news/bbc - strumieniuje artykuły w czasie rzeczywistym (Server-Sent Events) + +Testowanie endpointów w Swagger (OpenAPI) dla backendu +http://localhost:8000/docs + +Frontend +- npm install + +Uruchomienie aplikacji w wierszu polecenia (przykładowa ścieżka) D:\>Studia\Semestr 7\PRACA DYPLOMOWA\TruthScan AI\TruthScan AI_frontend +npm run dev +http://localhost:3000 diff --git a/TruthScan AI_frontend/app/TipModel.tsx b/TruthScan AI_frontend/app/TipModel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0aeb1b8b4ed2bbab565cb93c5f52172dee7a2c7c --- /dev/null +++ b/TruthScan AI_frontend/app/TipModel.tsx @@ -0,0 +1,90 @@ +/** + * Komponent modalny wyświetlający edukacyjne porady dla użytkownika. + */ + +"use client"; + +import { useEffect } from "react"; + +type TipModalProps = { + title: string; + body: string; + linkHref: string; + linkLabel: string; + onClose: () => void; +}; + +export default function TipModal({ + title, + body, + linkHref, + linkLabel, + onClose, +}: TipModalProps) { + // ESC + blokada scrolla z kompensacją paska + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", onKey); + + const docEl = document.documentElement; + const bodyEl = document.body; + const scrollbarWidth = window.innerWidth - docEl.clientWidth; + const prevOverflow = bodyEl.style.overflow; + const prevPaddingRight = bodyEl.style.paddingRight; + + if (scrollbarWidth > 0) bodyEl.style.paddingRight = `${scrollbarWidth}px`; + bodyEl.style.overflow = "hidden"; + + return () => { + window.removeEventListener("keydown", onKey); + bodyEl.style.overflow = prevOverflow; + bodyEl.style.paddingRight = prevPaddingRight; + }; + }, [onClose]); + + return ( +
+ +
+ +
+

{body}

+ + + {linkLabel} + +
+ +
+ +
+ + + ); +} diff --git a/TruthScan AI_frontend/app/api/fetch-article-content/route.ts b/TruthScan AI_frontend/app/api/fetch-article-content/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4befe92bc93f986198b8ed43a757ce7b0e3bf3d --- /dev/null +++ b/TruthScan AI_frontend/app/api/fetch-article-content/route.ts @@ -0,0 +1,198 @@ +/** + * Endpoint API odpowiedzialny za pobieranie i ekstrakcję treści artykułów. + */ + +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json({ error: 'URL is required' }, { status: 400 }); + } + + try { + console.log('Fetching BBC article from:', url); + + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + + // Ekstrakcja treści (BBC → fallback ogólny) + let text = extractBBCArticleContent(html); + if (!text || text.length < 300) { + text = extractGenericArticleContent(html); + } + + console.log('Extracted article length:', text.length); + + return NextResponse.json({ + content: text, + success: true, + contentLength: text.length + }); + + } catch (error) { + console.error('Error fetching article:', error); + return NextResponse.json({ + error: 'Failed to fetch article content', + success: false + }, { status: 500 }); + } +} + + /** + * Ekstrakcja treści artykułu dla serwisu BBC. + */ +function extractBBCArticleContent(html: string): string { + console.log('Extracting BBC article content...'); + const bbcSelectors = [ + /]*id="main-content"[^>]*>([\s\S]*?)<\/main>/i, + /]*>([\s\S]*?)<\/article>/i, + /]*data-component="text-block"[^>]*>([\s\S]*?)<\/div>/gi, + /]*class="[^"]*ssrcss-1ocoo3l-Wrap[^"]*"[^>]*>([\s\S]*?)<\/div>/gi, + /]*class="[^"]*story-body[^"]*"[^>]*>([\s\S]*?)<\/div>/i, + /]*data-entityid="story-content"[^>]*>([\s\S]*?)<\/div>/i, + ]; + + for (const regex of bbcSelectors) { + const matches = html.match(regex); + if (matches) { + let content = ''; + if (regex.flags.includes('g')) { + for (let i = 0; i < matches.length; i++) { + content += matches[i] + ' '; + } + } else { + content = matches[1] || matches[0]; + } + + const cleaned = cleanArticleContent(content); + if (cleaned.length > 200) { + console.log('Found BBC content with selector'); + return cleaned; + } + } + } + + return ''; +} + +/** + * Ogólna metoda ekstrakcji treści dla pozostałych serwisów. + */ +function extractGenericArticleContent(html: string): string { + console.log('Using generic extraction...'); + + const genericSelectors = [ + /]*>([\s\S]*?)<\/article>/i, + /]*class="[^"]*article[^"]*"[^>]*>([\s\S]*?)<\/div>/i, + /]*class="[^"]*content[^"]*"[^>]*>([\s\S]*?)<\/div>/i, + /]*class="[^"]*post-content[^"]*"[^>]*>([\s\S]*?)<\/div>/i, + /]*class="[^"]*entry-content[^"]*"[^>]*>([\s\S]*?)<\/div>/i, + /]*class="[^"]*story[^"]*"[^>]*>([\s\S]*?)<\/div>/i, + ]; + + for (const regex of genericSelectors) { + const match = html.match(regex); + if (match && match[1]) { + const content = cleanArticleContent(match[1]); + if (content.length > 200) { + return content; + } + } + } + + const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); + if (bodyMatch && bodyMatch[1]) { + return cleanAndFilterContent(bodyMatch[1]); + } + + return cleanAndFilterContent(html); +} + +/** + * Usuwa znaczniki HTML oraz elementy nienależące do treści artykułu. + */ +function cleanArticleContent(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/]*>(.*?)<\/a>/gi, '$1') + .replace(/]*>/gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/ /g, ' ') + .trim(); +} + +/** + * Dodatkowe filtrowanie treści: + * usuwa szum informacyjny (nawigacja, reklamy, social media). + */ +function cleanAndFilterContent(text: string): string { + const noisePatterns = [ + /menu|navigation|nav|home|skip to content|skip to main|main menu/gi, + /header|footer|sidebar|side-bar|panel boczny/gi, + /share|udostępnij|comment|komentarz|like|follow|subscribe/gi, + /facebook|twitter|instagram|youtube|linkedin|social media/gi, + /reklama|advertisement|ads?|sponsored|promoted|partner/gi, + /bbc\.com|bbc\.co\.uk|bbc news|bbc sport|bbc iplayer/gi, + /home news|sport|business|innovation|culture|arts|travel/gi, + /weather|climate|audio|video|live|newsletters|podcast/gi, + /copyright|all rights reserved|privacy policy|terms of use/gi, + /cookie policy|contact us|about us|o nas|regulamin/gi, + ]; + + let cleaned = text; + + const lines = cleaned.split('\n').filter(line => { + const trimmed = line.trim(); + if (trimmed.length < 20) return false; + + for (const pattern of noisePatterns) { + if (pattern.test(trimmed)) { + return false; + } + } + + const hasProperSentence = /[.!?]/.test(trimmed) && trimmed.split(' ').length > 5; + const isNoise = /^[^a-zA-Z]*$/.test(trimmed) || trimmed.includes(''); + + return hasProperSentence && !isNoise; + }); + + cleaned = lines.join('\n'); + + for (const pattern of noisePatterns) { + cleaned = cleaned.replace(pattern, ''); + } + + return cleaned + .replace(/\s+/g, ' ') + .replace(/([.!?])\s+/g, '$1\n\n') + .trim() + .substring(0, 10000); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/dashboard/components/ArticlesModal.tsx b/TruthScan AI_frontend/app/dashboard/components/ArticlesModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2e1af9aacc2d0b76027c7c7394ca50781d0ad593 --- /dev/null +++ b/TruthScan AI_frontend/app/dashboard/components/ArticlesModal.tsx @@ -0,0 +1,83 @@ +/** + * Modal wyświetlający strumień artykułów dla wybranego źródła informacyjnego. + */ + +"use client"; + +import { useEffect } from "react"; +import LiveNewsFeed from "../../../components/LiveNewsFeed"; + +import type { Lang } from "../../../lib/types"; + +interface ArticlesModalProps { + source: string; + language: Lang; + onClose: () => void; +} + +export default function ArticlesModal({ + source, + language, + onClose, +}: ArticlesModalProps) { + // ESC + blokada scrolla z kompensacją paska + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", onKey); + + const docEl = document.documentElement; + const bodyEl = document.body; + const scrollbarWidth = window.innerWidth - docEl.clientWidth; + const prevOverflow = bodyEl.style.overflow; + const prevPaddingRight = bodyEl.style.paddingRight; + + if (scrollbarWidth > 0) bodyEl.style.paddingRight = `${scrollbarWidth}px`; + bodyEl.style.overflow = "hidden"; + + return () => { + window.removeEventListener("keydown", onKey); + bodyEl.style.overflow = prevOverflow; + bodyEl.style.paddingRight = prevPaddingRight; + }; + }, [onClose]); + + return ( +
+ +
+ +
+ +
+ +
+ +
+ + + ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/dashboard/components/ChartsSection.tsx b/TruthScan AI_frontend/app/dashboard/components/ChartsSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e0fb94d5e9dcf11e3c1b0660e336939419911a72 --- /dev/null +++ b/TruthScan AI_frontend/app/dashboard/components/ChartsSection.tsx @@ -0,0 +1,282 @@ +/** + * Sekcja dashboardu odpowiedzialna za wizualizację danych analitycznych. + * + * Komponent integruje: + * - wykres sentymentu emocjonalnego, + * - wykres prawdopodobieństwa fake newsów według źródeł, + * - mechanizm leniwego ładowania i cache po stronie klienta, + * - modal z listą artykułów dla wybranego źródła. + */ + +"use client"; + +import { useRef, useEffect, useState } from "react"; +import SourceSelector from "../../../components/SourceSelector"; +import EmotionalPieChart from "../../../components/EmotionalPieChart"; +import FakeNewsBarChart from "../../../components/FakeNewsBarChart"; +import ProgressBar from "../../../components/ProgressBar"; +import { useDashboardCharts } from "../../hooks/useDashboardCharts"; +import locales from "../../../lib/locales"; +import MiniSpinner from "./MiniSpinner"; +import ArticlesModal from "./ArticlesModal"; + +import type { Lang } from "../../../lib/types"; + +interface Props { + language: Lang; + selectedSource: string; + setSelectedSource: (source: string) => void; +} + +const DASHBOARD_CHARTS_KEY = "dashboard:charts"; + +type ChartsCache = { + barData: any[]; + emotionData: any[]; + totalSources: number; +}; + +export default function ChartsSection({ + language, + selectedSource, + setSelectedSource, +}: Props) { + const [isClient, setIsClient] = useState(false); + const [cachedCharts, setCachedCharts] = useState(null); + + const { + barData, + emotionData, + progressCount, + progressPct, + chartsLoading, + chartsError, + chartsStarted, + loadCharts, + totalSources, + hasBars, + hasEmos, + } = useDashboardCharts(language); + + const [showArticlesModal, setShowArticlesModal] = useState(false); + const chartsSectionRef = useRef(null); + const hasLoadedRef = useRef(false); + + useEffect(() => { + setIsClient(true); + + import("../../../app/stores/newsCache").then(({ useNewsCache }) => { + const cache = useNewsCache.getState(); + setCachedCharts(cache.get(DASHBOARD_CHARTS_KEY)); + }); + }, []); + + useEffect(() => { + if (!isClient || hasLoadedRef.current) return; + + const el = chartsSectionRef.current; + if (!el) return; + + if (cachedCharts) { + console.log("✅ Używam cache dla wykresów"); + return; + } + + hasLoadedRef.current = true; + + if ("IntersectionObserver" in window) { + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + console.log("🚀 Ładuję wykresy (intersection)..."); + loadCharts(); + io.disconnect(); + } + }, + { threshold: 0.05, rootMargin: "200px 0px" } + ); + io.observe(el); + return () => io.disconnect(); + } else { + const t = setTimeout(() => { + console.log("🚀 Ładuję wykresy (timeout)..."); + loadCharts(); + }, 300); + return () => clearTimeout(t); + } + }, [isClient, cachedCharts, loadCharts]); + + useEffect(() => { + if (!isClient || !chartsStarted || chartsLoading || chartsError || (!hasBars && !hasEmos)) { + return; + } + + if (!barData?.length && !emotionData?.length) return; + + import("../../../app/stores/newsCache").then(({ useNewsCache }) => { + const cache = useNewsCache.getState(); + + const currentCache = cache.get(DASHBOARD_CHARTS_KEY); + const areDataEqual = + JSON.stringify(currentCache?.barData) === JSON.stringify(barData) && + JSON.stringify(currentCache?.emotionData) === JSON.stringify(emotionData); + + if (areDataEqual) return; + + const payload: ChartsCache = { + barData, + emotionData, + totalSources, + }; + cache.set(DASHBOARD_CHARTS_KEY, payload); + setCachedCharts(payload); + }); + }, [ + isClient, + chartsStarted, + chartsLoading, + chartsError, + hasBars, + hasEmos, + barData, + emotionData, + totalSources, + ]); + + if (!isClient) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + const effectiveBarData = cachedCharts?.barData ?? barData; + const effectiveEmotionData = cachedCharts?.emotionData ?? emotionData; + const effectiveTotalSources = cachedCharts?.totalSources ?? totalSources; + + const effectiveHasBars = cachedCharts + ? cachedCharts.barData.length > 0 + : hasBars; + const effectiveHasEmos = cachedCharts + ? cachedCharts.emotionData.length > 0 + : hasEmos; + + const effectiveChartsStarted = cachedCharts ? true : chartsStarted; + const effectiveChartsLoading = cachedCharts ? false : chartsLoading; + const effectiveProgressCount = cachedCharts + ? effectiveTotalSources + : progressCount; + const effectiveProgressPct = cachedCharts ? 100 : progressPct; + + const t = locales[language] ?? locales.pl; + + return ( + <> +
+
+
+

{t.selectSource}

+ + + {selectedSource && ( + + )} +
+ +
+ {effectiveChartsStarted && ( + + )} + + {chartsError ? ( +
{chartsError}
+ ) : effectiveHasEmos ? ( + + ) : effectiveChartsLoading ? ( + + ) : null} +
+
+ +
+ {effectiveChartsStarted && ( + + )} + + {chartsError ? ( +
{chartsError}
+ ) : effectiveHasBars ? ( + + ) : effectiveChartsLoading ? ( + + ) : null} +
+
+ + {showArticlesModal && ( + setShowArticlesModal(false)} + /> + )} + + ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/dashboard/components/DashboardHeader.tsx b/TruthScan AI_frontend/app/dashboard/components/DashboardHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1c6569b461fd397998dc61b2ef38bc6b1eedad8b --- /dev/null +++ b/TruthScan AI_frontend/app/dashboard/components/DashboardHeader.tsx @@ -0,0 +1,62 @@ +/** + * Nagłówek dashboardu prezentujący nazwę systemu + * oraz opis jego funkcjonalności w zależności od języka interfejsu. + */ + +"use client"; + +import type { Lang } from "../../../lib/types"; + +interface Props { + language: Lang; + hydrated: boolean; +} + +export default function DashboardHeader({ language, hydrated }: Props) { + const isPl = language === "pl"; + const isNo = language === "no"; + + return ( +
+ {/* Gradientowy nagłówek AI */} +

+ + TruthScan AI +

+ +

+ {hydrated + ? isPl + ? "Narzędzie do analizy wiadomości z wielu źródeł, które pomaga ocenić emocjonalny wydźwięk treści oraz wykrywać potencjalnie wprowadzające w błąd informacje." + : isNo + ? "Verktøy for nyhetsanalyse fra flere kilder som hjelper deg å vurdere den emosjonelle tonen og oppdage potensielt villedende informasjon." + : "A tool for analyzing news from multiple sources that helps assess emotional tone and detect potentially misleading content." + : ""} +

+ +

+ {hydrated + ? isPl + ? "W oparciu o automatyczną analizę treści TruthScan AI prezentuje wyniki w formie przejrzystych wykresów i zestawienia artykułów." + : isNo + ? "Basert på automatisk innholdsanalyse presenterer TruthScan AI resultater i form av tydelige diagrammer og artikkeloversikter." + : "Using automated content analysis, TruthScan AI presents results through clear charts and structured article summaries." + : ""} +

+
+ ); +} + + diff --git a/TruthScan AI_frontend/app/dashboard/components/MiniSpinner.tsx b/TruthScan AI_frontend/app/dashboard/components/MiniSpinner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3fe212538e2fa3c1402e6d3de5c93cab46ea44d2 --- /dev/null +++ b/TruthScan AI_frontend/app/dashboard/components/MiniSpinner.tsx @@ -0,0 +1,19 @@ +/** + * Mały komponent prezentacyjny wyświetlający wskaźnik ładowania + * wraz z krótkim komunikatem tekstowym. + */ + +"use client"; + +interface Props { + text: string; +} + +export default function MiniSpinner({ text }: Props) { + return ( +
+
+ {text} +
+ ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/dashboard/components/SourceComparison.tsx b/TruthScan AI_frontend/app/dashboard/components/SourceComparison.tsx new file mode 100644 index 0000000000000000000000000000000000000000..59308284496453a4cdc9cb7c436b070ddf72f5a3 --- /dev/null +++ b/TruthScan AI_frontend/app/dashboard/components/SourceComparison.tsx @@ -0,0 +1,53 @@ +/** + * Sekcja dashboardu umożliwiająca porównanie wielu źródeł informacyjnych. + * + * Renderuje zestaw bloków źródeł, pozwalając użytkownikowi analizować + * i porównywać dane pochodzące z różnych serwisów informacyjnych. + */ + +"use client"; + +import { useState } from "react"; +import SourceBlock from "../../../components/SourceBlock"; +import locales from "../../../lib/locales"; + +import type { Lang } from "../../../lib/types"; + +interface Props { + language: Lang; +} + +const ALL_SOURCES = [ + "BBC", "CNN", "NYTimes", "Guardian", "AlJazeera", + "Money", "PolsatNews", "GazetaPrawna", "SpidersWeb", "Bankier", + "NRK", "VG", "Dagbladet", "Aftenposten", +] as const; + +export default function SourceComparison({ language }: Props) { + const [compareSources, setCompareSources] = useState([ + ...ALL_SOURCES, + ]); + + const updateCompareSource = (idx: number, src: string) => + setCompareSources((prev) => prev.map((s, i) => (i === idx ? src : s))); + + return ( +
+

+ {language === "pl" ? "Porównanie źródeł" : language === "no" ? "Kildesammenligning" : "Sources comparison"} +

+ +
+ {compareSources.map((src, idx) => ( + updateCompareSource(idx, next)} + language={language} + locales={locales} + /> + ))} +
+
+ ); +} diff --git a/TruthScan AI_frontend/app/dashboard/error.tsx b/TruthScan AI_frontend/app/dashboard/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ee3ddf3a04bc2216e4d81cc75edacd2292945d5 --- /dev/null +++ b/TruthScan AI_frontend/app/dashboard/error.tsx @@ -0,0 +1,33 @@ +/** + * Komponent obsługi błędów dla widoku dashboardu. + */ + +"use client"; + +interface Props { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function ErrorPage({ error, reset }: Props) { + return ( +
+

+ ❌ {typeof window !== "undefined" && localStorage.getItem("lang") === "en" + ? "An error occurred in the dashboard" + : "Wystąpił błąd w dashboardzie"} +

+ +

{error.message}

+ + +
+ ); +} diff --git a/TruthScan AI_frontend/app/dashboard/loading.tsx b/TruthScan AI_frontend/app/dashboard/loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa57bdba47b1f8217bbc9380bea196320722a94a --- /dev/null +++ b/TruthScan AI_frontend/app/dashboard/loading.tsx @@ -0,0 +1,21 @@ +/** + * Komponent stanu ładowania dla widoku dashboardu. + */ + +export default function Loading() { + return ( +
+ +
+
+
+
+ +

+ ⏳ Ładowanie dashboardu... +

+
+ ); +} + + diff --git a/TruthScan AI_frontend/app/dashboard/page.tsx b/TruthScan AI_frontend/app/dashboard/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..04d02959b5bac535acb29e231a29f397cc7fbe87 --- /dev/null +++ b/TruthScan AI_frontend/app/dashboard/page.tsx @@ -0,0 +1,71 @@ +/** + * Główna strona dashboardu analitycznego aplikacji. + */ + +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCcw } from "lucide-react"; +import { useLanguage } from "../hooks/useLanguage"; +import DashboardHeader from "./components/DashboardHeader"; +import ChartsSection from "./components/ChartsSection"; +import SourceComparison from "./components/SourceComparison"; + + + +import { useNewsCache, newsKey } from "../../app/stores/newsCache"; + +export default function DashboardPage() { + const [hydrated, setHydrated] = useState(false); + const { language } = useLanguage(); + const [selectedSource, setSelectedSource] = useState("BBC"); + const cache = useNewsCache(); + const [reloadKey, setReloadKey] = useState(0); + const REFRESH_TEXT = { + pl: "Odśwież ", + en: "Refresh ", + no: "Oppdater ", +} as const; + + + useEffect(() => { + setHydrated(true); + }, []); + + const handleRefresh = () => { + // Usunięcie danych z cache i ponowne pobranie + cache.del(newsKey(selectedSource, language)); + setReloadKey((k) => k + 1); + }; + + return ( +
+
+ +
+ +
+
+ + + + +
+ ); +} + + \ No newline at end of file diff --git a/TruthScan AI_frontend/app/error.tsx b/TruthScan AI_frontend/app/error.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17411137cd341393c71a027b5e179c2781184a92 --- /dev/null +++ b/TruthScan AI_frontend/app/error.tsx @@ -0,0 +1,39 @@ +/** + * Globalny komponent obsługi błędów aplikacji (Next.js App Router). + */ + +"use client"; + +interface Props { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function GlobalError({ error, reset }: Props) { + + const lang = + typeof window !== "undefined" && localStorage.getItem("lang") === "en" + ? "en" + : "pl"; + + return ( + + +

+ ❌ {lang === "en" + ? "An unexpected error occurred" + : "Wystąpił nieoczekiwany błąd"} +

+ +

{error.message}

+ + + + + ); +} diff --git a/TruthScan AI_frontend/app/hooks/useArticleContent.ts b/TruthScan AI_frontend/app/hooks/useArticleContent.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3f50793bfa55139c8374ae727979f78b9c1731f --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/useArticleContent.ts @@ -0,0 +1,121 @@ +/** + * Hook odpowiedzialny za pobieranie i wstępne czyszczenie treści artykułów. + */ + +import { useCallback } from 'react'; + +export const useArticleContent = () => { + const fetchFullContent = useCallback(async (url: string): Promise => { + try { + console.log("🔄 Pobieranie pełnej treści z:", url); + // Pobranie treści artykułu przez proxy (obejście CORS) + const proxyUrl = `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(url)}`; + + const response = await fetch(proxyUrl, { + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml', + 'Accept-Language': 'pl,en-US;q=0.7,en;q=0.3', + }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const html = await response.text(); + + if (!html || html.length < 500) { + console.log("⚠️ Treść zbyt krótka lub pusta"); + return ""; + } + // Parsowanie HTML i usunięcie elementów nienależących do treści + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const elementsToRemove = doc.querySelectorAll( + 'script, style, nav, header, footer, aside, .ad, .ads, [class*="ad-"], .navigation, .menu, .social, .share, .comments, iframe' + ); + elementsToRemove.forEach(el => el.remove()); + + // Próba znalezienia głównej treści artykułu + const contentSelectors = [ + 'article', + '.article-content', + '.post-content', + '.entry-content', + '.story-content', + '.news-content', + '.content-area', + '[role="main"]', + 'main', + '.content', + '.story__content', + '.article-body', + '.article-text', + '.post-body', + '.news-body', + '.td-post-content', + '.single-content', + '.article__content' + ]; + + let contentElement = null; + for (const selector of contentSelectors) { + const element = doc.querySelector(selector); + if (element && element.textContent && element.textContent.length > 200) { + contentElement = element; + break; + } + } + + let content = ''; + if (contentElement) { + content = contentElement.textContent || ''; + } else { + const paragraphs = doc.querySelectorAll('p'); + const paragraphTexts = Array.from(paragraphs) + .map(p => p.textContent?.trim()) + .filter(text => text && text.length > 50) + .slice(0, 20); + + content = paragraphTexts.join('\n\n'); + } + + content = content + .replace(/\s+/g, ' ') + .replace(/\n\s*\n/g, '\n\n') + .trim(); + + if (content.length < 100) { + console.log("⚠️ Za mało treści, próbuję fallback..."); + const metaDescription = doc.querySelector('meta[name="description"]')?.getAttribute('content'); + const ogDescription = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); + + content = metaDescription || ogDescription || doc.title || ''; + } + + console.log("✅ Pobrano treść, długość:", content.length); + return content; + + } catch (error) { + console.error("❌ Błąd pobierania treści:", error); + + try { + console.log("🔄 Próba bez proxy..."); + const directResponse = await fetch(url, { + mode: 'no-cors', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + // Fallback w przypadku błędu pobierania treści + return ""; + } catch (directError) { + console.error("❌ Błąd przy próbie bez proxy:", directError); + return ""; + } + } + }, []); + + return { fetchFullContent }; +}; \ No newline at end of file diff --git a/TruthScan AI_frontend/app/hooks/useCachedEmotionStats.ts b/TruthScan AI_frontend/app/hooks/useCachedEmotionStats.ts new file mode 100644 index 0000000000000000000000000000000000000000..53ec478a1484833ea5393167bc1af6f924658ed0 --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/useCachedEmotionStats.ts @@ -0,0 +1,63 @@ +/** + * Hook pobierający statystyki emocji dla źródła z wykorzystaniem cache po stronie klienta. + */ + +"use client"; + +import { useEffect, useState } from "react"; +import { useNewsCache, statsKey } from "../../app/stores/newsCache"; + +type EmotionStats = { + source: string; + total_articles: number; + emotion_counts: { + Pozytywne: number; + Neutralne: number; + Negatywne: number; + }; + emotion_percentages: { + Pozytywne: number; + Neutralne: number; + Negatywne: number; + }; +}; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000"; + +export function useCachedEmotionStats(source: string) { + const cache = useNewsCache(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!source) return; + + const key = statsKey(source); + const cached = cache.get(key); + + // Jeśli dane są w cache, pomijamy request do API + if (cached) { + setData(cached); + setLoading(false); + return; + } + + (async () => { + try { + setLoading(true); + const res = await fetch(`${API_URL}/emotion-stats/${encodeURIComponent(source)}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = (await res.json()) as EmotionStats; + setData(json); + cache.set(key, json); + } catch (e: any) { + setError(e?.message ?? "fetch error"); + } finally { + setLoading(false); + } + })(); + }, [source]); + + return { data, loading, error }; +} diff --git a/TruthScan AI_frontend/app/hooks/useDashboardCharts.ts b/TruthScan AI_frontend/app/hooks/useDashboardCharts.ts new file mode 100644 index 0000000000000000000000000000000000000000..4daf1872650b9037012aebd0311fea1435dbcfd6 --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/useDashboardCharts.ts @@ -0,0 +1,102 @@ +/** + * Hook odpowiedzialny za pobieranie i agregację danych do wykresów dashboardu. + */ + +import { useCallback, useRef, useState } from "react"; +import { fetchOneSource, NewsData } from "../../lib/fetchNews"; +import type { Lang } from "../../lib/types"; + +const ALL_SOURCES = [ + "BBC", "CNN", "NYTimes", "Guardian", "AlJazeera", + "Money", "PolsatNews", "GazetaPrawna", "SpidersWeb", "Bankier", + "NRK", "VG", "Dagbladet", "Aftenposten", +] as const; + +export function useDashboardCharts(language: Lang) { + const [barData, setBarData] = useState<{ label: string; value: number }[]>([]); + const [emotionData, setEmotionData] = useState<{ name: string; value: number }[]>([]); + const [progressCount, setProgressCount] = useState(0); + const [chartsLoading, setChartsLoading] = useState(false); + const [chartsError, setChartsError] = useState(null); + + // Struktury w useRef – pozwalają aktualizować dane bez nadmiarowych re-renderów + const barsMapRef = useRef>(new Map()); + const emosMapRef = useRef>({ + POSITIVE: 0, NEGATIVE: 0, NEUTRAL: 0 + }); + const chartsStartedRef = useRef(false); + + const progressPct = Math.round((progressCount / ALL_SOURCES.length) * 100); + + const normalizeSent = (s: string): "POSITIVE" | "NEGATIVE" | "NEUTRAL" => { + const k = (s || "").trim().toUpperCase(); + if (k === "POZYTYWNE" || k === "POSITIVE" || k === "POSITIVT") return "POSITIVE"; + if (k === "NEGATYWNE" || k === "NEGATIVE" || k === "NEGATIVT") return "NEGATIVE"; + if (k === "NEUTRALNE" || k === "NEUTRAL" || k === "NØYTRALT") return "NEUTRAL"; + return "NEUTRAL"; + }; + + const refreshChartsFromRefs = () => { + const order = new Map(ALL_SOURCES.map((s, i) => [s as string, i])); + const arr = Array.from(barsMapRef.current.entries()).map(([label, value]) => ({ + label, value, + })); + arr.sort((a, b) => (order.get(a.label) ?? 999) - (order.get(b.label) ?? 999)); + setBarData(arr); + + const emoArr = [ + { name: "POSITIVE", value: emosMapRef.current.POSITIVE }, + { name: "NEUTRAL", value: emosMapRef.current.NEUTRAL }, + { name: "NEGATIVE", value: emosMapRef.current.NEGATIVE } + ]; + setEmotionData(emoArr); + }; + + const loadCharts = useCallback(async () => { + if (chartsStartedRef.current) return; + chartsStartedRef.current = true; + setChartsLoading(true); + setChartsError(null); + setProgressCount(0); + barsMapRef.current.clear(); + emosMapRef.current = { POSITIVE: 0, NEGATIVE: 0, NEUTRAL: 0 }; + + const promises = ALL_SOURCES.map(async (src) => { + try { + const { news, emotions } = await fetchOneSource(src, language); + + if (news) { + barsMapRef.current.set(news.source, parseFloat(news.fake_news)); + } + + for (const [emotionKey, count] of Object.entries(emotions)) { + const normalized = normalizeSent(emotionKey); + emosMapRef.current[normalized] = (emosMapRef.current[normalized] || 0) + (count as number); + } + + refreshChartsFromRefs(); + setProgressCount((c) => c + 1); + } catch (error) { + console.error(`Error loading source ${src}:`, error); + setProgressCount((c) => c + 1); + } + }); + + await Promise.allSettled(promises); + setChartsLoading(false); + }, [language]); + + return { + barData, + emotionData, + progressCount, + progressPct, + chartsLoading, + chartsError, + chartsStarted: chartsStartedRef.current, + loadCharts, + totalSources: ALL_SOURCES.length, + hasBars: barData.length > 0, + hasEmos: emotionData.length > 0, + }; +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/hooks/useFavicon.ts b/TruthScan AI_frontend/app/hooks/useFavicon.ts new file mode 100644 index 0000000000000000000000000000000000000000..d004b4fd396930f85216cc3081937ba57b9d8f0b --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/useFavicon.ts @@ -0,0 +1,73 @@ +/** + * Hook odpowiedzialny za ustalenie i fallback ikonki (favicon) źródła artykułu. + */ + +import { useState, useEffect, useMemo } from 'react'; +import { Article } from '../../lib/fetchNews'; + +const SOURCE_DOMAINS: Record = { + bbc: "bbc.com", + cnn: "cnn.com", + nytimes: "nytimes.com", + newyorktimes: "nytimes.com", + guardian: "theguardian.com", + theguardian: "theguardian.com", + aljazeera: "aljazeera.com", + dziennik: "dziennik.pl", + polsatnews: "polsatnews.pl", + gazetaprawna: "gazetaprawna.pl", + spidersweb: "spidersweb.pl", + bankier: "bankier.pl", +}; + +function normalizeKey(s?: string) { + return (s || "") + .toLowerCase() + .normalize("NFKD") + .replace(/[''`"]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim() + .replace(/\s+/g, ""); +} + +export function useFavicon(article: Article) { + // Lista potencjalnych favicon z fallbackami + const candidates = useMemo(() => { + const cand: string[] = []; + const s2 = (host: string) => `https://www.google.com/s2/favicons?domain=${host}&sz=64`; + const ddg = (host: string) => `https://icons.duckduckgo.com/ip3/${host}.ico`; + + if (article.link) { + try { + const raw = article.link.startsWith("http") ? article.link : `https://${article.link}`; + const host = new URL(raw).hostname; + if (host) cand.push(s2(host), ddg(host)); + } catch {} + } + + const srcKey = normalizeKey(article.source); + const mapped = SOURCE_DOMAINS[srcKey]; + if (mapped) cand.push(s2(mapped), ddg(mapped)); + if (!mapped && srcKey) { + cand.push(s2(`${srcKey}.com`), ddg(`${srcKey}.com`)); + cand.push(s2(`${srcKey}.pl`), ddg(`${srcKey}.pl`)); + } + + return [...new Set(cand)]; + }, [article]); + + const [currentIndex, setCurrentIndex] = useState(0); + const currentSrc = candidates[currentIndex]; + const sourceInitial = (article.source || "?").slice(0, 1).toUpperCase(); + + useEffect(() => setCurrentIndex(0), [candidates.join("|")]); + + const nextFavicon = () => setCurrentIndex(prev => prev + 1); + + return { + faviconSrc: currentSrc, + sourceInitial, + hasFavicon: !!currentSrc, + nextFavicon + }; +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/hooks/useLanguage.ts b/TruthScan AI_frontend/app/hooks/useLanguage.ts new file mode 100644 index 0000000000000000000000000000000000000000..5910907f17d351924d90a4d4c69770350680a44a --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/useLanguage.ts @@ -0,0 +1,54 @@ +/** + * Hook zarządzający językiem interfejsu aplikacji. + */ + +import { useState, useEffect, useRef } from 'react'; + +import type { Lang } from "../../lib/types"; + +const isLang = (v: unknown): v is Lang => v === "pl" || v === "en" || v === "no"; + +export function useLanguage() { + const [language, setLanguage] = useState("pl"); + const langRef = useRef("pl"); + + useEffect(() => { + const htmlLang = document.documentElement.lang; + if (isLang(htmlLang)) { + setLanguage(htmlLang); + langRef.current = htmlLang; + } + + const stored = localStorage.getItem("lang"); + if (isLang(stored)) { + setLanguage(stored); + langRef.current = stored; + } + + // Synchronizacja zmiany języka między komponentami i kartami przeglądarki + const onCustom = (e: Event) => { + const next = (e as CustomEvent).detail; + if (isLang(next)) { + setLanguage(next); + langRef.current = next; + } + }; + + const onStorage = (e: StorageEvent) => { + if (e.key === "lang" && isLang(e.newValue)) { + setLanguage(e.newValue); + langRef.current = e.newValue; + } + }; + + window.addEventListener("app:langchange", onCustom as EventListener); + window.addEventListener("storage", onStorage); + + return () => { + window.removeEventListener("app:langchange", onCustom as EventListener); + window.removeEventListener("storage", onStorage); + }; + }, []); + + return { language, langRef }; +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/hooks/useNewsCache.ts b/TruthScan AI_frontend/app/hooks/useNewsCache.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ad83696f2330dc15ce3407b2c8c7975779e72d4 --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/useNewsCache.ts @@ -0,0 +1,87 @@ +/** + * Hook pobierający listę newsów dla wybranego źródła. + * Wykorzystuje cache po stronie klienta w celu ograniczenia liczby zapytań do API. + */ + +import { useEffect, useState } from "react"; +import { useNewsCache, newsKey } from "../../app/stores/newsCache"; +import type { Lang } from "../../lib/types"; + +type Article = { + title: string; + link: string; + summary: string; + published: string; + source: string; + sentiment: string; + fake_probability: number; + sentiment_score?: number; +}; + +type NewsResponse = { source: string; articles: Article[] }; + +export function useCachedNews(source: string, lang: Lang = "pl") { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const cache = useNewsCache(); + + useEffect(() => { + let mounted = true; + const key = newsKey(source, lang); + + // Odczyt danych z cache przed wykonaniem zapytania HTTP + const cached = cache.get(key); + if (cached) { + setData(cached); + setLoading(false); + return; + } + + (async () => { + try { + setLoading(true); + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/news/${encodeURIComponent(source)}?lang=${lang}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json: NewsResponse = await res.json(); + const articles = json.articles ?? []; + if (!mounted) return; + setData(articles); + + // Zapis danych w cache po poprawnym pobraniu + cache.set(key, articles); + } catch (e: any) { + if (!mounted) return; + setError(e?.message ?? "fetch error"); + } finally { + if (mounted) setLoading(false); + } + })(); + + return () => { + // Zabezpieczenie przed aktualizacją stanu po odmontowaniu komponentu + mounted = false; + }; + }, [source, lang]); + + const refresh = async () => { + const key = newsKey(source, lang); + cache.del(key); + setData(null); + setLoading(true); + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/news/${encodeURIComponent(source)}?lang=${lang}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json: NewsResponse = await res.json(); + const articles = json.articles ?? []; + setData(articles); + cache.set(key, articles); + } catch (e: any) { + setError(e?.message ?? "fetch error"); + } finally { + setLoading(false); + } + }; + + return { data, loading, error, refresh }; +} diff --git a/TruthScan AI_frontend/app/hooks/useNewsStream.ts b/TruthScan AI_frontend/app/hooks/useNewsStream.ts new file mode 100644 index 0000000000000000000000000000000000000000..9db68e08ee9b7bad4a076765797a8a298bd544b0 --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/useNewsStream.ts @@ -0,0 +1,128 @@ +/** + * Hook obsługujący strumieniowe pobieranie newsów (SSE) dla wybranego źródła. + */ + +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { Article } from "@/lib/fetchNews"; +import { useNewsCache, newsKey } from "../../app/stores/newsCache"; +import type { Lang } from "../../lib/types"; + +type StreamState = { + articles: Article[]; + loading: boolean; + error: string | null; + progress: number; + total: number; +}; + +export function useNewsStream(source: string, lang: Lang = "pl", limit: number = 5): StreamState { + const [state, setState] = useState({ + articles: [], + loading: true, + error: null, + progress: 0, + total: 0, + }); + + const esRef = useRef(null); + const cache = useNewsCache(); + + useEffect(() => { + if (!source) return; + + const cacheKey = newsKey(source, lang); + const cached = cache.get(cacheKey); + + // Jeżeli dane są w cache, pomijamy połączenie SSE + if (cached && cached.length > 0) { + setState({ + articles: cached, + loading: false, + error: null, + progress: cached.length, + total: cached.length, + }); + return; + } + + setState({ + articles: [], + loading: true, + error: null, + progress: 0, + total: 0, + }); + + const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000"; + const es = new EventSource(`${apiBase}/stream-news/${encodeURIComponent(source)}?lang=${lang}`); + esRef.current = es; + + let collected: Article[] = []; + + const handleMeta = (e: MessageEvent) => { + try { + const { total } = JSON.parse(e.data); + setState((s) => ({ ...s, total: Math.min(total ?? 0, limit) })); + } catch { + } + }; + + const handleBackendError = (e: MessageEvent) => { + const msg = (() => { + try { + return JSON.parse(e.data)?.message || "Backend error"; + } catch { + return "Backend error"; + } + })(); + setState((s) => ({ ...s, loading: false, error: msg })); + }; + + const handleMessage = (ev: MessageEvent) => { + try { + const article = JSON.parse(ev.data) as Article; + collected.push(article); + setState((s) => { + const next = [...s.articles, article].slice(0, limit); + return { + ...s, + articles: next, + progress: Math.min(next.length, s.total || limit), + }; + }); + } catch { + } + }; + + const handleDone = () => { + setState((s) => ({ ...s, loading: false })); + // Zapis pełnego wyniku do cache po zakończeniu strumienia + if (collected.length > 0) { + cache.set(cacheKey, collected); + } + es.close(); + }; + + const handleError = () => { + setState((s) => ({ ...s, loading: false, error: "Stream error" })); + es.close(); + }; + + es.addEventListener("meta", handleMeta); + es.addEventListener("backend_error", handleBackendError); + es.addEventListener("done", handleDone); + es.onmessage = handleMessage; + es.onerror = handleError; + + return () => { + es.removeEventListener("meta", handleMeta); + es.removeEventListener("backend_error", handleBackendError); + es.removeEventListener("done", handleDone); + es.close(); + }; + }, [source, lang, limit]); + + return state; +} diff --git a/TruthScan AI_frontend/app/hooks/usePDFExport.ts b/TruthScan AI_frontend/app/hooks/usePDFExport.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3334465f7dbcc2c6e70f02c268b265c6222a928 --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/usePDFExport.ts @@ -0,0 +1,160 @@ +/** + * Hook umożliwiający eksport wybranego fragmentu interfejsu do PDF (druk przeglądarki). + */ + +import { useCallback } from 'react'; + +interface PDFOptions { + title?: string; + margins?: string; +} + +export const usePDFExport = ( + componentRef: React.RefObject, + options: PDFOptions = {} +) => { + const { + title = 'ThruScan_Analysis', + margins = '15mm' + } = options; + + const handlePrint = useCallback((): Promise => { + return new Promise((resolve, reject) => { + if (!componentRef.current) { + reject(new Error('Component reference is not available')); + return; + } + + const printWindow = window.open('', '_blank'); + + // Fallback: jeśli okno nie może zostać otwarte (blokada popupów), + // zapisujemy zawartość jako statyczny plik HTML + if (!printWindow) { + const content = componentRef.current.innerHTML; + const blob = new Blob([` + + + + + ${title} + + + ${content} + + `], { type: 'text/html' }); + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${title}.html`; + link.click(); + URL.revokeObjectURL(url); + resolve(); + return; + } + + try { + const content = componentRef.current.cloneNode(true) as HTMLDivElement; + + // Usunięcie elementów interaktywnych z eksportowanego widoku + const interactiveElements = content.querySelectorAll('button, input, select, textarea, .pdf-generator-controls'); + interactiveElements.forEach(el => el.remove()); + + const style = ` + + `; + + printWindow.document.write(` + + + + ${title} + + + ${style} + + + ${content.innerHTML} + + + `); + + printWindow.document.close(); + + setTimeout(() => { + printWindow.focus(); + printWindow.print(); + + const checkPrint = setInterval(() => { + if (printWindow.closed) { + clearInterval(checkPrint); + resolve(); + } + }, 100); + + setTimeout(() => { + if (!printWindow.closed) { + printWindow.close(); + resolve(); + } + }, 10000); + + }, 1000); + + } catch (error) { + printWindow?.close(); + reject(error); + } + }); + }, [componentRef, title, margins]); + + return handlePrint; +}; \ No newline at end of file diff --git a/TruthScan AI_frontend/app/hooks/useSentiment.ts b/TruthScan AI_frontend/app/hooks/useSentiment.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c2987852baa3dcb6e7962048e57538ff620d7fb --- /dev/null +++ b/TruthScan AI_frontend/app/hooks/useSentiment.ts @@ -0,0 +1,40 @@ +/** + * Hook mapujący wynik analizy sentymentu na etykietę i kolor interfejsu. + */ + +import { useMemo } from 'react'; + +export interface SentimentResult { + label: string; + color: string; +} + +export function useSentiment(sentiment: string, language: "pl" | "en" | "no"): SentimentResult { + return useMemo(() => { + const sentimentUpper = (sentiment || "").toUpperCase(); + + // Normalizacja wyniku sentymentu do formy prezentacyjnej (UI) + const label = language === "pl" + ? sentimentUpper.includes("POS") ? "Pozytywne" + : sentimentUpper.includes("NEG") ? "Negatywne" + : "Neutralne" + : language === "no" + ? sentimentUpper.includes("POS") ? "Positivt" + : sentimentUpper.includes("NEG") ? "Negativt" + : "Nøytralt" + : sentimentUpper.includes("POS") ? "Positive" + : sentimentUpper.includes("NEG") ? "Negative" + : "Neutral"; + + const positiveLabel = language === "pl" ? "Pozytywne" : language === "no" ? "Positivt" : "Positive"; + const negativeLabel = language === "pl" ? "Negatywne" : language === "no" ? "Negativt" : "Negative"; + + const color = label === positiveLabel + ? "text-green-500" + : label === negativeLabel + ? "text-red-500" + : "text-blue-400"; + + return { label, color }; + }, [sentiment, language]); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/layout.tsx b/TruthScan AI_frontend/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..611a8419e828f7fe2a398a949ddd4ce1f56e3c2c --- /dev/null +++ b/TruthScan AI_frontend/app/layout.tsx @@ -0,0 +1,28 @@ +/** + * Główny layout aplikacji frontendowej (Next.js App Router). + */ + +import "../styles/globals.css"; +import NavBar from "../components/NavBar"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
+ + +
{children}
+ +
+ © {new Date().getFullYear()} TruthScan AI +
+
+ + + ); +} diff --git a/TruthScan AI_frontend/app/page.tsx b/TruthScan AI_frontend/app/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d709a7921dc24b348b02de1bd3c9db2966db1ba2 --- /dev/null +++ b/TruthScan AI_frontend/app/page.tsx @@ -0,0 +1,331 @@ +/** + * Strona główna aplikacji TruthScan AI. + */ + +"use client"; + +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import locales from "../lib/locales"; +import TipModal from "../app/TipModel"; +import type { Lang } from "../lib/types"; + +export default function HomePage() { + + const [language, setLanguage] = useState("pl"); + const [activeTip, setActiveTip] = useState(null); + + useEffect(() => { + // Synchronizacja języka interfejsu (localStorage + zdarzenie globalne) + const stored = localStorage.getItem("lang"); + if (stored === "pl" || stored === "en" || stored === "no") setLanguage(stored as Lang); + + const onLangChange = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail === "pl" || detail === "en" || detail === "no") setLanguage(detail as Lang); + }; + window.addEventListener("app:langchange", onLangChange as EventListener); + + return () => window.removeEventListener("app:langchange", onLangChange as EventListener); + }, []); + + const t = locales[language]; + const T = (pl: string, no: string, en: string) => + language === "pl" ? pl : language === "no" ? no : en; + + const tips = [ + { + pl: "Sprawdź źródło artykułu", + no: "Sjekk kilden til artikkelen", + en: "Check the article source", + morePl: "Zwróć uwagę na autora i wydawcę. Sprawdź czy strona ma sekcję 'O nas', dane kontaktowe i politykę redakcyjną. Unikaj anonimowych blogów i stron bez informacji o zespole. Prawdziwe media zwykle podają swoje dane i mają przejrzystą strukturę własności.", + moreNo: "Vær oppmerksom på forfatteren og utgiveren. Sjekk om nettstedet har en 'Om oss'-seksjon, kontaktinformasjon og redaksjonell policy. Unngå anonyme blogger og nettsteder uten teaminformasjon. Legitime medier oppgir vanligvis sine data og har en gjennomsiktig eierskapsstruktur.", + moreEn: "Pay attention to the author and publisher. Check if the site has an 'About us' section, contact details and editorial policy. Avoid anonymous blogs and sites without team information. Legitimate media usually provide their data and have transparent ownership structure.", + link: { href: "https://demagog.org.pl/edukacja/", labelPl: "Demagog: Jak weryfikować źródła", labelNo: "Demagog: Slik verifiserer du kilder", labelEn: "Demagog: How to verify sources" }, + icon: "🔍" + }, + { + pl: "Analizuj emocjonalny język", + no: "Analyser emosjonelt språk", + en: "Analyze emotional language", + morePl: "Teksty pełne wykrzykników, CAPS LOCKA i ostrych epitetów często manipulują emocjami. Prawdziwe newsy starają się być neutralne. Zwróć uwagę na clickbaitowe nagłówki obiecujące szokujące rewelacje bez pokrycia w treści.", + moreNo: "Tekster fulle av utropstegn, STORE BOKSTAVER og harde epiteter manipulerer ofte følelser. Ekte nyheter prøver å være nøytrale. Vær oppmerksom på clickbait-overskrifter som lover sjokkerende avsløringer uten innhold.", + moreEn: "Texts full of exclamation marks, ALL CAPS and harsh epithets often manipulate emotions. Real news tries to be neutral. Pay attention to clickbait headlines promising shocking revelations without substance.", + link: { href: "https://www.snopes.com/collections/fact-checking-101/", labelPl: "Snopes: Podstawy fact-checkingu", labelNo: "Snopes: Grunnleggende fakta-sjekk", labelEn: "Snopes: Fact-checking basics" }, + icon: "🎭" + }, + { + pl: "Weryfikuj w wielu źródłach", + no: "Verifiser i flere kilder", + en: "Verify in multiple sources", + morePl: "Nie polegaj na jednym źródle. Sprawdź tę samą informację w co najmniej 2-3 wiarygodnych mediach. Użyj wyszukiwarki z operatorami jak 'site:.gov.pl' lub 'site:.edu'. Sprawdź datę publikacji - stare newsy często są udostępniane jako aktualne.", + moreNo: "Ikke stol på én kilde. Sjekk samme informasjon i minst 2-3 pålitelige medier. Bruk søkemotorer med operatorer som 'site:.gov.no' eller 'site:.edu'. Sjekk publiseringsdatoen - gamle nyheter deles ofte som nye.", + moreEn: "Don't rely on one source. Check the same information in at least 2-3 reliable media. Use search engines with operators like 'site:.gov' or 'site:.edu'. Check the publication date - old news is often shared as current.", + link: { href: "https://factcheck.afp.com/", labelPl: "AFP Fact Check", labelNo: "AFP Fact Check", labelEn: "AFP Fact Check" }, + icon: "📡" + }, + { + pl: "Sprawdź dowody i cytaty", + no: "Sjekk bevis og sitater", + en: "Check evidence and quotes", + morePl: "Prawdziwe artykuły zawierają konkretne dane, statystyki, cytaty ekspertów i odnośniki do badań. Fake newsy operują ogólnikami i emocjami. Sprawdź czy cytowane osoby rzeczywiście istnieją i czy zostały poprawnie przytoczone.", + moreNo: "Ekte artikler inneholder spesifikke data, statistikk, ekspertsitater og henvisninger til forskning. Falske nyheter opererer med generaliseringer og følelser. Sjekk om siterte personer faktisk eksisterer og ble korrekt sitert.", + moreEn: "Real articles contain specific data, statistics, expert quotes and references to research. Fake news operates on generalizations and emotions. Check if quoted people actually exist and were correctly cited.", + link: { href: "https://www.politifact.com/", labelPl: "PolitiFact: Weryfikacja faktów", labelNo: "PolitiFact: Faktaverifisering", labelEn: "PolitiFact: Fact verification" }, + icon: "📊" + }, + { + pl: "Uważaj na deepfakes i manipulacje", + no: "Vær forsiktig med deepfakes og manipulasjoner", + en: "Beware of deepfakes and manipulations", + morePl: "Coraz częściej spotykamy zmontowane zdjęcia, filmy i nagrania audio. Sprawdź oryginalne źródło materiału. Użyj narzędzi do odwrotnego wyszukiwania obrazów. Zwróć uwagę na nienaturalne ruchy twarzy w filmach.", + moreNo: "Vi møter stadig mer manipulerte bilder, videoer og lydopptak. Sjekk den originale kilden til materialet. Bruk verktøy for omvendt bildesøk. Vær oppmerksom på unaturlige ansiktsbevegelser i videoer.", + moreEn: "We increasingly encounter edited photos, videos and audio recordings. Check the original source of the material. Use reverse image search tools. Pay attention to unnatural facial movements in videos.", + link: { href: "https://euvsdisinfo.eu/", labelPl: "EU vs Disinfo: Walka z dezinformacją", labelNo: "EU vs Disinfo: Kamp mot desinformasjon", labelEn: "EU vs Disinfo: Fighting disinformation" }, + icon: "🛸" + } + ]; + + const sources = [ + { href: "https://demagog.org.pl", label: "Demagog", tag: "PL",}, + { href: "https://konkret24.tvn24.pl", label: "Konkret24", tag: "PL",}, + { href: "https://fakehunter.pap.pl", label: "FakeHunter", tag: "PL",}, + { href: "https://euvsdisinfo.eu", label: "EU vs Disinfo", tag: "EU",}, + { href: "https://www.snopes.com/", label: "Snopes", tag: "EN",}, + { href: "https://www.politifact.com/", label: "PolitiFact", tag: "EN",}, + { href: "https://factcheck.afp.com/", label: "AFP Fact Check", tag: "EN",}, + { href: "https://www.factcheck.org/", label: "FactCheck.org", tag: "EN", }, + ]; + + const features = [ + { + icon: "🤖", + title: T("Analiza AI", "AI-analyse", "AI Analysis"), + description: T( + "Zaawansowane modele NLP do analizy sentymentu i wykrywania dezinformacji", + "Avanserte NLP-modeller for sentimentanalyse og oppdagelse av desinformasjon", + "Advanced NLP models for sentiment analysis and misinformation detection" + ) + }, + { + icon: "⚡", + title: T("Analiza w Czasie Rzeczywistym", "Sanntidsanalyse", "Real-time Analysis"), + description: T( + "Natychmiastowe przetwarzanie najnowszych wiadomości ze strumieni RSS", + "Umiddelbar behandling av siste nyheter fra RSS-strømmer", + "Instant processing of latest news from RSS feeds" + ) + }, + { + icon: "🌐", + title: T("Wiele Źródeł", "Flere kilder", "Multiple Sources"), + description: T( + "Porównuj wiadomości z 10+ zaufanych źródeł krajowych i międzynarodowych", + "Sammenlign nyheter fra 10+ pålitelige innenlandske og internasjonale kilder", + "Compare news from 10+ trusted domestic and international sources" + ) + }, + { + icon: "📊", + title: T("Dashboard z Wykresami", "Dashbord med diagrammer", "Chart Dashboard"), + description: T( + "Interaktywne wizualizacje danych i statystyki emocjonalne", + "Interaktive datavisualiseringer og emosjonell statistikk", + "Interactive data visualizations and emotional statistics" + ) + } + ]; + + return ( +
+ +
+ + + TruthScan AI + + + + {T( + "Inteligentny system do analizy wiarygodności newsów i wykrywania dezinformacji", + "Intelligent system for troverdighetanalyse av nyheter og oppdagelse av desinformasjon", + "Intelligent system for news credibility analysis and misinformation detection" + )} + + + +
+
10+
+
+ {T("Źródeł", "Kilder", "Sources")} +
+
+
+
2s
+
+ {T("Analiza", "Analyse", "Analysis")} +
+
+
+
AI
+
+ {T("Modele NLP", "NLP-modeller", "NLP Models")} +
+
+
+ + + + {T("Przejdź do Dashboardu →", "Gå til dashbordet →", "Go to Dashboard →")} + + + +
+
+ +
+
+ +

+ {T(" Dlaczego TruthScan AI?", " Hvorfor TruthScan AI?", " Why TruthScan AI?")} +

+

+ {T( + "Nowoczesne narzędzie wspierające krytyczne myślenie", + "Moderne verktøy som støtter kritisk tenkning", + "Modern tool supporting critical thinking" + )} +

+
+ +
+ {features.map((feature, index) => ( + +
{feature.icon}
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
+
+ +
+
+
+ +
+

+ {T("🔍 Jak rozpoznać fake newsy?", "🔍 Hvordan gjenkjenne falske nyheter?", "🔍 How to Spot Fake News")} +

+ +
+ {tips.map((tip, i) => ( + + ))} +
+
+ +
+

+ {T("📚 Polecane źródła", "📚 Anbefalte kilder", "📚 Recommended Sources")} +

+ +
+ {sources.map((source) => ( + +
+
+ + {source.label} + + + {source.tag} + +
+
+
+ ))} +
+
+
+
+
+ + {activeTip !== null && ( + setActiveTip(null)} + /> + )} + +
+); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/app/saved/page.tsx b/TruthScan AI_frontend/app/saved/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7742305d13bd08092986503a02cd43e22ae9d086 --- /dev/null +++ b/TruthScan AI_frontend/app/saved/page.tsx @@ -0,0 +1,15 @@ +/** + * Strona aplikacji prezentująca zapisane przez użytkownika artykuły. + * + * Stanowi prosty wrapper routingu Next.js dla komponentu SavedArticlesPage. + */ + +"use client"; + +import SavedArticlesPage from "../../components/SavedArticlesPage"; + +export default function Page() { + return ; +} + + diff --git a/TruthScan AI_frontend/app/stores/newsCache.tsx b/TruthScan AI_frontend/app/stores/newsCache.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a133cf4a7df73d124c2ec606bdf1ea67ab930eb --- /dev/null +++ b/TruthScan AI_frontend/app/stores/newsCache.tsx @@ -0,0 +1,90 @@ +/** + * Globalny cache danych newsowych po stronie frontendu. + * Implementuje prosty mechanizm TTL oraz synchronizację z localStorage. + */ + +import { create } from "zustand"; + +type CacheEntry = { + data: T; + ts: number; +}; + +type NewsCacheState = { + cache: Record; + ttlMs: number; + get: (key: string) => T | null; + set: (key: string, data: any) => void; + del: (key: string) => void; + clear: () => void; +}; + +const TTL_5_MIN = 5 * 60 * 1000; + +const storageKey = "truthscan_news_cache_v1"; + +// Odczyt cache z localStorage (jeśli dostępny) +function loadFromStorage(): Record { + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} +// Zapis cache do localStorage +function saveToStorage(cache: Record) { + try { + localStorage.setItem(storageKey, JSON.stringify(cache)); + } catch { + // ignorujemy błędy zapisu (np. quota exceeded)v + } +} + +export const useNewsCache = create((set, get) => ({ + cache: typeof window !== "undefined" ? loadFromStorage() : {}, + ttlMs: TTL_5_MIN, + + get: (key: string) => { + const { cache, ttlMs } = get(); + const entry = cache[key]; + if (!entry) return null; + const expired = Date.now() - entry.ts > ttlMs; + // Automatyczne wygaszanie wpisów cache (TTL) + if (expired) { + + const next = { ...cache }; + delete next[key]; + set({ cache: next }); + saveToStorage(next); + return null; + } + return entry.data as T; + }, + + set: (key: string, data: any) => { + const { cache } = get(); + const next = { ...cache, [key]: { data, ts: Date.now() } }; + set({ cache: next }); + saveToStorage(next); + }, + + del: (key: string) => { + const { cache } = get(); + const next = { ...cache }; + delete next[key]; + set({ cache: next }); + saveToStorage(next); + }, + + clear: () => { + set({ cache: {} }); + saveToStorage({}); + }, +})); + +// helper: klucz cache dla /news +export const newsKey = (source: string, lang = "pl") => `news:${source}:${lang}`; +// helper: klucz cache dla /emotion-stats +export const statsKey = (source: string) => `stats:${source}`; diff --git a/TruthScan AI_frontend/components/ArticleCard.tsx b/TruthScan AI_frontend/components/ArticleCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9f941bb19e622af964c1a3b00aec35c12a4cdb79 --- /dev/null +++ b/TruthScan AI_frontend/components/ArticleCard.tsx @@ -0,0 +1,234 @@ +/** + * Komponent prezentujący pojedynczy artykuł informacyjny. + * + * Odpowiada za: + * - wyświetlanie metadanych artykułu (tytuł, źródło, data, sentyment), + * - obsługę akcji użytkownika (zapis, usunięcie, eksport do PDF), + * - integrację z hookami pomocniczymi (sentyment, favicona, treść artykułu), + * - renderowanie w trybie pełnym lub kompaktowym. + */ + +"use client"; + +import React, { useState } from "react"; +import { Article } from "../lib/fetchNews"; +import { useArticleContent } from "../app/hooks/useArticleContent"; +import { useFavicon } from "../app/hooks/useFavicon"; +import { useSentiment } from "../app/hooks/useSentiment"; +import PDFModal from "./PDFModal"; +import type { Lang } from "../lib/types"; + +interface ArticleCardProps { + article: Article; + language: Lang; + locales: any; + onDelete?: (title: string) => void; + savedView?: boolean; + variant?: "full" | "compact"; + onSave?: (article: Article) => void; +} + +export default function ArticleCard({ + article, + language, + locales, + onDelete, + savedView = false, + variant = "full", + onSave, +}: ArticleCardProps) { + const { label: sentimentLabel, color: sentimentColor } = useSentiment(article.sentiment || "", language); + const { faviconSrc, sourceInitial, hasFavicon, nextFavicon } = useFavicon(article); + const { fetchFullContent } = useArticleContent(); + const [isExporting, setIsExporting] = useState(false); + const [isLoadingContent, setIsLoadingContent] = useState(false); + const [fullContent, setFullContent] = useState(""); + const [showPDF, setShowPDF] = useState(false); + const isCompact = variant === "compact"; + const t = locales[language] ?? locales.pl; + + const T = (pl: string, no: string, en: string) => + language === "pl" ? pl : language === "no" ? no : en; + + const handleExportPDF = async () => { + if (isExporting) return; + + setIsExporting(true); + try { + let contentToExport = article.summary || article.description || ""; + + if (article.link && article.link.startsWith('http') && !fullContent) { + setIsLoadingContent(true); + try { + const fetchedContent = await fetchFullContent(article.link); + setFullContent(fetchedContent); + } catch (error) { + console.log("ℹ️ Używam dostępnej treści"); + } finally { + setIsLoadingContent(false); + } + } + + setShowPDF(true); + } catch (error) { + console.error("❌ Błąd przygotowania PDF:", error); + alert(T("Błąd podczas przygotowywania PDF", "Feil ved forberedelse av PDF", "Error preparing PDF")); + } finally { + setIsExporting(false); + } + }; + + const handlePDFClose = () => setShowPDF(false); + const handlePDFExportStart = () => console.log("🟡 Eksport PDF rozpoczęty"); + const handlePDFExportEnd = () => { + console.log("🟢 Eksport PDF zakończony"); + handlePDFClose(); + }; + + const renderHeader = () => ( +
+ {hasFavicon ? ( + {`${article.source} + ) : ( +
+ {sourceInitial} +
+ )} + +

+ {article.title} +

+
+ ); + + const renderDescription = () => ( +

+ {article.summary || article.description || + T("Brak opisu", "Ingen beskrivelse", "No description")} +

+ ); + + const renderMetadata = () => ( +
+

+ 🕒 {T("Data", "Dato", "Date")}:{" "} + +

+

📰 {T("Źródło", "Kilde", "Source")}: {article.source || "-"}

+

+ 💬 {T("Sentyment", "Sentimentanalyse", "Sentiment")}:{" "} + {sentimentLabel} +

+

+ ❓ {T("Prawdopodobieństwo Fake News", "Sannsynlighet for falske nyheter", "Fake News Probability")}:{" "} + {typeof article.fake_probability === "number" + ? `${article.fake_probability.toFixed(2)}%` + : T("Brak danych", "Ingen data", "No data")} +

+
+ ); + + const renderActions = () => ( +
+ {article.link && ( + + 🔗 {T("Pokaż artykuł", "Vis artikkel", "View article")} + + )} + + {savedView && ( + + )} + + {savedView ? ( + + ) : onSave ? ( + + ) : null} +
+ ); + + return ( + <> +
+ {renderHeader()} + {renderDescription()} + {renderMetadata()} + {renderActions()} +
+ + + + ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/components/ArticleList.tsx b/TruthScan AI_frontend/components/ArticleList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ed460be8a84616afc9541f27d54d18b6082a268 --- /dev/null +++ b/TruthScan AI_frontend/components/ArticleList.tsx @@ -0,0 +1,61 @@ +/** + * Komponent prezentujący pojedynczy artykuł informacyjny. + * + * Odpowiada za: + * - wyświetlanie metadanych artykułu (tytuł, źródło, data, sentyment), + * - obsługę akcji użytkownika (zapis, usunięcie, eksport do PDF), + * - integrację z hookami pomocniczymi (sentyment, favicona, treść artykułu), + * - renderowanie w trybie pełnym lub kompaktowym. + */ + +"use client"; + +import React from "react"; +import ArticleCard from "./ArticleCard"; +import locales from "../lib/locales"; +import { Article } from "../lib/fetchNews"; +import type { Lang } from "../lib/types"; + +type ArticleListProps = { + articles: Article[]; + language: Lang; + locales: typeof locales; + savedView?: boolean; + onDelete?: (title: string) => void; +}; + +export default function ArticleList({ + articles, + language, + locales, + savedView = false, + onDelete, +}: ArticleListProps) { + if (!articles || articles.length === 0) { + return ( +

+ {language === "pl" ? "Brak artykułów do wyświetlenia." : language === "no" ? "Ingen artikler å vise." : "No articles to display."} +

+ ); + } + + return ( +
+ {articles.map((article, idx) => ( + onDelete(article.title) : undefined} + /> + ))} +
+ ); +} + + + + + diff --git a/TruthScan AI_frontend/components/DarkModeToggle.tsx b/TruthScan AI_frontend/components/DarkModeToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8459c7f29afe9f93d883f4ca413bd817d0500a60 --- /dev/null +++ b/TruthScan AI_frontend/components/DarkModeToggle.tsx @@ -0,0 +1,53 @@ +/** + * Komponent przełącznika trybu jasnego i ciemnego (dark mode). + * + * Odpowiada za: + * - zarządzanie stanem trybu kolorystycznego interfejsu, + * - synchronizację ustawienia z localStorage, + * - dynamiczne dodawanie klasy `dark` do elementu . + */ + +"use client"; + +import { useEffect, useState } from "react"; +import { Moon, Sun } from "lucide-react"; + +export default function DarkModeToggle() { + const [darkMode, setDarkMode] = useState(false); + + useEffect(() => { + const saved = localStorage.getItem("darkMode"); + if (saved === "true") { + setDarkMode(true); + document.documentElement.classList.add("dark"); + } + }, []); + + useEffect(() => { + if (darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + localStorage.setItem("darkMode", String(darkMode)); + }, [darkMode]); + + return ( + + ); +} + + + + diff --git a/TruthScan AI_frontend/components/EmotionalPieChart.tsx b/TruthScan AI_frontend/components/EmotionalPieChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c6689cb5586d5fa6256ce6eb5a7ae86fc68b1aa --- /dev/null +++ b/TruthScan AI_frontend/components/EmotionalPieChart.tsx @@ -0,0 +1,190 @@ +/** + * Komponent wizualizujący rozkład emocji w artykułach w formie wykresu kołowego. + * + * Odpowiada za: + * - normalizację i agregację danych sentymentu, + * - tłumaczenie etykiet w zależności od języka interfejsu, + * - prezentację danych z wykorzystaniem biblioteki Recharts, + * - obsługę tooltipów i etykiet procentowych. + */ + +"use client"; + +import { useMemo } from "react"; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"; +import { useLanguage } from "../app/hooks/useLanguage"; +import locales from "../lib/locales"; + +interface EmotionalPieChartProps { + data: { name: string; value: number }[]; + title?: string; +} + +type Canonical = "POSITIVE" | "NEGATIVE" | "NEUTRAL"; + +const EMOTION_COLORS = { + POSITIVE: "#22c55e", // green + NEGATIVE: "#ef4444", // red + NEUTRAL: "#3b82f6", // blue +} as const; + +const EMOTION_LABELS = { + pl: { + POSITIVE: "Pozytywne", + NEGATIVE: "Negatywne", + NEUTRAL: "Neutralne", + }, + en: { + POSITIVE: "Positive", + NEGATIVE: "Negative", + NEUTRAL: "Neutral", + }, + no: { + POSITIVE: "Positivt", + NEGATIVE: "Negativt", + NEUTRAL: "Nøytralt", + }, +} as const; + +export default function EmotionalPieChart({ data, title }: EmotionalPieChartProps) { + const { language } = useLanguage(); + const t = locales[language]; + + const toCanonical = (raw: string): Canonical | null => { + const u = (raw || "").trim().toUpperCase(); + if (u === "POSITIVE" || u === "POZYTYWNE") return "POSITIVE"; + if (u === "NEGATIVE" || u === "NEGATYWNE") return "NEGATIVE"; + if (u === "NEUTRAL" || u === "NEUTRALNE") return "NEUTRAL"; + return null; + }; + + const translatedData = useMemo(() => { + const initialData: Record = { + POSITIVE: 0, NEGATIVE: 0, NEUTRAL: 0 + }; + + for (const item of data) { + const c = toCanonical(item.name); + if (!c) continue; + initialData[c] = (initialData[c] ?? 0) + (item.value ?? 0); + } + + return (["POSITIVE", "NEUTRAL", "NEGATIVE"] as Canonical[]) + .map(canonical => ({ + canonical, + name: EMOTION_LABELS[language][canonical], + value: initialData[canonical], + color: EMOTION_COLORS[canonical] + })) + .filter(item => item.value > 0); + }, [data, language]); + + const totalValue = useMemo(() => + translatedData.reduce((sum, item) => sum + item.value, 0), + [translatedData] + ); + + const RAD = Math.PI / 180; + + const renderLabel = ({ cx, cy, midAngle, outerRadius, name, value, payload }: any) => { + const r = (outerRadius ?? 0) + 14; + const x = cx + r * Math.cos(-midAngle * RAD); + const y = cy + r * Math.sin(-midAngle * RAD); + const anchor: "start" | "end" = x > cx ? "start" : "end"; + + const fill = EMOTION_COLORS[(payload?.canonical as Canonical) ?? "NEUTRAL"]; + return ( + + {`${name}: ${value}`} + + ); + }; + + const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + const percentage = totalValue > 0 ? (data.value / totalValue) * 100 : 0; + + return ( +
+

{data.name}

+

+ {language === "pl" ? "Liczba artykułów: " : language === "no" ? "Antall artikler: " : "Number of articles: "} + {data.value} +

+

+ {language === "pl" ? "Procent: " : language === "no" ? "Prosent: " : "Percentage: "} + {percentage.toFixed(1)}% +

+
+ ); + } + return null; + }; + + if (translatedData.length === 0) { + return ( +
+

+ {title || t.emotionsInArticles} +

+
+

+ {language === "pl" ? "Brak danych do wyświetlenia" : language === "no" ? "Ingen data å vise" : "No data to display"} +

+
+
+ ); + } + + return ( +
+

+ {title || t.emotionsInArticles} +

+ +
+ + + + {translatedData.map((item, idx) => ( + + ))} + + + } /> + + ( + {value} + )} + wrapperStyle={{ paddingTop: '10px' }} + /> + + +
+ +

+ {language === "pl" + ? "Wykres pokazuje rozkład emocji w analizowanych artykułach." + : language === "no" + ? "Diagrammet viser fordelingen av følelser i analyserte artikler." + : "This chart shows the distribution of detected emotions in articles."} +

+
+ ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/components/FakeNewsBarChart.tsx b/TruthScan AI_frontend/components/FakeNewsBarChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3cbf6451190ec0a1954c442e4617dde042f3f45e --- /dev/null +++ b/TruthScan AI_frontend/components/FakeNewsBarChart.tsx @@ -0,0 +1,151 @@ +/** + * Komponent prezentujący porównawczy wykres słupkowy udziału fake newsów + * w poszczególnych źródłach informacyjnych. + * + * Odpowiada za: + * - wizualizację wyników analizy fake news w podziale na źródła, + * - obsługę wielojęzycznych etykiet i opisów, + * - czytelną prezentację danych z wykorzystaniem biblioteki Recharts, + * - informowanie użytkownika o braku lub niepełnych danych. + */ + +"use client"; + +import { useMemo } from "react"; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Cell, +} from "recharts"; +import { useLanguage } from "../app/hooks/useLanguage"; +import locales from "../lib/locales"; + +interface Props { + data: { label: string; value: number }[]; + title?: string; + footerNote?: string; +} + +export default function FakeNewsBarChart({ data, title, footerNote }: Props) { + + const { language } = useLanguage(); + const t = locales[language]; + + const rows = useMemo(() => data.map((d) => ({ ...d })), [data]); + + const NOTE_TEXT = { + pl: "Wykres przedstawia oszacowany udział artykułów oznaczonych jako fake news w poszczególnych źródłach.", + en: "The chart shows an estimated share of articles flagged as fake news across sources.", + no: "Diagrammet viser en estimert andel artikler flagget som falske nyheter per kilde.", + }; + + const METRIC_NAME = { + pl: "Prawdopodobieństwo fake news", + en: "Fake-news probability", + no: "Sannsynlighet for falske nyheter", + }; + + const NO_DATA_TEXT = { + pl: "Brak danych", + en: "No data", + no: "Ingen data", + }; + + const note = footerNote ?? NOTE_TEXT[language]; + const metricName = METRIC_NAME[language]; + const noDataText = NO_DATA_TEXT[language]; + const formatTooltipValue = (v: number) => { + const valNum = Number(v); + const isEmpty = v == null || Number.isNaN(valNum) || valNum === 0; + return isEmpty ? noDataText : `${valNum.toFixed(2)}%`; + }; + + const formatTooltipLabel = (label: string, items: any[]) => { + const actualLabel = items?.[0]?.payload?.label ?? label; + const v = Number(items?.[0]?.value); + const empty = v == null || Number.isNaN(v) || v === 0; + + const prefix = language === "pl" ? "Źródło: " : language === "no" ? "Kilde: " : "Source: "; + const suffix = empty + ? language === "pl" ? " (brak danych)" : language === "no" ? " (ingen data)" : " (no data)" + : ""; + + return `${prefix}${actualLabel}${suffix}`; + }; + + return ( +
+

+ {title ?? t.fakeNewsSources} +

+ +
+ + + + + + + + + [formatTooltipValue(v), metricName]} + labelFormatter={formatTooltipLabel} + contentStyle={{ + backgroundColor: "#1f2937", + color: "#fff", + borderRadius: 8, + border: "none", + }} + itemStyle={{ color: "#e5e7eb" }} + labelStyle={{ color: "#e5e7eb" }} + cursor={{ fill: "rgba(59,130,246,0.06)" }} + /> + + + {rows.map((r, i) => ( + + ))} + + + +
+ +

+ {note} +

+
+ ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/components/LanguageSwitcher.tsx b/TruthScan AI_frontend/components/LanguageSwitcher.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b4c3deb37f093969e75800afc871ad030d7306e2 --- /dev/null +++ b/TruthScan AI_frontend/components/LanguageSwitcher.tsx @@ -0,0 +1,32 @@ +/** + * Komponent przełącznika języka interfejsu użytkownika. + * + * Odpowiada za: + * - umożliwienie użytkownikowi wyboru języka aplikacji (PL / EN), + * - przekazanie wybranego języka do komponentu nadrzędnego, + * - wizualną prezentację aktualnego języka w formie listy rozwijanej. + */ + + +import React from "react"; +import type { Lang } from "../lib/types"; + +interface Props { + language: Lang; + setLanguage: (lang: Lang) => void; +} + +export default function LanguageSwitcher({ language, setLanguage }: Props) { + return ( + + ); +} + diff --git a/TruthScan AI_frontend/components/LiveNewsFeed.tsx b/TruthScan AI_frontend/components/LiveNewsFeed.tsx new file mode 100644 index 0000000000000000000000000000000000000000..421bbdcb074480c469502fa72bbe41a46214c63d --- /dev/null +++ b/TruthScan AI_frontend/components/LiveNewsFeed.tsx @@ -0,0 +1,157 @@ +/** + * Komponent strumieniowego wyświetlania artykułów z wybranego źródła. + * + * Odpowiada za: + * - odbiór danych w czasie rzeczywistym (streaming), + * - prezentację postępu przetwarzania artykułów, + * - animowane renderowanie listy artykułów, + * - umożliwienie zapisu pojedynczych artykułów. + */ + + +"use client"; + +import React, { useEffect, useState, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useNewsStream } from "../app/hooks/useNewsStream"; +import type { Article } from "../lib/fetchNews"; +import ArticleCard from "./ArticleCard"; + +import type { Lang } from "../lib/types"; + +interface LiveNewsFeedProps { + source: string; + language: Lang; +} + +const TEXT_CONTENT = { + pl: { + title: "Strumień wiadomości", + loading: "⏳ Ładowanie danych...", + error: "❌ Błąd połączenia z serwerem", + progress: (current: number, total: number) => `Przetworzono ${current} z ${total} artykułów`, + complete: "✅ Wszystkie artykuły załadowane!", + saveError: "❌ Błąd zapisu", + saveSuccess: "✅ Artykuł zapisany!", + connectionError: "❌ Błąd połączenia z backendem." + }, + en: { + title: "News Stream", + loading: "⏳ Loading data...", + error: "❌ Server connection error", + progress: (current: number, total: number) => `Processed ${current} of ${total} articles`, + complete: "✅ All articles loaded!", + saveError: "❌ Save error", + saveSuccess: "✅ Article saved!", + connectionError: "❌ Connection error." + }, + no: { + title: "Nyhetsstrøm", + loading: "⏳ Laster inn data...", + error: "❌ Tilkoblingsfeil til serveren", + progress: (current: number, total: number) => `Behandlet ${current} av ${total} artikler`, + complete: "✅ Alle artikler lastet!", + saveError: "❌ Lagringsfeil", + saveSuccess: "✅ Artikkel lagret!", + connectionError: "❌ Tilkoblingsfeil." + } +}; + +export default function LiveNewsFeed({ source, language }: LiveNewsFeedProps) { + const { + articles: rawArticles, + loading, + error, + progress = 0, + total = 0, + } = useNewsStream(source); + + const [localArticles, setLocalArticles] = useState([]); + const t = TEXT_CONTENT[language]; + + useEffect(() => { + setLocalArticles([]); + }, [source]); + + useEffect(() => { + const next = (rawArticles ?? []) as Article[]; + setLocalArticles(next); + }, [rawArticles]); + + const handleSave = async (article: Article) => { + try { + const response = await fetch("http://127.0.0.1:8000/save-article", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(article), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + alert(`${t.saveError}: ${err.detail || response.statusText}`); + return; + } + + alert(t.saveSuccess); + } catch (err) { + console.error("❌ Save error:", err); + alert(t.connectionError); + } + }; + + const progressPercent = Math.round((Math.min(progress, total) / Math.max(total, 1)) * 100); + const progressText = t.progress(Math.min(progress, total), total); + + return ( +
+

+ 📡 {t.title}: {source} +

+ + {loading &&

{t.loading}

} + + {!!error && progress < total && ( +

{t.error}

+ )} + +
+
+
+ +

+ {progressText} +

+ + + {localArticles.map((a, i) => ( + + + + ))} + + + {progress === total && total > 0 && ( +

+ {t.complete} +

+ )} +
+ ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/components/NavBar.tsx b/TruthScan AI_frontend/components/NavBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0061bc3fe44c1a9fe21e4ee674d24ac83ffc4261 --- /dev/null +++ b/TruthScan AI_frontend/components/NavBar.tsx @@ -0,0 +1,96 @@ +/** + * Globalny komponent nawigacji aplikacji. + * + * Odpowiada za: + * - prezentację głównej nawigacji między widokami, + * - obsługę zmiany języka interfejsu (PL / EN), + * - przełączanie trybu jasnego i ciemnego, + * - wizualne wskazanie aktywnej trasy. + */ + + +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { motion } from "framer-motion"; +import LanguageSwitcher from "./LanguageSwitcher"; +import DarkModeToggle from "./DarkModeToggle"; + +type Lang = "pl" | "en" | "no"; + +export default function NavBar() { + const pathname = usePathname(); + const [language, setLanguage] = useState("pl"); + + useEffect(() => { + const stored = localStorage.getItem("lang"); + if (stored === "pl" || stored === "en" || stored === "no") setLanguage(stored as Lang); + }, []); + + useEffect(() => { + localStorage.setItem("lang", language); + if (typeof document !== "undefined") document.documentElement.lang = language; + window.dispatchEvent(new CustomEvent("app:langchange", { detail: language })); + }, [language]); + + const links = [ + { href: "/", labelPl: "Strona główna", labelEn: "Home", labelNo: "Hjem", icon: "🏠" }, + { href: "/dashboard", labelPl: "Dashboard", labelEn: "Dashboard", labelNo: "Dashbord", icon: "📊" }, + { href: "/saved", labelPl: "Zapisane", labelEn: "Saved", labelNo: "Lagrede", icon: "💾" }, + ]; + + return ( + + ); +} + + diff --git a/TruthScan AI_frontend/components/PDFGenerator.tsx b/TruthScan AI_frontend/components/PDFGenerator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..28be4c1cbbaa57a6f193d0ee07e53eb3c967b930 --- /dev/null +++ b/TruthScan AI_frontend/components/PDFGenerator.tsx @@ -0,0 +1,83 @@ +/** + * Komponent odpowiedzialny za generowanie i eksport widoku do pliku PDF. + * + * Odpowiada za: + * - opakowanie przekazanej treści w strukturę raportu, + * - integrację z mechanizmem eksportu PDF po stronie klienta, + * - obsługę akcji rozpoczęcia i zakończenia eksportu. + */ + +import React, { useRef } from 'react'; +import { usePDFExport } from '../app/hooks/usePDFExport'; + +interface PDFGeneratorProps { + children: React.ReactNode; + title?: string; + fileName?: string; + showButton?: boolean; + buttonText?: string; + buttonClass?: string; + onExportStart?: () => void; + onExportEnd?: () => void; +} + +const PDFGenerator: React.FC = ({ + children, + title = 'ThruScan_Analysis', + fileName = 'ThruScan_Analysis', + showButton = true, + buttonText = 'Eksportuj do PDF', + buttonClass = 'bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600', + onExportStart, + onExportEnd +}) => { + const contentRef = useRef(null); + const handleExportPDF = usePDFExport(contentRef, { title }); + + const handlePrint = async () => { + onExportStart?.(); + try { + await handleExportPDF(); + } finally { + onExportEnd?.(); + } + }; + + return ( +
+ {showButton && ( +
+ +
+ )} + +
+
+

{title}

+
+ Wygenerowano: {new Date().toLocaleDateString('pl-PL')} +
+
+ +
+ {children} +
+ +
+ ThruScan AI Report • Strona 1 +
+
+
+ ); +}; + +export default PDFGenerator; \ No newline at end of file diff --git a/TruthScan AI_frontend/components/PDFModal.tsx b/TruthScan AI_frontend/components/PDFModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eef1bfaedd82c9cf5a8024955b456ae38ade266c --- /dev/null +++ b/TruthScan AI_frontend/components/PDFModal.tsx @@ -0,0 +1,161 @@ +/** + * Modal odpowiedzialny za prezentację i eksport analizy artykułu do formatu PDF. + * + * Odpowiada za: + * - wyświetlenie podglądu raportu analitycznego w oknie modalnym, + * - przekazanie danych artykułu do generatora PDF, + * - obsługę stanu ładowania pełnej treści artykułu, + * - inicjację i zakończenie procesu eksportu. + */ + +"use client"; + +import React from 'react'; +import PDFGenerator from './PDFGenerator'; +import { Article } from '../lib/fetchNews'; +import type { Lang } from '../lib/types'; + +interface PDFModalProps { + article: Article; + language: Lang; + sentimentLabel: string; + sentimentColor: string; + fullContent: string; + isLoadingContent: boolean; + showPDF: boolean; + onClose: () => void; + onExportStart: () => void; + onExportEnd: () => void; +} + +export default function PDFModal({ + article, + language, + sentimentLabel, + sentimentColor, + fullContent, + isLoadingContent, + showPDF, + onClose, + onExportStart, + onExportEnd +}: PDFModalProps) { + if (!showPDF) return null; + + const T = (pl: string, no: string, en: string) => + language === "pl" ? pl : language === "no" ? no : en; + + return ( +
+
+
+

+ {T("Eksport PDF", "PDF-eksport", "PDF Export")} - {article.title} +

+ +
+
+ +
+
+

{article.title}

+
+
+ {T("Źródło", "Kilde", "Source")}:
+ {article.source} +
+
+ {T("Data publikacji", "Publiseringsdato", "Publication date")}:
+ {article.published} +
+
+ {T("Sentyment", "Sentimentanalyse", "Sentiment")}:
+ {sentimentLabel} +
+
+ {T("Prawd. Fake News", "Sannsynlighet falske nyheter", "Fake News Prob.")}:
+ {typeof article.fake_probability === "number" ? + `${article.fake_probability.toFixed(2)}%` : "N/A"} +
+
+
+ +
+

+ {T("Pełna treść artykułu", "Fullstendig artikkelinnhold", "Full Article Content")} +

+
+ {isLoadingContent ? ( +
+
+

+ {T("Pobieranie pełnej treści...", "Laster inn fullstendig innhold...", "Loading full content...")} +

+
+ ) : ( +
+ {fullContent || article.summary || article.description || + T("Brak dostępnej treści", "Ingen innhold tilgjengelig", "No content available")} +
+ )} + + {(fullContent && fullContent.length > 0) && ( +
+ ℹ️ {T( + "Treść pobrana automatycznie z oryginalnego źródła", + "Innhold automatisk hentet fra original kilde", + "Content automatically fetched from original source" + )} +
+ )} +
+
+ + {article.link && ( +
+

+ {T("Link do oryginalnego artykułu", "Original artikkellenke", "Original Article Link")} +

+ +
+ )} + +
+

{T("Wygenerowano przez ThruScan Analysis", "Generert av ThruScan Analysis", "Generated by ThruScan Analysis")}

+

{new Date().toLocaleDateString(language === "pl" ? "pl-PL" : language === "no" ? "no-NO" : "en-US", { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}

+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/components/PrintableArticle.tsx b/TruthScan AI_frontend/components/PrintableArticle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..04d63836855cc30975f168a48426db0a3e5b94ef --- /dev/null +++ b/TruthScan AI_frontend/components/PrintableArticle.tsx @@ -0,0 +1,202 @@ +/** + * Komponent odpowiedzialny za renderowanie artykułu w formacie przeznaczonym do druku. + * + * Odpowiada za: + * - prezentację treści artykułu w układzie zgodnym z wydrukiem / PDF, + * - ujednolicony layout raportu (nagłówek, metadane, treść, stopka), + * - obsługę wersji językowych (PL / EN), + * - współpracę z mechanizmami typu react-to-print lub eksport PDF. + */ + +"use client"; + +import React from 'react'; +import type { Lang } from '../lib/types'; + +interface PrintableArticleProps { + article: { + title: string; + source: string; + published?: string; + summary?: string; + description?: string; + fake_probability?: number; + }; + sentimentLabel: string; + language: Lang; + onClose?: () => void; +} + +export const PrintableArticle: React.FC = ({ + article, + sentimentLabel, + language, + onClose +}) => { + const T = (pl: string, no: string, en: string) => + language === "pl" ? pl : language === "no" ? no : en; + + const articleContent = article.summary || article.description || + T("Brak dostępnej treści artykułu.", "Ingen artikkeltekst tilgjengelig.", "No article content available."); + + return ( +
+ {/* Ten komponent będzie używany przez react-to-print */} +
+
+
ThruScan AI
+
{article.title}
+
+ +
+
+ 📅 {T("Data", "Dato", "Date")}: + {article.published || T("Brak daty", "Ingen dato", "No date")} +
+
+ 📰 {T("Źródło", "Kilde", "Source")}: + {article.source || "-"} +
+
+ 💬 {T("Sentyment", "Sentimentanalyse", "Sentiment")}: + {sentimentLabel} +
+
+ ❓ Fake News: + {typeof article.fake_probability === "number" ? article.fake_probability.toFixed(2) + "%" : T("Brak danych", "Ingen data", "No data")} +
+
+ +
+
+ 📝 {T("TREŚĆ ARTYKUŁU", "ARTIKKELINNHOLD", "ARTICLE CONTENT")} +
+
+ {articleContent.split('\n').map((paragraph, index) => + paragraph.trim() ?

{paragraph}

: null + )} +
+
+ +
+ {T("Wygenerowano przez ThruScan AI", "Generert av ThruScan AI", "Generated by ThruScan AI")} • {new Date().toLocaleDateString(language === "pl" ? "pl-PL" : language === "no" ? "no-NO" : "en-US")} +
+
+ + +
+ ); +}; \ No newline at end of file diff --git a/TruthScan AI_frontend/components/ProgressBar.tsx b/TruthScan AI_frontend/components/ProgressBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f333d1ce51e41becef6ed4f9eab0846bd2739a9b --- /dev/null +++ b/TruthScan AI_frontend/components/ProgressBar.tsx @@ -0,0 +1,48 @@ +/** + * Komponent wizualizujący postęp ładowania danych analitycznych. + * + * Odpowiada za: + * - prezentację bieżącego stanu przetwarzania źródeł, + * - wyświetlenie wartości liczbowych i procentowych postępu, + * - dostosowanie komunikatów do wybranego języka interfejsu (PL / EN). + */ + + +"use client"; + +import type { Lang } from "../lib/types"; + +interface ProgressBarProps { + progressCount: number; + totalSources: number; + progressPct: number; + language: Lang; +} + +export default function ProgressBar({ + progressCount, + totalSources, + progressPct, + language +}: ProgressBarProps) { + return ( +
+
+ + {language === "pl" + ? `Postęp ładowania wykresów: ${progressCount} / ${totalSources}` + : language === "no" + ? `Lasteframgang for diagrammer: ${progressCount} / ${totalSources}` + : `Charts loading progress: ${progressCount} / ${totalSources}`} + + {progressPct}% +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/TruthScan AI_frontend/components/SavedArticlesPage.tsx b/TruthScan AI_frontend/components/SavedArticlesPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd693b99088df300014fc1abcd750f557f47bbd6 --- /dev/null +++ b/TruthScan AI_frontend/components/SavedArticlesPage.tsx @@ -0,0 +1,116 @@ +/** + * Widok zapisanych artykułów użytkownika. + * + * Odpowiada za: + * - pobieranie listy zapisanych artykułów z backendu, + * - synchronizację języka interfejsu (localStorage + event), + * - prezentację artykułów w formie kart, + * - obsługę usuwania zapisanych wpisów. + */ + + +"use client"; + +import { useEffect, useState } from "react"; +import locales from "../lib/locales"; +import ArticleCard from "./ArticleCard"; +import { Article } from "../lib/fetchNews"; +import type { Lang } from "../lib/types"; + +export default function SavedArticlesPage() { + const [savedArticles, setSavedArticles] = useState([]); + const [language, setLanguage] = useState("pl"); + + useEffect(() => { + const storedLang = localStorage.getItem("lang"); + if (storedLang === "pl" || storedLang === "en" || storedLang === "no") { + setLanguage(storedLang as Lang); + } + + const onStorage = (e: StorageEvent) => { + if (e.key === "lang") { + const v = e.newValue; + if (v === "pl" || v === "en" || v === "no") setLanguage(v as Lang); + } + }; + const onCustom = (e: Event) => { + const lang = (e as CustomEvent).detail; + if (lang === "pl" || lang === "en" || lang === "no") setLanguage(lang as Lang); + }; + + window.addEventListener("storage", onStorage); + window.addEventListener("app:langchange", onCustom as EventListener); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener("app:langchange", onCustom as EventListener); + }; + }, []); + + + useEffect(() => { + fetchSavedArticles(); + }, []); + + const fetchSavedArticles = async () => { + try { + const response = await fetch("http://127.0.0.1:8000/saved-articles"); + const result = await response.json(); + setSavedArticles(result); + } catch (error) { + console.error("❌ Błąd pobierania artykułów:", error); + } + }; + + const handleDelete = async (title: string) => { + try { + const res = await fetch("http://127.0.0.1:8000/delete-article", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); + + if (res.ok) { + setSavedArticles((prev) => prev.filter((a) => a.title !== title)); + alert(language === "pl" ? "🗑️ Artykuł usunięty." : language === "no" ? "🗑️ Artikkelen ble slettet." : "🗑️ Article deleted."); + } else { + alert(language === "pl" ? "❌ Błąd usuwania." : language === "no" ? "❌ Sletting mislyktes." : "❌ Delete failed."); + } + } catch (err) { + console.error("❌ Delete error:", err); + alert(language === "pl" ? "❌ Błąd połączenia." : language === "no" ? "❌ Tilkoblingsfeil." : "❌ Connection error."); + } + }; + + const t = locales[language] ?? locales.pl; + + return ( + +
+

+ 💾 {language === "pl" ? "Zapisane artykuły" : language === "no" ? "Lagrede artikler" : "Saved articles"} +

+ + {savedArticles.length === 0 ? ( +

+ {language === "pl" ? "Brak zapisanych artykułów." : language === "no" ? "Ingen lagrede artikler." : "No saved articles."} +

+ ) : ( + savedArticles.map((article, index) => ( + + )) + )} +
+ ); +} + + + + diff --git a/TruthScan AI_frontend/components/SourceBlock.tsx b/TruthScan AI_frontend/components/SourceBlock.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ed94c29be1d6418c19c94d4bbf4c63416325405 --- /dev/null +++ b/TruthScan AI_frontend/components/SourceBlock.tsx @@ -0,0 +1,105 @@ +/** + * Blok porównawczy pojedynczego źródła informacyjnego. + * + * Odpowiada za: + * - prezentację wybranego źródła wiadomości, + * - możliwość dynamicznej zmiany źródła w obrębie porównania, + * - wyświetlenie podstawowych metryk (liczba artykułów, ryzyko fake news), + * - osadzenie strumienia artykułów dla danego źródła. + */ + +"use client"; + +import SourceSelector from "./SourceSelector"; +import LiveNewsFeed from "./LiveNewsFeed"; +import type { Lang } from "../lib/types"; + +export type SourceBlockProps = { + source: string; + onChangeSource: (s: string) => void; + language: Lang; + locales: any; + stats?: { + articlesCount?: number; + fakeRiskPercent?: number; + }; + className?: string; +}; + +const STATS_TEXT = { + pl: { + articles: "Artykułów:", + fakeRisk: 'Ryzyko "fake":', + }, + en: { + articles: "Articles:", + fakeRisk: "Fake risk:", + }, + no: { + articles: "Artikler:", + fakeRisk: "Falsk-risiko:", + }, +}; + +export default function SourceBlock({ + source, + onChangeSource, + language, + locales, + stats, + className, +}: SourceBlockProps) { + const t = STATS_TEXT[language]; + + return ( +
+
+
+

+ {source} +

+
+ +
+ +
+
+ + {stats && (stats.articlesCount != null || stats.fakeRiskPercent != null) && ( +
+ {stats.articlesCount != null && ( + + + {t.articles} + {stats.articlesCount} + + )} + {stats.fakeRiskPercent != null && ( + + + {t.fakeRisk} + + {stats.fakeRiskPercent.toFixed(2)}% + + + )} +
+ )} + + {/* Feed – remount przy zmianie źródła */} +
+ +
+
+ ); +} + diff --git a/TruthScan AI_frontend/components/SourceSelector.tsx b/TruthScan AI_frontend/components/SourceSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..30de4cb88b5bd34d6b459c0c76181d3c0369b9a8 --- /dev/null +++ b/TruthScan AI_frontend/components/SourceSelector.tsx @@ -0,0 +1,55 @@ +/** + * Selektor źródła wiadomości. + * + * Odpowiada za: + * - wybór aktywnego źródła informacyjnego, + * - przekazanie zmiany wyboru do komponentu nadrzędnego, + * - prezentację listy dostępnych źródeł w aktualnym języku interfejsu. + */ + + +"use client"; + +import React from "react"; +import type { Lang } from "../lib/types"; + +interface Props { + selectedSource: string; + setSelectedSource: (src: string) => void; + language: Lang; + locales: Record; +} + +export default function SourceSelector({ selectedSource, setSelectedSource, language, locales }: Props) { + return ( + + ); +} + + diff --git a/TruthScan AI_frontend/lib/fetchNews.ts b/TruthScan AI_frontend/lib/fetchNews.ts new file mode 100644 index 0000000000000000000000000000000000000000..3dff216e02fab39d714a9020e3fbf4675d13caaa --- /dev/null +++ b/TruthScan AI_frontend/lib/fetchNews.ts @@ -0,0 +1,245 @@ +/** + * Warstwa komunikacji z backendem odpowiedzialna za pobieranie + * i normalizację danych newsowych. + * + * Odpowiada za: + * - pobieranie danych z API dla pojedynczych i wielu źródeł, + * - normalizację struktury artykułów niezależnie od backendu, + * - agregację statystyk (fake news, sentyment), + * - obsługę pobierania progresywnego i współbieżności. + */ + +export interface Article { + title: string; + description?: string; + content?: string; + sentiment?: ( + | "Pozytywne" | "Negatywne" | "Neutralne" + | "Positive" | "Negative" | "Neutral" + | "Positivt" | "Negativt" | "Nøytralt" + ); + fake_probability?: number; + publishedAt?: string; + source: string; + url?: string; + [key: string]: any; +} + +export interface NewsData { + source: string; + fake_news: string; +} + +export interface Emotions { + [sentiment: string]: number; +} + +export interface FetchResult { + newsData: NewsData[]; + allArticles: Article[]; + emotions: Emotions; +} + + +export interface PartialUpdate { + source: string; + newsDatum: NewsData; + articles: Article[]; + emotionsDelta: Emotions; +} + +export interface FetchAllNewsOptions { + onPartial?: (u: PartialUpdate) => void; + onProgress?: (processed: number, total: number) => void; + concurrency?: number; // domyślnie 3 +} + +// ------------------------------------------------------------ +// Mapowanie / normalizacja +// ------------------------------------------------------------ +const SOURCE_MAP: Record = { + bbc: "BBC", + cnn: "CNN", + nytimes: "NYTimes", + guardian: "Guardian", + aljazeera: "AlJazeera", + money: "Money", + polsatnews: "PolsatNews", + gazetaprawna: "GazetaPrawna", + spidersweb: "SpidersWeb", + bankier: "Bankier", + nrk: "NRK", + vg: "VG", + dagbladet: "Dagbladet", + aftenposten: "Aftenposten", +}; + +function normalizeSource(s: string) { + return SOURCE_MAP[s.toLowerCase()] ?? s; +} + +function normalizeArticle(raw: any, sourceLabel: string): Article { + return { + title: raw?.title ?? "", + description: raw?.summary ?? raw?.description ?? raw?.content ?? "", + content: raw?.content ?? raw?.summary ?? "", + url: raw?.link ?? raw?.url, + sentiment: raw?.sentiment, + fake_probability: raw?.fake_probability, + publishedAt: raw?.published ?? raw?.publishedAt, + source: sourceLabel, + ...raw, + }; +} + +// -------------------- NOWE: pojedyncze źródło (do ładowania progresywnego) -------------------- +export async function fetchOneSource( + source: string, + language: "pl" | "en" | "no" = "pl" +): Promise<{ news?: NewsData; emotions: Emotions }> { + const src = normalizeSource(source); + const emotions: Emotions = {}; + + try { + const res = await fetch(`http://127.0.0.1:8000/news/${src}?lang=${language}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const data = await res.json(); + const rawArticles = Array.isArray(data?.articles) ? (data.articles as any[]) : []; + const articles = rawArticles.map((a) => normalizeArticle(a, src)); + + // DEBUG: logi pomocnicze używane podczas walidacji danych z backendu + console.log(`📊 Source: ${src}, Articles: ${articles.length}`); + articles.forEach((article, index) => { + console.log(` Article ${index + 1}:`, { + title: article.title?.substring(0, 50) + '...', + sentiment: article.sentiment, + fake_probability: article.fake_probability + }); + }); + + if (!articles.length) { + return { news: { source: src, fake_news: "0" }, emotions }; + } + + const avgFake = + articles.reduce((sum: number, a: any) => sum + (a.fake_probability ?? 0), 0) / + (articles.length || 1); + + for (const art of articles) { + if (art.sentiment) emotions[art.sentiment] = (emotions[art.sentiment] ?? 0) + 1; + } + + // 🆕 DEBUG: sprawdźmy jakie emocje zebraliśmy + console.log(`🎭 Emotions for ${src}:`, emotions); + + return { news: { source: src, fake_news: avgFake.toFixed(2) }, emotions }; + } catch (err) { + console.warn(`❌ Błąd źródła: ${src}`, err); + return { news: { source: src, fake_news: "0" }, emotions }; + } +} + +// ------------------------------------------------------------ +// Mały helper — kontrola współbieżności +// ------------------------------------------------------------ +async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise +) { + let index = 0; + const workers: Promise[] = []; + + async function next() { + if (index >= items.length) return; + const i = index++; + await worker(items[i]); + return next(); + } + + const n = Math.min(limit, items.length); + for (let i = 0; i < n; i++) workers.push(next()!); + await Promise.all(workers); +} + +// ------------------------------------------------------------ +// Główna funkcja — progresywne pobieranie +// ------------------------------------------------------------ +export async function fetchAllNews( + sources: string[], + language: "pl" | "en" | "no" = "pl", + opts: FetchAllNewsOptions = {} +): Promise { + const { onPartial, onProgress, concurrency = 3 } = opts; + + const allArticles: Article[] = []; + const emotions: Emotions = { + Pozytywne: 0, Negatywne: 0, Neutralne: 0, + Positive: 0, Negative: 0, Neutral: 0, + }; + const newsData: NewsData[] = []; + + const normalized = sources.map(normalizeSource); + const total = normalized.length; + let processed = 0; + + await runWithConcurrency(normalized, concurrency, async (source) => { + const url = `http://127.0.0.1:8000/news/${encodeURIComponent(source)}?lang=${language}`; + + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const data = await res.json(); + const rawArticles: any[] = Array.isArray(data?.articles) ? data.articles : []; + const articles = rawArticles.map((a) => normalizeArticle(a, source)); + + let avg = 0; + if (articles.length > 0) { + avg = + articles.reduce( + (sum, a) => sum + (Number(a.fake_probability) || 0), + 0 + ) / articles.length; + } + + const datum: NewsData = { source, fake_news: avg.toFixed(2) }; + newsData.push(datum); + + const delta: Emotions = {}; + for (const art of articles) { + const s = art.sentiment; + if (s) delta[s] = (delta[s] ?? 0) + 1; + } + + onPartial?.({ + source, + newsDatum: datum, + articles, + emotionsDelta: delta, + }); + + allArticles.push(...articles); + for (const [k, v] of Object.entries(delta)) { + emotions[k] = (emotions[k] ?? 0) + (v as number); + } + } catch (err) { + console.warn(`❌ Błąd źródła: ${source}`, err); + const datum: NewsData = { source, fake_news: "0" }; + newsData.push(datum); + + onPartial?.({ + source, + newsDatum: datum, + articles: [], + emotionsDelta: {}, + }); + } finally { + processed += 1; + onProgress?.(processed, total); + } + }); + + return { newsData, allArticles, emotions }; +} diff --git a/TruthScan AI_frontend/lib/locales.js b/TruthScan AI_frontend/lib/locales.js new file mode 100644 index 0000000000000000000000000000000000000000..65fe5e1c13b469b30f61dd637dffef8c1c063ad8 --- /dev/null +++ b/TruthScan AI_frontend/lib/locales.js @@ -0,0 +1,99 @@ +/** + * Centralny słownik tłumaczeń (i18n) aplikacji TruthScan AI. + * + * Zawiera: + * - teksty interfejsu użytkownika w językach PL / EN, + * - etykiety wykresów i komponentów, + * - komunikaty systemowe i akcje użytkownika. + * + * Wykorzystywany przez komponenty UI oraz hooki językowe. + */ + +const locales = { + pl: { + allSources: "Wszystkie źródła", + searchPlaceholder: "Wyszukaj artykuł...", + fakeNewsSources: "Fake News w różnych źródłach", + emotionsInArticles: "Emocje w Artykułach", + searchResults: "Wyniki wyszukiwania", + noResults: "Brak artykułów.", + fakeProbability: "Prawdopodobieństwo Fake News", + date: "Data", + welcomeTitle: "TruthScan AI", + welcomeDescription: + "Aplikacja wspierana sztuczną inteligencją do oceny wiarygodności informacji i wykrywania fake newsów w czasie rzeczywistym.", + goToDashboard: " Przejdź do Dashboardu", + selectSource: "Wybierz źródło", + articleSaved: "Artykuł został zapisany!", + saveError: "Nie udało się zapisać artykułu.", + confirmDelete: "Czy na pewno chcesz usunąć ten artykuł?", + articleDeleted: "Artykuł usunięty!", + deleteError: "Wystąpił błąd podczas usuwania artykułu.", + sentimentLabels: { + POSITIVE: "Pozytywny", + NEGATIVE: "Negatywny", + NEUTRAL: "Neutralny", + }, + view: "Pokaż artykuł", + save: "Zapisz", + delete: "Usuń", + }, + en: { + allSources: "All sources", + searchPlaceholder: "Search for an article...", + fakeNewsSources: "Fake News in Various Sources", + emotionsInArticles: "Emotions in Articles", + searchResults: "Search Results", + noResults: "No articles found.", + fakeProbability: "Fake News Probability", + date: "Date", + welcomeTitle: "TruthScan AI", + welcomeDescription: + "AI-powered app for evaluating news credibility and detecting fake news in real-time.", + goToDashboard: " Go to Dashboard", + selectSource: "Select Source", + articleSaved: "Article saved successfully!", + saveError: "Failed to save article.", + confirmDelete: "Are you sure you want to delete this article?", + articleDeleted: "Article deleted!", + deleteError: "An error occurred while deleting the article.", + sentimentLabels: { + POSITIVE: "Positive", + NEGATIVE: "Negative", + NEUTRAL: "Neutral", + }, + view: "View article", + save: "Save", + delete: "Delete", + }, + no: { + allSources: "Alle kilder", + searchPlaceholder: "Søk etter en artikkel...", + fakeNewsSources: "Falske nyheter i ulike kilder", + emotionsInArticles: "Følelser i artikler", + searchResults: "Søkeresultater", + noResults: "Ingen artikler funnet.", + fakeProbability: "Sannsynlighet for falske nyheter", + date: "Dato", + welcomeTitle: "TruthScan AI", + welcomeDescription: + "AI-drevet app for å vurdere troverdigheten til nyheter og oppdage falske nyheter i sanntid.", + goToDashboard: " Gå til dashbordet", + selectSource: "Velg kilde", + articleSaved: "Artikkelen ble lagret!", + saveError: "Kunne ikke lagre artikkelen.", + confirmDelete: "Er du sikker på at du vil slette denne artikkelen?", + articleDeleted: "Artikkelen ble slettet!", + deleteError: "Det oppstod en feil under sletting av artikkelen.", + sentimentLabels: { + POSITIVE: "Positivt", + NEGATIVE: "Negativt", + NEUTRAL: "Nøytralt", + }, + view: "Vis artikkel", + save: "Lagre", + delete: "Slett", + }, +}; + +export default locales; diff --git a/TruthScan AI_frontend/lib/types.ts b/TruthScan AI_frontend/lib/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c9c61db2e5a322ce5eb78b01d06ed8f6f8c61d7 --- /dev/null +++ b/TruthScan AI_frontend/lib/types.ts @@ -0,0 +1,5 @@ +/** + * Typy współdzielone między komponentami i hookami. + */ + +export type Lang = "pl" | "en" | "no"; diff --git a/TruthScan AI_frontend/next.config.ts b/TruthScan AI_frontend/next.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9ffa3083ad279ecf95fd8eae59cb253e9a539c4 --- /dev/null +++ b/TruthScan AI_frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/TruthScan AI_frontend/package-lock.json b/TruthScan AI_frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..9ef8d5508e8e6060251a9772185534a4281da0d5 --- /dev/null +++ b/TruthScan AI_frontend/package-lock.json @@ -0,0 +1,3821 @@ +{ + "name": "news-analysis-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "news-analysis-frontend", + "version": "0.1.0", + "dependencies": { + "@tailwindcss/line-clamp": "^0.4.4", + "framer-motion": "^12.23.24", + "jspdf": "^3.0.3", + "lucide-react": "^0.516.0", + "next": "15.2.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-to-print": "^3.2.0", + "recharts": "^2.15.1", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/d3-color": "^3.1.3", + "@types/d3-path": "^3.1.1", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.13", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.1.tgz", + "integrity": "sha512-JmY0qvnPuS2NCWOz2bbby3Pe0VzdAQ7XpEB6uLIHmtXNfAsAO0KLQLkuAoc42Bxbo3/jMC3dcn9cdf+piCcG2Q==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.1.tgz", + "integrity": "sha512-aWXT+5KEREoy3K5AKtiKwioeblmOvFFjd+F3dVleLvvLiQ/mD//jOOuUcx5hzcO9ISSw4lrqtUPntTpK32uXXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.1.tgz", + "integrity": "sha512-E/w8ervu4fcG5SkLhvn1NE/2POuDCDEy5gFbfhmnYXkyONZR68qbUlJlZwuN82o7BrBVAw+tkR8nTIjGiMW1jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.1.tgz", + "integrity": "sha512-gXDX5lIboebbjhiMT6kFgu4svQyjoSed6dHyjx5uZsjlvTwOAnZpn13w9XDaIMFFHw7K8CpBK7HfDKw0VZvUXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.1.tgz", + "integrity": "sha512-3v0pF/adKZkBWfUffmB/ROa+QcNTrnmYG4/SS+r52HPwAK479XcWoES2I+7F7lcbqc7mTeVXrIvb4h6rR/iDKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.1.tgz", + "integrity": "sha512-RbsVq2iB6KFJRZ2cHrU67jLVLKeuOIhnQB05ygu5fCNgg8oTewxweJE8XlLV+Ii6Y6u4EHwETdUiRNXIAfpBww==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.1.tgz", + "integrity": "sha512-QHsMLAyAIu6/fWjHmkN/F78EFPKmhQlyX5C8pRIS2RwVA7z+t9cTb0IaYWC3EHLOTjsU7MNQW+n2xGXr11QPpg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.1.tgz", + "integrity": "sha512-Gk42XZXo1cE89i3hPLa/9KZ8OuupTjkDmhLaMKFohjf9brOeZVEa3BQy1J9s9TWUqPhgAEbwv6B2+ciGfe54Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.1.tgz", + "integrity": "sha512-YjqXCl8QGhVlMR8uBftWk0iTmvtntr41PhG1kvzGp0sUP/5ehTM+cwx25hKE54J0CRnHYjSGjSH3gkHEaHIN9g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@swc/helpers/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@tailwindcss/line-clamp": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz", + "integrity": "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", + "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.18", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tailwindcss/node/node_modules/detect-libc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@tailwindcss/node/node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@tailwindcss/node/node_modules/jiti": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", + "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node/node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", + "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-x64": "4.1.13", + "@tailwindcss/oxide-freebsd-x64": "4.1.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-x64-musl": "4.1.13", + "@tailwindcss/oxide-wasm32-wasi": "4.1.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", + "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", + "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", + "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", + "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", + "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", + "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", + "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", + "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", + "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", + "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/detect-libc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", + "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.13", + "@tailwindcss/oxide": "4.1.13", + "postcss": "^8.4.41", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", + "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/react": { + "version": "19.1.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.15.tgz", + "integrity": "sha512-+kLxJpaJzXybyDyFXYADyP1cznTO8HSuBpenGlnKOAkH4hyNINiywvXS/tGJhsrGGP/gM185RA3xpjY0Yg4erA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz", + "integrity": "sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/busboy/node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001745", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", + "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/core-js": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.227", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", + "integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/framer-motion/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jspdf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz", + "integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.9", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/lucide-react": { + "version": "0.516.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.516.0.tgz", + "integrity": "sha512-aybBJzLHcw1CIn3rUcRkztB37dsJATtpffLNX+0/w+ws2p21nYIlOwX/B5fqxq8F/BjqVemnJX8chKwRidvROg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.1.tgz", + "integrity": "sha512-zxbsdQv3OqWXybK5tMkPCBKyhIz63RstJ+NvlfkaLMc/m5MwXgz2e92k+hSKcyBpyADhMk2C31RIiaDjUZae7g==", + "license": "MIT", + "dependencies": { + "@next/env": "15.2.1", + "@swc/counter": "0.1.3", + "@swc/helpers": "0.5.15", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.2.1", + "@next/swc-darwin-x64": "15.2.1", + "@next/swc-linux-arm64-gnu": "15.2.1", + "@next/swc-linux-arm64-musl": "15.2.1", + "@next/swc-linux-x64-gnu": "15.2.1", + "@next/swc-linux-x64-musl": "15.2.1", + "@next/swc-win32-arm64-msvc": "15.2.1", + "@next/swc-win32-x64-msvc": "15.2.1", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-smooth/node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/react-smooth/node_modules/fast-equals": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz", + "integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/react-smooth/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/react-smooth/node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react-smooth/node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react-smooth/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-smooth/node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-to-print": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.2.0.tgz", + "integrity": "sha512-IX2D0mebKMgYTBD6s5tf9B7YRL3RFWjRoevYK8JKgRwn94Rep7PFZyeOTGjCmXofKB1SKzvPSzDrAMG4I2PIwg==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts-scale/node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/sharp/node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/sharp/node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/sharp/node_modules/detect-libc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", + "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sharp/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT", + "optional": true + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp/node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/styled-jsx/node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", + "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/tar": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "dev": true, + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/victory-vendor/node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/victory-vendor/node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/victory-vendor/node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/victory-vendor/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/TruthScan AI_frontend/package.json b/TruthScan AI_frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..91ece8ddc20ac41c8315a04d5a8edd876862ee2b --- /dev/null +++ b/TruthScan AI_frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "news-analysis-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@tailwindcss/line-clamp": "^0.4.4", + "framer-motion": "^12.23.24", + "jspdf": "^3.0.3", + "lucide-react": "^0.516.0", + "next": "15.2.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-to-print": "^3.2.0", + "recharts": "^2.15.1", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/d3-color": "^3.1.3", + "@types/d3-path": "^3.1.1", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.13", + "typescript": "^5" + } +} diff --git a/TruthScan AI_frontend/postcss.config.js b/TruthScan AI_frontend/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..33ad091d26d8a9dc95ebdf616e217d985ec215b8 --- /dev/null +++ b/TruthScan AI_frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/TruthScan AI_frontend/postcss.config.mjs b/TruthScan AI_frontend/postcss.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c7bcb4b1ee14cd5e25078c2c934529afdd2a7df9 --- /dev/null +++ b/TruthScan AI_frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/TruthScan AI_frontend/public/favicon.ico b/TruthScan AI_frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c Binary files /dev/null and b/TruthScan AI_frontend/public/favicon.ico differ diff --git a/TruthScan AI_frontend/public/file.svg b/TruthScan AI_frontend/public/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..004145cddf3f9db91b57b9cb596683c8eb420862 --- /dev/null +++ b/TruthScan AI_frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TruthScan AI_frontend/public/globe.svg b/TruthScan AI_frontend/public/globe.svg new file mode 100644 index 0000000000000000000000000000000000000000..567f17b0d7c7fb662c16d4357dd74830caf2dccb --- /dev/null +++ b/TruthScan AI_frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TruthScan AI_frontend/public/logo.svg b/TruthScan AI_frontend/public/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/TruthScan AI_frontend/public/next.svg b/TruthScan AI_frontend/public/next.svg new file mode 100644 index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398 --- /dev/null +++ b/TruthScan AI_frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TruthScan AI_frontend/public/vercel.svg b/TruthScan AI_frontend/public/vercel.svg new file mode 100644 index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9 --- /dev/null +++ b/TruthScan AI_frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TruthScan AI_frontend/public/window.svg b/TruthScan AI_frontend/public/window.svg new file mode 100644 index 0000000000000000000000000000000000000000..b2b2a44f6ebc70c450043c05a002e7a93ba5d651 --- /dev/null +++ b/TruthScan AI_frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TruthScan AI_frontend/styles/globals.css b/TruthScan AI_frontend/styles/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..a5d428813c57bb49de734bdfa15576bb157a43db --- /dev/null +++ b/TruthScan AI_frontend/styles/globals.css @@ -0,0 +1,137 @@ +/** + * Globalne style aplikacji TruthScan AI. + * + * Zawiera: + * - konfigurację Tailwind CSS, + * - zmienne CSS dla motywu jasnego i ciemnego, + * - podstawowe style globalne (html, body, linki), + * - animacje pomocnicze, + * - style dedykowane do eksportu i drukowania PDF. + */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +@tailwind base; +@tailwind components; +@tailwind utilities; + + +/* Custom styles or CSS variables */ +:root { + --background: #ffffff; + --foreground: #000000; + --card: #f9fafb; + --accent: #3b82f6; + --muted: #6b7280; + --font-geist-sans: 'Geist', sans-serif; + --font-geist-mono: 'Geist Mono', monospace; + --chart-color: #FF4136; + --chart-grid: rgba(0, 0, 0, 0.137); +} + +.dark { + --background: #0a0a0a; + --foreground: #f1f5f9; + --card: #1f2937; + --accent: #60a5fa; + --muted: #9ca3af; + --chart-color: #FF6B6B; + --chart-grid: rgba(255, 255, 255, 0.137); +} + + +/* Global styles */ +html, +body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-geist-sans), sans-serif; + height: 100%; +} + +/* Links */ +a { + @apply text-blue-600 hover:underline transition-colors; +} + +.dark a { + @apply text-blue-400; +} + +@keyframes tipIn { + from { opacity: 0; transform: scale(0.98); } + to { opacity: 1; transform: scale(1); } +} + +@media print { + @page { + margin: 15mm; + size: A4; + } + + body { + margin: 0 !important; + padding: 0 !important; + font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif !important; + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; + color: #000 !important; + background: white !important; + line-height: 1.4; + } + + .pdf-generator-controls, + .no-print, + button, + .btn, + .navigation, + header, + footer { + display: none !important; + } + + * { + -webkit-print-color-adjust: exact !important; + color-adjust: exact !important; + } + + .pdf-content { + margin: 0 !important; + padding: 0 !important; + box-shadow: none !important; + border: none !important; + background: white !important; + } + + table { + width: 100% !important; + border-collapse: collapse !important; + } + + th, td { + border: 1px solid #ddd !important; + padding: 8px !important; + background: white !important; + } + + th { + background-color: #f5f5f5 !important; + } + + h1, h2, h3, h4, h5, h6 { + color: #000 !important; + page-break-after: avoid; + } + + p { + color: #000 !important; + } + + .break-inside-avoid { + page-break-inside: avoid; + break-inside: avoid; + } +} + + + + diff --git a/TruthScan AI_frontend/tailwind.config.js b/TruthScan AI_frontend/tailwind.config.js new file mode 100644 index 0000000000000000000000000000000000000000..6202e510b5cddc78088a3abd4063b9c67ff55979 --- /dev/null +++ b/TruthScan AI_frontend/tailwind.config.js @@ -0,0 +1,46 @@ +/** + * Konfiguracja Tailwind CSS dla aplikacji TruthScan AI. + * + * Zawiera: + * - ścieżki do plików objętych analizą klas (content), + * - obsługę trybu ciemnego przez klasę `.dark`, + * - mapowanie zmiennych CSS na kolory Tailwind, + * - definicje fontów globalnych, + * - safelistę klas używanych dynamicznie. + */ +module.exports = { + content: [ + "./app/**/*.{js,ts,jsx,tsx}", + "./components/**/*.{js,ts,jsx,tsx}", + "./lib/**/*.{js,ts,jsx,tsx}", + ], + darkMode: "class", + theme: { + extend: { + colors: { + background: "hsl(var(--background) / )", + foreground: "hsl(var(--foreground) / )", + card: "hsl(var(--card) / )", + accent: "hsl(var(--accent) / )", + muted: "hsl(var(--muted) / )", + }, + fontFamily: { + sans: ["var(--font-geist-sans)", "sans-serif"], + mono: ["var(--font-geist-mono)", "monospace"], + }, + }, + }, + safelist: [ + "bg-background", + "text-foreground", + "dark:bg-background", + "dark:text-foreground", + "bg-card", + "text-muted", + "border", + "dark:border", + ], + plugins: [], +}; + + diff --git a/TruthScan AI_frontend/tsconfig.json b/TruthScan AI_frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..aedd2e6277f3c66ee56712dc108320cb7fa46f90 --- /dev/null +++ b/TruthScan AI_frontend/tsconfig.json @@ -0,0 +1,38 @@ +/** + * Konfiguracja TypeScript dla aplikacji TruthScan AI (Next.js). + * + * Odpowiada za: + * - ustawienia kompilatora zgodne z App Routerem Next.js, + * - ścisłą kontrolę typów (strict mode), + * - obsługę JSX i modułów ESNext, + * - mapowanie aliasów importów (@/*), + * - definicję plików objętych analizą typów. + */ + +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/dashboard/page.tsx", "components/ArticleList.tsx"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..fcc66a5d9b1035c0326782923bd5f537f92e7962 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "jspdf": "^3.0.3" + } +}