FE-3 + FE-4: Full-Featured Research-Ansicht + Docker Deployment
FE-3:
- Error-System: ErrorBannerStore + ErrorBanner (stapelbar, auto-hide, 5 categories)
- ContradictionBanner: Visuelle Widerspruchserkennung
- StatusTab: Zeitleiste, Fortschrittsbalken, Details
- ShareButton: Inline-Share mit Geteilt-Badge
- ShareModal: Ablaufzeit (24h-30Tage/Nie), Max-Views, Copy/Revoke
- research/{id}/+page.svelte: 6 Tabs mit Polling (5s), onDestroy cleanup
- share/[token]/+page.svelte: Vollständige Read-Only Share-Ansicht mit Wasserzeichen
- research/new/+page.svelte: Char-Counter, Language/Depth-Auswahl
FE-4:
- Dockerfile: HEALTHCHECK, Error-Handling, multi-stage build
- Caddyfile: HTTPS, Security Headers, gzip+zstd
- docker-compose.yml: Healthchecks, resource limits, restart policy
- tests/: Theme, API, Formatter, Share-Link Tests
- README.md: Architektur-Diagramm, Installation, Nutzung
- CHANGELOG.md: FE-0 bis FE-4
This commit is contained in:
62
CHANGELOG.md
Normal file
62
CHANGELOG.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle bedeutenden Änderungen an diesem Projekt werden in diesem Dokument dokumentiert.
|
||||||
|
|
||||||
|
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
und das Versionsierung folgt dem [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [FE-4] — 2025-01-XX — Docker Deployment, HTTPS, Tests
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Dockerfile**: Verbessert mit HEALTHCHECK, Error-Handling (`|| exit 1`), multi-stage build
|
||||||
|
- **Caddyfile**: HTTPS mit Let's Encrypt (automatisch wenn CADDY_DOMAIN gesetzt), Security Headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Content-Security-Policy, Referrer-Policy, Permissions-Policy), gzip + zstd Compression
|
||||||
|
- **docker-compose.yml**: Healthchecks für web und caddy, resource limits (memory, cpus), restart policy `unless-stopped`, networks, volumes
|
||||||
|
- **tests/**: Theme-Persistenz, API-Client, Formatter, Share-Link-Expiration Tests
|
||||||
|
- **README.md**: Komplette Dokumentation mit Architektur-Diagramm, Installation, Nutzung, Environment Variables
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Share-Links: Token-basiert mit Ablaufzeit (24h, 48h, 7Tage, 14Tage, 30Tage, Nie) und Max-Views (1, 10, 50, Unbegrenzt)
|
||||||
|
- Share-Link-Seite ist PUBLIC (kein Auth erforderlich), Read-Only
|
||||||
|
|
||||||
|
## [FE-3] — Full-Featured Research-Ansicht
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Error-System**: `ErrorBannerStore.ts` + `ErrorBanner.svelte` — stapelbar, auto-hide (5s), Error/Success/Warning/Fatal/Info Kategorien
|
||||||
|
- **ContradictionBanner.svelte**: Visuelle Anzeige widersprüchlicher Claims mit rot umrandeter Box
|
||||||
|
- **StatusTab.svelte**: Status-Zeitleiste (CREATED → COMPLETED), Fortschrittsbalken, Fehlermeldung
|
||||||
|
- **ShareButton.svelte**: Inline-Share-Button mit "Geteilt"-Badge und Clipboard-Funktion
|
||||||
|
- **ShareModal.svelte**: Vollständiges Share-Dialog mit Ablaufzeit, Max-Views, Link-Kopieren, Link deaktivieren
|
||||||
|
- **research/{id}/+page.svelte**: 6 Tabs mit voller Inhalt (Status, Quellen, Claims, Evidence, Bericht, Methodik), Polling alle 5s, onDestroy Cleanup
|
||||||
|
- **share/[token]/+page.svelte**: Vollständige Share-Link-Ansicht mit Read-Only-Modus, Wasserzeichen
|
||||||
|
- **research/new/+page.svelte**: Char-Counter (max 500), Language-Auswahl, Depth-Auswahl, Loading-State
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Research-Detail-Seite: Vollständige 6-Tab-Implementierung mit Polling
|
||||||
|
- Share-Modal: Ablaufzeit-Slider, Max-Views, visuelles Feedback
|
||||||
|
|
||||||
|
## [FE-2] — API-Anbindung, Share-Links, Markdown
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `research-api.ts`: Vollständiger Research-API-Client (createResearch, getResearchStatus, getResearchDetail, listResearch, getResearchReport, deleteResearch, shareResearch, getShareData, stopResearch, revokeShare)
|
||||||
|
- `ShareModal.svelte`: Share-Link-Generierung mit Ablaufzeit und Max-Views
|
||||||
|
- `share/[token]/+page.svelte`: Public Read-Only Share-Ansicht
|
||||||
|
- `ReportTab.svelte`: Markdown-Report mit Download als JSON/Markdown/Text
|
||||||
|
- `svelte-markdown` als Dependency für Markdown-Rendering
|
||||||
|
|
||||||
|
## [FE-1] — Login, Sidebar, Research-Layout
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Login-Seite mit API-Key-Auth
|
||||||
|
- Sidebar mit Research-Historie
|
||||||
|
- Research-Detail-Layout mit Tabs
|
||||||
|
- Theme-Toggle (Dark/Light)
|
||||||
|
|
||||||
|
## [FE-0] — Projektgrundgerüst
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- SvelteKit + TailwindCSS + TypeScript Setup
|
||||||
|
- Dockerfile + docker-compose.yml für Deployment
|
||||||
|
- Basis-Komponenten und Layout
|
||||||
|
- API-Client Grundgerüst
|
||||||
54
Caddyfile
54
Caddyfile
@@ -1,12 +1,60 @@
|
|||||||
|
# HTTPS with automatic Let's Encrypt (only if CADDY_DOMAIN is set)
|
||||||
|
# Fallback: HTTP only if no domain
|
||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
:443 {
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
|
|
||||||
# Security Headers
|
# Security Headers
|
||||||
@notPrivateIP not remote_ip 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8
|
header {
|
||||||
|
|
||||||
encode gzip zstd
|
|
||||||
X-Content-Type-Options nosniff
|
X-Content-Type-Options nosniff
|
||||||
X-Frame-Options DENY
|
X-Frame-Options DENY
|
||||||
X-XSS-Protection "1; mode=block"
|
X-XSS-Protection "1; mode=block"
|
||||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://*; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'"
|
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://*; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||||
|
Referrer-Policy no-referrer-when-downgrade
|
||||||
|
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||||
|
X-Permitted-Cross-Domain-Policies none
|
||||||
|
X-DNS-Prefetch-Control off
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS redirect (only when domain is set)
|
||||||
|
@hasDomain {
|
||||||
|
host {env.CADDY_DOMAIN}
|
||||||
|
}
|
||||||
|
redir @hasDomain https://{host}{uri}
|
||||||
|
|
||||||
|
# Compression
|
||||||
|
encode gzip zstd
|
||||||
|
|
||||||
|
# Gzip level
|
||||||
|
gzip 1
|
||||||
|
|
||||||
|
# Rate limiting (optional, only if NSCT_RATE_LIMIT is set)
|
||||||
|
@hasRateLimit {
|
||||||
|
expression {env.NSCT_RATE_LIMIT} != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log
|
||||||
|
log {
|
||||||
|
format json
|
||||||
|
output stdout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# HTTP fallback (when no domain is set)
|
||||||
|
:80 {
|
||||||
|
reverse_proxy web:3000
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
X-Frame-Options DENY
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://*; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||||
|
Referrer-Policy no-referrer-when-downgrade
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip zstd
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Dockerfile
16
Dockerfile
@@ -1,23 +1,33 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json .
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json ./
|
||||||
|
COPY package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
RUN npm run build || exit 1
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy built assets and dependencies
|
||||||
COPY --from=builder /app/build ./build
|
COPY --from=builder /app/build ./build
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY package.json package.json
|
COPY package.json .
|
||||||
|
|
||||||
# Non-root user
|
# Non-root user
|
||||||
RUN addgroup -S nsct && adduser -S nsct -G nsct
|
RUN addgroup -S nsct && adduser -S nsct -G nsct
|
||||||
USER nsct
|
USER nsct
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://localhost:3000/health || exit 1
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
119
README.md
Normal file
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# NSCT Research Frontend
|
||||||
|
|
||||||
|
Web-Oberfläche für den **Neutral Search Crawler Tool (NSCT)** — eine Such- und Analyseplattform für faktbasierte Recherche.
|
||||||
|
|
||||||
|
## Architektur-Diagramm
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Client │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ Research │ │ Sources │ │ Claims │ │ Report │ │
|
||||||
|
│ │ Detail │ │ Tab │ │ Tab │ │ Tab │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ SvelteKit App (SPA) │ │
|
||||||
|
│ │ - 6 Tabs: Status, Quellen, Claims │ │
|
||||||
|
│ │ - Evidence, Bericht, Methodik │ │
|
||||||
|
│ │ - Error-System, Share-Links │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼ fetch
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Proxy │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Caddy 2 │ │
|
||||||
|
│ │ - HTTPS (Let's Encrypt) │ │
|
||||||
|
│ │ - Security Headers │ │
|
||||||
|
│ │ - gzip + zstd Compression │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend API │
|
||||||
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ NSCT Backend (Port 8080) │ │
|
||||||
|
│ │ - /v1/research │ │
|
||||||
|
│ │ - /v1/research/{id}/status │ │
|
||||||
|
│ │ - /v1/research/{id} │ │
|
||||||
|
│ │ - /v1/research/share/{token} │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Research-Tab** mit 6 Ansichten: Status, Quellen, Claims, Evidence, Bericht, Methodik
|
||||||
|
- **Contradiction Detection**: Automatische Erkennung widersprüchlicher Claims
|
||||||
|
- **Share-Links**: Token-basierte Freigabe mit Ablaufzeit und Max-Views
|
||||||
|
- **Error-System**: Stapelbare Toast-Notifications mit Auto-hide
|
||||||
|
- **Error-Handling**: Polling bei COMPLETED/FAILED, onDestroy Cleanup
|
||||||
|
- **Responsive Design**: Mobile-First mit TailwindCSS
|
||||||
|
- **Docker Deployment**: Caddy Reverse-Proxy mit HTTPS, Healthchecks, Resource-Limits
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Lokal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mit API-Key
|
||||||
|
|
||||||
|
Setze die `NSCT_API_BASE_URL` Environment Variable für die Backend-Adresse:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Beschreibung | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `NSCT_API_BASE_URL` | Backend API URL | `http://localhost:8080` |
|
||||||
|
| `CADDY_DOMAIN` | Domain für HTTPS (Let's Encrypt) | keine (HTTP nur) |
|
||||||
|
| `NSCT_RATE_LIMIT` | Rate-Limit Config für Caddy | keine |
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
1. **API-Key eintragen** im Login
|
||||||
|
2. **Neue Recherche starten** mit Suchanfrage, Sprache und Tiefe
|
||||||
|
3. **Fortschritt verfolgen** im Status-Tab (Polling alle 5s)
|
||||||
|
4. **Quellen & Claims analysieren** in den jeweiligen Tabs
|
||||||
|
5. **Bericht herunterladen** als JSON, Markdown oder Text
|
||||||
|
6. **Thread teilen** über Share-Links mit Ablaufzeit
|
||||||
|
|
||||||
|
## Share-Links
|
||||||
|
|
||||||
|
- Token-basiert, zeitlich begrenzt
|
||||||
|
- Ablaufzeiten: 24h, 48h, 7Tage, 14Tage, 30Tage, Nie
|
||||||
|
- Max. Aufrufe: 1, 10, 50, Unbegrenzt
|
||||||
|
- Read-Only-Zugriff für Empfänger
|
||||||
|
- Token kann deaktiviert werden
|
||||||
|
|
||||||
|
## Testen
|
||||||
|
|
||||||
|
Die Tests befinden sich im `tests/`-Verzeichnis:
|
||||||
|
|
||||||
|
- `tests/test_theme.svelte` — Theme-Persistenz (localStorage)
|
||||||
|
- `tests/test_api.svelte` — API-Client-Tests
|
||||||
|
- `tests/test_formatter.svelte` — Formatter-Tests
|
||||||
|
- `tests/test_share_link.svelte` — Share-Link-Ablauf-Tests
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
- **SvelteKit** + **TailwindCSS** + **TypeScript**
|
||||||
|
- SvelteKit Static Adapter (`adapter-static`)
|
||||||
|
- Build Output: `build/`
|
||||||
|
- Polling: Alle 5 Sekunden, stoppt bei COMPLETED/FAILED
|
||||||
|
- Error-System: `ErrorBannerStore.ts` + `ErrorBanner.svelte` (stapelbar, auto-hide)
|
||||||
@@ -12,6 +12,20 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
networks:
|
networks:
|
||||||
- nsct-network
|
- nsct-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '1.0'
|
||||||
|
reservations:
|
||||||
|
memory: 128M
|
||||||
|
cpus: '0.25'
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2.8-alpine
|
image: caddy:2.8-alpine
|
||||||
@@ -24,10 +38,27 @@ services:
|
|||||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
|
environment:
|
||||||
|
- CADDY_DOMAIN=${CADDY_DOMAIN:-}
|
||||||
|
- NSCT_RATE_LIMIT=${NSCT_RATE_LIMIT:-}
|
||||||
networks:
|
networks:
|
||||||
- nsct-network
|
- nsct-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- web
|
- web
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 64M
|
||||||
|
cpus: '0.1'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
nsct-network:
|
nsct-network:
|
||||||
|
|||||||
6
src/app.d.ts
vendored
6
src/app.d.ts
vendored
@@ -12,3 +12,9 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const window: {
|
||||||
|
__shareToken: string | null;
|
||||||
|
} & Window;
|
||||||
|
}
|
||||||
98
src/lib/components/ContradictionBanner.svelte
Normal file
98
src/lib/components/ContradictionBanner.svelte
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ClaimInfo, SourceInfo } from '$lib/types';
|
||||||
|
|
||||||
|
export let claims: ClaimInfo[] = [];
|
||||||
|
export let sources: SourceInfo[] = [];
|
||||||
|
export let onDismiss?: () => void;
|
||||||
|
|
||||||
|
// Find contradictions: claims with same topic but different conclusions
|
||||||
|
// A simple heuristic: claims that reference the same source_id or overlapping text
|
||||||
|
interface ContradictionPair {
|
||||||
|
claimA: ClaimInfo;
|
||||||
|
claimB: ClaimInfo;
|
||||||
|
sourceA: SourceInfo | undefined;
|
||||||
|
sourceB: SourceInfo | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: contradictions: ContradictionPair[] = findContradictions(claims, sources);
|
||||||
|
|
||||||
|
function findContradictions(claims: ClaimInfo[], sources: SourceInfo[]): ContradictionPair[] {
|
||||||
|
const pairs: ContradictionPair[] = [];
|
||||||
|
for (let i = 0; i < claims.length; i++) {
|
||||||
|
for (let j = i + 1; j < claims.length; j++) {
|
||||||
|
const a = claims[i];
|
||||||
|
const b = claims[j];
|
||||||
|
|
||||||
|
// Check if same source_id but different confidence (potential contradiction)
|
||||||
|
const sameSource = a.source_id && b.source_id && a.source_id === b.source_id;
|
||||||
|
const differentConfidence = Math.abs(a.confidence - b.confidence) > 0.3;
|
||||||
|
|
||||||
|
// Check if confidence differs significantly and claims are different
|
||||||
|
if (sameSource && differentConfidence) {
|
||||||
|
pairs.push({
|
||||||
|
claimA: a,
|
||||||
|
claimB: b,
|
||||||
|
sourceA: sources.find(s => s.id === a.source_id),
|
||||||
|
sourceB: sources.find(s => s.id === b.source_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if contradictions.length > 0}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each contradictions as pair}
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-xl border-2 border-red-300 dark:border-red-700 bg-red-50/80 dark:bg-red-900/10"
|
||||||
|
>
|
||||||
|
<!-- Contradiction badge -->
|
||||||
|
<div class="absolute top-0 left-0 right-0 bg-red-500 dark:bg-red-600 text-white text-xs font-bold px-3 py-1 text-center uppercase tracking-wider">
|
||||||
|
⚠ Widerspruch erkannt
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-8 pb-4 px-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Claim A -->
|
||||||
|
<div class="p-3 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-xs font-bold text-red-600 dark:text-red-400">Quelle A</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-900 dark:text-white leading-relaxed">{pair.claimA.claim}</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||||
|
{#if pair.claimA.source_id}
|
||||||
|
<span class="text-xs text-gray-500 font-mono">{pair.claimA.source_id}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pair.sourceA?.domain}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-gray-600 dark:text-gray-400">
|
||||||
|
{pair.sourceA.domain}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Claim B -->
|
||||||
|
<div class="p-3 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-xs font-bold text-red-600 dark:text-red-400">Quelle B</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-900 dark:text-white leading-relaxed">{pair.claimB.claim}</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||||
|
{#if pair.claimB.source_id}
|
||||||
|
<span class="text-xs text-gray-500 font-mono">{pair.claimB.source_id}</span>
|
||||||
|
{/if}
|
||||||
|
{#if pair.sourceB?.domain}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-gray-600 dark:text-gray-400">
|
||||||
|
{pair.sourceB.domain}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
54
src/lib/components/ErrorBanner.svelte
Normal file
54
src/lib/components/ErrorBanner.svelte
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { errorBanners } from './ErrorBannerStore';
|
||||||
|
|
||||||
|
type ErrorType = 'error' | 'success' | 'warning' | 'fatal' | 'info';
|
||||||
|
|
||||||
|
const TYPE_STYLES: Record<ErrorType, string> = {
|
||||||
|
error: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300',
|
||||||
|
success: 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300',
|
||||||
|
warning: 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300',
|
||||||
|
fatal: 'bg-red-100 dark:bg-red-900/40 border-red-400 dark:border-red-700 text-red-900 dark:text-red-200',
|
||||||
|
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<ErrorType, string> = {
|
||||||
|
error: '⚠️',
|
||||||
|
success: '✅',
|
||||||
|
warning: '⚠️',
|
||||||
|
fatal: '🔴',
|
||||||
|
info: 'ℹ️',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full pointer-events-none">
|
||||||
|
{#each $errorBanners as banner (banner.id)}
|
||||||
|
{#if !banner.dismissed}
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg animate-slide-in {TYPE_STYLES[banner.type]}"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span class="flex-shrink-0 text-sm mt-0.5">{TYPE_ICONS[banner.type]}</span>
|
||||||
|
<p class="flex-1 text-sm leading-relaxed">{banner.message}</p>
|
||||||
|
<button
|
||||||
|
on:click={() => errorBanners.dismiss(banner.id)}
|
||||||
|
class="flex-shrink-0 p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes slide-in {
|
||||||
|
from { opacity: 0; transform: translateX(100%); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slide-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
src/lib/components/ErrorBannerStore.ts
Normal file
73
src/lib/components/ErrorBannerStore.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export type ErrorType = 'error' | 'success' | 'warning' | 'fatal' | 'info';
|
||||||
|
|
||||||
|
export interface BannerMessage {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: ErrorType;
|
||||||
|
duration?: number;
|
||||||
|
dismissed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBanner(message: string, type: ErrorType, duration = 5000): BannerMessage {
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
duration: duration > 0 ? duration : undefined,
|
||||||
|
dismissed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addError(message: string, type: ErrorType = 'error', duration?: number) {
|
||||||
|
return banners.update(banners => {
|
||||||
|
const banner = createBanner(message, type, duration);
|
||||||
|
return [...banners, banner];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addErrorSuccess(message: string) {
|
||||||
|
return addError(message, 'success', 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addErrorWarning(message: string) {
|
||||||
|
return addError(message, 'warning', 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addErrorFatal(message: string) {
|
||||||
|
return addError(message, 'fatal', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addErrorInfo(message: string) {
|
||||||
|
return addError(message, 'info', 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createErrorBannerStore() {
|
||||||
|
const { subscribe, update } = writable<BannerMessage[]>([]);
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
update(banners => banners.filter(b => b.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
update([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
dismiss,
|
||||||
|
clearAll,
|
||||||
|
addError,
|
||||||
|
addErrorSuccess,
|
||||||
|
addErrorWarning,
|
||||||
|
addErrorFatal,
|
||||||
|
addErrorInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorBanners = createErrorBannerStore();
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getApiKey } from '$lib/auth';
|
import { getApiKey } from '$lib/auth';
|
||||||
import { shareResearch, type ShareResult } from '$lib/research-api';
|
import { shareResearch, revokeShare, type ShareResult } from '$lib/research-api';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { errorBanners } from '$lib/components/ErrorBannerStore';
|
||||||
|
import type { ResearchDetail } from '$lib/research-api';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
export let researchId: string;
|
export let researchId: string;
|
||||||
|
export let detail: ResearchDetail | null = null;
|
||||||
|
|
||||||
const EXPIRY_OPTIONS = [
|
const EXPIRY_OPTIONS = [
|
||||||
{ label: '24 Stunden', value: '24h', hours: 24 },
|
{ label: '24 Stunden', value: '24h', hours: 24 },
|
||||||
@@ -24,11 +27,14 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
let selectedExpiry = '7d';
|
let selectedExpiry = '7d';
|
||||||
let maxViews = '';
|
let maxViews = '10';
|
||||||
let generating = false;
|
let generating = false;
|
||||||
let result: ShareResult | null = null;
|
let result: ShareResult | null = null;
|
||||||
let copied = false;
|
let copied = false;
|
||||||
let error = '';
|
let existingToken: string | null = null;
|
||||||
|
|
||||||
|
// Check if there's an existing share token in the detail
|
||||||
|
$: existingToken = (detail as any)?.share_token ?? null;
|
||||||
|
|
||||||
function getExpiryDate(hours: number): string | undefined {
|
function getExpiryDate(hours: number): string | undefined {
|
||||||
if (hours <= 0) return undefined;
|
if (hours <= 0) return undefined;
|
||||||
@@ -38,12 +44,11 @@
|
|||||||
|
|
||||||
async function generateLink() {
|
async function generateLink() {
|
||||||
generating = true;
|
generating = true;
|
||||||
error = '';
|
|
||||||
result = null;
|
result = null;
|
||||||
|
|
||||||
const apiKey = getApiKey();
|
const apiKey = getApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
error = 'Kein API-Key verfügbar.';
|
errorBanners.addError('Kein API-Key verfügbar.', 'error');
|
||||||
generating = false;
|
generating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,11 +56,12 @@
|
|||||||
try {
|
try {
|
||||||
const hours = EXPIRY_OPTIONS.find(o => o.value === selectedExpiry)?.hours ?? 168;
|
const hours = EXPIRY_OPTIONS.find(o => o.value === selectedExpiry)?.hours ?? 168;
|
||||||
const expiresAt = getExpiryDate(hours);
|
const expiresAt = getExpiryDate(hours);
|
||||||
const maxViewsNum = maxViews === '' || maxViews === '0' ? undefined : parseInt(maxViews, 10);
|
const maxViewsNum = maxViews === '0' || maxViews === '' ? undefined : parseInt(maxViews, 10);
|
||||||
|
|
||||||
result = await shareResearch(apiKey, researchId, expiresAt, maxViewsNum);
|
result = await shareResearch(apiKey, researchId, expiresAt, maxViewsNum);
|
||||||
|
errorBanners.addErrorSuccess('Share-Link generiert.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Link konnte nicht generiert werden.';
|
errorBanners.addError(e instanceof Error ? e.message : 'Link konnte nicht generiert werden.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
generating = false;
|
generating = false;
|
||||||
}
|
}
|
||||||
@@ -66,16 +72,29 @@
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(result.url);
|
await navigator.clipboard.writeText(result.url);
|
||||||
copied = true;
|
copied = true;
|
||||||
|
errorBanners.addErrorSuccess('Link kopiert!');
|
||||||
setTimeout(() => { copied = false; }, 2000);
|
setTimeout(() => { copied = false; }, 2000);
|
||||||
} catch {
|
} catch {
|
||||||
error = 'Link konnte nicht kopiert werden.';
|
errorBanners.addError('Link konnte nicht kopiert werden.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeLink() {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) return;
|
||||||
|
try {
|
||||||
|
await revokeShare(apiKey, existingToken ?? '');
|
||||||
|
result = null;
|
||||||
|
existingToken = null;
|
||||||
|
errorBanners.addErrorSuccess('Share-Link deaktiviert.');
|
||||||
|
} catch (e) {
|
||||||
|
errorBanners.addError(e instanceof Error ? e.message : 'Link konnte nicht deaktiviert werden.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
result = null;
|
result = null;
|
||||||
copied = false;
|
copied = false;
|
||||||
error = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@@ -111,7 +130,7 @@
|
|||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="px-6 py-4 space-y-4">
|
<div class="px-6 py-4 space-y-4">
|
||||||
{#if result}
|
{#if result || existingToken}
|
||||||
<!-- Generated link -->
|
<!-- Generated link -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
@@ -121,7 +140,7 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readonly
|
readonly
|
||||||
value={result.url}
|
value={result?.url ?? `https://app.example.com/share/${existingToken}`}
|
||||||
class="flex-1 px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-900 dark:text-white font-mono truncate"
|
class="flex-1 px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-900 dark:text-white font-mono truncate"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -131,12 +150,18 @@
|
|||||||
{copied ? '✓ Kopiert' : 'Kopieren'}
|
{copied ? '✓ Kopiert' : 'Kopieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
|
<div class="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
<p>Gültig bis: {result.token ? new Date().toLocaleDateString('de-DE') : '—'}</p>
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
{#if maxViews && maxViews !== '0'}
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 9.586 7.707 8.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
<p>Max. Aufrufe: {maxViews}</p>
|
</svg>
|
||||||
{/if}
|
Link wurde erfolgreich generiert
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
on:click={revokeLink}
|
||||||
|
class="text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 underline"
|
||||||
|
>
|
||||||
|
Link deaktivieren
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Expiry selector -->
|
<!-- Expiry selector -->
|
||||||
@@ -163,7 +188,6 @@
|
|||||||
bind:value={maxViews}
|
bind:value={maxViews}
|
||||||
class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="">Unbegrenzt</option>
|
|
||||||
{#each MAX_VIEWS_OPTIONS as opt}
|
{#each MAX_VIEWS_OPTIONS as opt}
|
||||||
<option value={opt.value}>{opt.label}</option>
|
<option value={opt.value}>{opt.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -189,12 +213,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
||||||
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { isAuthenticated } from '$lib/auth';
|
import { isAuthenticated } from '$lib/auth';
|
||||||
import { createResearch } from '$lib/sidebar';
|
import { createResearch } from '$lib/sidebar';
|
||||||
|
import { errorBanners } from '$lib/components/ErrorBannerStore';
|
||||||
|
|
||||||
let query = '';
|
let query = '';
|
||||||
let language = 'Deutsch';
|
let language = 'Deutsch';
|
||||||
@@ -9,6 +10,12 @@
|
|||||||
let submitting = false;
|
let submitting = false;
|
||||||
let error = '';
|
let error = '';
|
||||||
|
|
||||||
|
const MAX_LENGTH = 500;
|
||||||
|
|
||||||
|
$: charCount = query.length;
|
||||||
|
$: remaining = MAX_LENGTH - charCount;
|
||||||
|
$: charCountColor = remaining < 0 ? 'text-red-500' : remaining <= 20 ? 'text-amber-500' : 'text-gray-400';
|
||||||
|
|
||||||
function getApiKey(): string | null {
|
function getApiKey(): string | null {
|
||||||
return localStorage.getItem('nsct-api-key');
|
return localStorage.getItem('nsct-api-key');
|
||||||
}
|
}
|
||||||
@@ -16,12 +23,20 @@
|
|||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
error = 'Bitte gib eine Suchanfrage ein.';
|
error = 'Bitte gib eine Suchanfrage ein.';
|
||||||
|
errorBanners.addError('Keine Suchanfrage eingegeben.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length > MAX_LENGTH) {
|
||||||
|
error = `Maximale Länge von ${MAX_LENGTH} Zeichen erreicht.`;
|
||||||
|
errorBanners.addError('Zu lange Anfrage.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = getApiKey();
|
const key = getApiKey();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
error = 'Nicht authentifiziert.';
|
error = 'Nicht authentifiziert.';
|
||||||
|
errorBanners.addError('Kein API-Key verfügbar.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +45,12 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createResearch(key, query.trim(), language, depth);
|
const result = await createResearch(key, query.trim(), language, depth);
|
||||||
|
errorBanners.addErrorSuccess('Research wird erstellt…');
|
||||||
window.location.href = `/research/${result.id}`;
|
window.location.href = `/research/${result.id}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Fehler beim Erstellen der Recherche.';
|
const msg = e instanceof Error ? e.message : 'Fehler beim Erstellen der Recherche.';
|
||||||
|
error = msg;
|
||||||
|
errorBanners.addError(msg, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
@@ -44,7 +62,7 @@
|
|||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Neue Recherche</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Neue Recherche</h1>
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
|
||||||
<!-- Query textarea -->
|
<!-- Query textarea with counter -->
|
||||||
<div>
|
<div>
|
||||||
<label for="query" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label for="query" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Suchanfrage
|
Suchanfrage
|
||||||
@@ -52,11 +70,20 @@
|
|||||||
<textarea
|
<textarea
|
||||||
id="query"
|
id="query"
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
placeholder="Gib eine Suchanfrage ein..."
|
placeholder="Gib eine Suchanfrage ein... (z.B. 'Was sind die neuesten Forschungsergebnisse zu KI in der Medizin?')"
|
||||||
rows="6"
|
rows="5"
|
||||||
|
maxlength={MAX_LENGTH}
|
||||||
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none transition-colors"
|
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none transition-colors"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<div class="flex items-center justify-between mt-1">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Mindestens 5 Zeichen erforderlich.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-medium {charCountColor}">
|
||||||
|
{charCount} / {MAX_LENGTH}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Language selection -->
|
<!-- Language selection -->
|
||||||
@@ -64,13 +91,14 @@
|
|||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Sprache
|
Sprache
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-3">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each ['Deutsch', 'English', 'Multi'] as lang}
|
{#each ['Deutsch', 'English', 'Multi'] as lang}
|
||||||
<label
|
<label
|
||||||
class="flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer transition-colors"
|
class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition-colors"
|
||||||
class:ring-2 class:ring-indigo-500={language === lang}
|
class:ring-2 class:ring-indigo-500={language === lang}
|
||||||
class:border-indigo-500={language === lang}
|
class:border-indigo-500={language === lang}
|
||||||
class:border-gray-300 dark:border-gray-600={language !== lang}
|
class:border-gray-300 dark:border-gray-600={language !== lang}
|
||||||
|
class:bg-indigo-50 dark:class:bg-indigo-900/20={language === lang}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -91,13 +119,14 @@
|
|||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Tiefe
|
Tiefe
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-3">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each ['quick', 'normal', 'deep'] as d}
|
{#each ['quick', 'normal', 'deep'] as d}
|
||||||
<label
|
<label
|
||||||
class="flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer transition-colors"
|
class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition-colors"
|
||||||
class:ring-2 class:ring-indigo-500={depth === d}
|
class:ring-2 class:ring-indigo-500={depth === d}
|
||||||
class:border-indigo-500={depth === d}
|
class:border-indigo-500={depth === d}
|
||||||
class:border-gray-300 dark:border-gray-600={depth !== d}
|
class:border-gray-300 dark:border-gray-600={depth !== d}
|
||||||
|
class:bg-indigo-50 dark:class:bg-indigo-900/20={depth === d}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -108,7 +137,7 @@
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{d === 'quick' ? 'Schnell' : d === 'normal' ? 'Normal' : 'Tief'}
|
{d === 'quick' ? '⚡ Schnell' : d === 'normal' ? '📋 Normal' : '🔍 Tief'}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -118,8 +147,8 @@
|
|||||||
<!-- Submit button -->
|
<!-- Submit button -->
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting || query.length < 5}
|
||||||
class="w-full px-6 py-3 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed"
|
class="w-full px-6 py-3 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
{#if submitting}
|
{#if submitting}
|
||||||
<span class="flex items-center justify-center gap-2">
|
<span class="flex items-center justify-center gap-2">
|
||||||
@@ -127,7 +156,7 @@
|
|||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Recherche wird gestartet…
|
Wird erstellt…
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
Recherche starten
|
Recherche starten
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { getResearchStatus, getResearchDetail, stopResearch, deleteResearch, revokeShare } from '$lib/research-api';
|
import { getResearchStatus, getResearchDetail, stopResearch, deleteResearch } from '$lib/research-api';
|
||||||
import { getApiKey } from '$lib/auth';
|
import { getApiKey } from '$lib/auth';
|
||||||
import { pollingInterval, pollingActive, lastApiError, shareToken } from '$lib/stores';
|
import { pollingInterval, pollingActive } from '$lib/stores';
|
||||||
import { truncateId } from '$lib/utils/formatters';
|
import { truncateId, formatDate } from '$lib/utils/formatters';
|
||||||
import type { ResearchDetail } from '$lib/research-api';
|
import type { ResearchDetail, ResearchStatus } from '$lib/research-api';
|
||||||
|
import StatusTab from './components/StatusTab.svelte';
|
||||||
import SourcesTab from './components/SourcesTab.svelte';
|
import SourcesTab from './components/SourcesTab.svelte';
|
||||||
import ClaimsTab from './components/ClaimsTab.svelte';
|
import ClaimsTab from './components/ClaimsTab.svelte';
|
||||||
import EvidenceTab from './components/EvidenceTab.svelte';
|
import EvidenceTab from './components/EvidenceTab.svelte';
|
||||||
import ReportTab from './components/ReportTab.svelte';
|
import ReportTab from './components/ReportTab.svelte';
|
||||||
import MethodologyTab from './components/MethodologyTab.svelte';
|
import MethodologyTab from './components/MethodologyTab.svelte';
|
||||||
import ShareModal from '$lib/components/ShareModal.svelte';
|
import ShareModal from '$lib/components/ShareModal.svelte';
|
||||||
|
import ShareButton from './components/ShareButton.svelte';
|
||||||
|
import { errorBanners } from '$lib/components/ErrorBannerStore';
|
||||||
|
import type { ErrorType } from '$lib/components/ErrorBannerStore';
|
||||||
|
|
||||||
export let researchId: string;
|
export let researchId: string;
|
||||||
|
|
||||||
@@ -21,62 +25,60 @@
|
|||||||
let loading = true;
|
let loading = true;
|
||||||
let activeTab = 'Status';
|
let activeTab = 'Status';
|
||||||
let showShareModal = false;
|
let showShareModal = false;
|
||||||
let showStopConfirm = false;
|
|
||||||
let showDeleteConfirm = false;
|
|
||||||
let hasFinished = false;
|
let hasFinished = false;
|
||||||
|
|
||||||
$: TABS = hasFinished
|
// --- Tab definitions with i18n labels ---
|
||||||
? ['Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik']
|
const TABS = ['Status', 'Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik'];
|
||||||
: ['Status', 'Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik'];
|
const TABS_COMPLETED = ['Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik'];
|
||||||
|
|
||||||
// --- Polling ---
|
// --- Polling ---
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
$: pollingMs = $pollingInterval;
|
$: pollingMs = $pollingInterval;
|
||||||
$: isPollingActive = $pollingActive;
|
$: isPollingActive = $pollingActive;
|
||||||
|
$: tabs = (statusText === 'COMPLETED' || statusText === 'FAILED' || statusText === 'STOPPED')
|
||||||
|
? TABS_COMPLETED
|
||||||
|
: TABS;
|
||||||
|
|
||||||
async function doPoll() {
|
async function doPoll() {
|
||||||
try {
|
try {
|
||||||
const apiKey = getApiKey();
|
const apiKey = getApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
error = 'Kein API-Key verfügbar.';
|
errorBanners.addError('Kein API-Key verfügbar.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getResearchStatus(apiKey, researchId);
|
const res: ResearchStatus = await getResearchStatus(apiKey, researchId);
|
||||||
statusText = res.status;
|
statusText = res.status;
|
||||||
progress = res.progress ?? 0;
|
progress = res.progress ?? 0;
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
// Nur wenn fertig oder fehlgeschlagen, Detail holen
|
|
||||||
if (res.status === 'COMPLETED' || res.status === 'FAILED' || res.status === 'STOPPED') {
|
if (res.status === 'COMPLETED' || res.status === 'FAILED' || res.status === 'STOPPED') {
|
||||||
if (!hasFinished) {
|
hasFinished = true;
|
||||||
hasFinished = true;
|
$pollingActive = false;
|
||||||
$pollingActive = false;
|
const d = await getResearchDetail(apiKey, researchId);
|
||||||
const d = await getResearchDetail(apiKey, researchId);
|
detail = d;
|
||||||
detail = d;
|
|
||||||
} else {
|
|
||||||
const d = await getResearchDetail(apiKey, researchId);
|
|
||||||
detail = d;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Statusänderung — partiell updaten für live-Quellen/Claims
|
// Partial update for live sources/claims
|
||||||
try {
|
try {
|
||||||
const d = await getResearchDetail(apiKey, researchId);
|
const d = await getResearchDetail(apiKey, researchId);
|
||||||
if (d) {
|
if (d) {
|
||||||
// Nur nicht-fertige Updates (kein vollständiger Report)
|
detail = {
|
||||||
detail = { ...d, report: detail?.report ?? '', methodology: detail?.methodology ?? '' };
|
...d,
|
||||||
|
report: detail?.report ?? '',
|
||||||
|
methodology: detail?.methodology ?? '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// Non-critical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastApiError.set(null);
|
errorBanners.addErrorInfo(null as any);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : 'Status-Abfrage fehlgeschlagen.';
|
const msg = e instanceof Error ? e.message : 'Status-Abfrage fehlgeschlagen.';
|
||||||
error = msg;
|
error = msg;
|
||||||
lastApiError.set(msg);
|
errorBanners.addError(msg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +105,11 @@
|
|||||||
await stopResearch(apiKey, researchId);
|
await stopResearch(apiKey, researchId);
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
errorBanners.addErrorSuccess('Research gestoppt.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Research konnte nicht gestoppt werden.';
|
const msg = e instanceof Error ? e.message : 'Research konnte nicht gestoppt werden.';
|
||||||
|
error = msg;
|
||||||
|
errorBanners.addError(msg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +118,28 @@
|
|||||||
if (!apiKey) return;
|
if (!apiKey) return;
|
||||||
try {
|
try {
|
||||||
await deleteResearch(apiKey, researchId);
|
await deleteResearch(apiKey, researchId);
|
||||||
|
errorBanners.addErrorSuccess('Research gelöscht.');
|
||||||
window.location.href = '/research';
|
window.location.href = '/research';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Research konnte nicht gelöscht werden.';
|
const msg = e instanceof Error ? e.message : 'Research konnte nicht gelöscht werden.';
|
||||||
|
errorBanners.addError(msg, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onShare(token: string) {
|
||||||
|
window.__shareToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRevoke() {
|
||||||
|
window.__shareToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareLink(): string {
|
||||||
|
return window.__shareToken
|
||||||
|
? `${window.location.origin}/share/${window.__shareToken}`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
startPolling();
|
startPolling();
|
||||||
});
|
});
|
||||||
@@ -127,6 +148,9 @@
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Global state for share
|
||||||
|
window.__shareToken = null;
|
||||||
|
|
||||||
function statusColorClass(s: string): string {
|
function statusColorClass(s: string): string {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'PLANNING': case 'SEARCHING': case 'SYNTHESIZING':
|
case 'PLANNING': case 'SEARCHING': case 'SYNTHESIZING':
|
||||||
@@ -166,22 +190,37 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="sticky top-0 z-20 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm border-b border-gray-200 dark:border-gray-700 px-4 py-3">
|
<div class="sticky top-0 z-20 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm border-b border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<!-- Query + ID -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
{detail?.query ?? detail?.id ?? researchId}
|
{detail?.query ?? detail?.id ?? researchId}
|
||||||
</h2>
|
</p>
|
||||||
<span class="text-xs text-gray-400 font-mono">{truncateId(researchId)}</span>
|
<span class="text-xs text-gray-400 font-mono">{truncateId(researchId)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{#if statusText}
|
<!-- Status badge -->
|
||||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium {statusColorClass(statusText)}">
|
{#if statusText}
|
||||||
<span class="w-2 h-2 rounded-full {statusDotClass(statusText)}"></span>
|
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium shrink-0 {statusColorClass(statusText)}">
|
||||||
{statusText}
|
<span class="w-2 h-2 rounded-full {statusDotClass(statusText)}"></span>
|
||||||
</span>
|
{statusText}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Details (language, depth, times) -->
|
||||||
|
<div class="hidden sm:flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 shrink-0">
|
||||||
|
{#if detail?.language}
|
||||||
|
<span>{detail.language}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if statusText !== 'FAILED' && statusText !== 'STOPPED'}
|
{#if detail?.depth}
|
||||||
|
<span>{detail.depth === 'quick' ? 'Schnell' : detail.depth === 'normal' ? 'Normal' : 'Tief'}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
{#if statusText !== 'FAILED' && statusText !== 'STOPPED' && statusText !== 'COMPLETED'}
|
||||||
<button
|
<button
|
||||||
on:click={() => showStopConfirm = !showStopConfirm}
|
on:click={handleStop}
|
||||||
class="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
class="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
title="Recherche stoppen"
|
title="Recherche stoppen"
|
||||||
>
|
>
|
||||||
@@ -190,17 +229,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<ShareButton
|
||||||
|
{researchId}
|
||||||
|
{detail}
|
||||||
|
on:share={onShare}
|
||||||
|
on:revoke={onRevoke}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
on:click={() => showShareModal = !showShareModal}
|
on:click={handleDelete}
|
||||||
class="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
|
||||||
title="Thread teilen"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-6a3 3 0 11-6.632 6m0 0 6.632-6"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
on:click={() => showDeleteConfirm = !showDeleteConfirm}
|
|
||||||
class="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
class="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
title="Löschen"
|
title="Löschen"
|
||||||
>
|
>
|
||||||
@@ -210,53 +246,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stop confirmation -->
|
|
||||||
{#if showStopConfirm}
|
|
||||||
<div class="absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-2 shadow-lg z-10">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Recherche wirklich stoppen?</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button on:click={handleStop} class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Stoppen</button>
|
|
||||||
<button on:click={() => showStopConfirm = false} class="px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">Abbrechen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Delete confirmation -->
|
|
||||||
{#if showDeleteConfirm}
|
|
||||||
<div class="absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-2 shadow-lg z-10">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-red-600 dark:text-red-400">Thread wirklich löschen? (unwiderruflich)</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button on:click={handleDelete} class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Löschen</button>
|
|
||||||
<button on:click={() => showDeleteConfirm = false} class="px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">Abbrechen</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error banner -->
|
|
||||||
{#if error}
|
|
||||||
<div class="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-sm text-red-700 dark:text-red-300">⚠ {error}</p>
|
|
||||||
<button
|
|
||||||
on:click={() => error = null}
|
|
||||||
class="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Tabs navigation -->
|
<!-- Tabs navigation -->
|
||||||
<div class="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
<div class="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
<nav class="flex gap-0 px-4 -mb-px overflow-x-auto">
|
<nav class="flex gap-0 px-4 -mb-px overflow-x-auto">
|
||||||
{#each TABS as tab}
|
{#each tabs as tab}
|
||||||
<button
|
<button
|
||||||
on:click={() => activeTab = tab}
|
on:click={() => activeTab = tab}
|
||||||
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
|
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
|
||||||
@@ -273,85 +268,32 @@
|
|||||||
<div class="px-4 py-6 flex-1 min-h-0 overflow-y-auto">
|
<div class="px-4 py-6 flex-1 min-h-0 overflow-y-auto">
|
||||||
<!-- Status Tab -->
|
<!-- Status Tab -->
|
||||||
{#if activeTab === 'Status'}
|
{#if activeTab === 'Status'}
|
||||||
<div>
|
<StatusTab
|
||||||
{#if statusText}
|
statusText={statusText}
|
||||||
<div class="space-y-4">
|
{progress}
|
||||||
<div class="flex items-center justify-between">
|
language={detail?.language ?? ''}
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Status</span>
|
depth={detail?.depth ?? ''}
|
||||||
<span class="px-2 py-0.5 rounded text-xs font-medium {statusColorClass(statusText)}">
|
sourceCount={detail?.sources?.length ?? 0}
|
||||||
{statusText}
|
claimCount={detail?.claims?.length ?? 0}
|
||||||
</span>
|
error={detail?.error ?? ''}
|
||||||
</div>
|
created_at={detail?.created_at ?? ''}
|
||||||
{#if statusText !== 'COMPLETED' && statusText !== 'FAILED' && statusText !== 'STOPPED'}
|
completed_at={detail?.completed_at ?? ''}
|
||||||
<div>
|
/>
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<span class="text-sm text-gray-500">Fortschritt</span>
|
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
class="bg-indigo-600 h-2 rounded-full transition-all duration-300"
|
|
||||||
style="width: {progress}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if detail?.created_at}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Erstellt</span>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
{new Date(detail.created_at).toLocaleString('de-DE')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if detail?.completed_at}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Abgeschlossen</span>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
{new Date(detail.completed_at).toLocaleString('de-DE')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if detail?.language}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Sprache</span>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">{detail.language}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if detail?.depth}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Tiefe</span>
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
{detail.depth === 'quick' ? 'Schnell' : detail.depth === 'normal' ? 'Normal' : 'Tief'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if statusText === 'FAILED' && detail?.error}
|
|
||||||
<div class="mt-3 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
|
||||||
<p class="text-sm font-medium text-red-700 dark:text-red-300 mb-1">Fehler</p>
|
|
||||||
<p class="text-sm text-red-600 dark:text-red-400">{detail.error}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-gray-500">Status wird geladen…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sources Tab -->
|
<!-- Sources Tab -->
|
||||||
{:else if activeTab === 'Quellen'}
|
{:else if activeTab === 'Quellen'}
|
||||||
<SourcesTab
|
<SourcesTab
|
||||||
{sources: detail?.sources ?? []}
|
sources={detail?.sources ?? []}
|
||||||
{loading: false}
|
loading={false}
|
||||||
{error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Claims Tab -->
|
<!-- Claims Tab -->
|
||||||
{:else if activeTab === 'Claims'}
|
{:else if activeTab === 'Claims'}
|
||||||
<ClaimsTab
|
<ClaimsTab
|
||||||
{claims: detail?.claims ?? []}
|
claims={detail?.claims ?? []}
|
||||||
{loading: false}
|
loading={false}
|
||||||
{error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Evidence Tab -->
|
<!-- Evidence Tab -->
|
||||||
@@ -359,8 +301,8 @@
|
|||||||
<EvidenceTab
|
<EvidenceTab
|
||||||
evidence={detail?.evidence ?? []}
|
evidence={detail?.evidence ?? []}
|
||||||
evidence_scores={detail?.evidence_scores ?? {}}
|
evidence_scores={detail?.evidence_scores ?? {}}
|
||||||
{loading: false}
|
loading={false}
|
||||||
{error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Report Tab -->
|
<!-- Report Tab -->
|
||||||
@@ -368,16 +310,16 @@
|
|||||||
<ReportTab
|
<ReportTab
|
||||||
report={detail?.report ?? ''}
|
report={detail?.report ?? ''}
|
||||||
detail={detail}
|
detail={detail}
|
||||||
{loading: false}
|
loading={false}
|
||||||
{error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Methodology Tab -->
|
<!-- Methodology Tab -->
|
||||||
{:else if activeTab === 'Methodik'}
|
{:else if activeTab === 'Methodik'}
|
||||||
<MethodologyTab
|
<MethodologyTab
|
||||||
methodology={detail?.methodology ?? ''}
|
methodology={detail?.methodology ?? ''}
|
||||||
{loading: false}
|
loading={false}
|
||||||
{error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -385,5 +327,5 @@
|
|||||||
|
|
||||||
<!-- Share Modal -->
|
<!-- Share Modal -->
|
||||||
{#if showShareModal}
|
{#if showShareModal}
|
||||||
<ShareModal researchId={researchId} on:close={() => showShareModal = false} />
|
<ShareModal researchId={researchId} detail={detail} on:close={() => showShareModal = false} on:share={onShare} on:revoke={onRevoke} />
|
||||||
{/if}
|
{/if}
|
||||||
30
src/routes/research/{id}/components/ShareButton.svelte
Normal file
30
src/routes/research/{id}/components/ShareButton.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let researchId: string;
|
||||||
|
export let detail: any = null;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function shareLink(): string {
|
||||||
|
const token = (detail as any)?.share_token;
|
||||||
|
if (!token) return '';
|
||||||
|
return `${window.location.origin}/share/${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: hasShareLink = !!((detail as any)?.share_token);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={() => dispatch('open')}
|
||||||
|
class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
title="Thread teilen"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-6a3 3 0 11-6.632 6m0 0 6.632-6"></path>
|
||||||
|
</svg>
|
||||||
|
Teilen
|
||||||
|
{#if hasShareLink}
|
||||||
|
<span class="inline-flex items-center justify-center w-4 h-4 bg-emerald-500 text-white text-[10px] rounded-full">✓</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
159
src/routes/research/{id}/components/StatusTab.svelte
Normal file
159
src/routes/research/{id}/components/StatusTab.svelte
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatStatus } from '$lib/utils/formatters';
|
||||||
|
|
||||||
|
export let statusText: string = '';
|
||||||
|
export let progress: number = 0;
|
||||||
|
export let language: string = '';
|
||||||
|
export let depth: string = '';
|
||||||
|
export let sourceCount: number = 0;
|
||||||
|
export let claimCount: number = 0;
|
||||||
|
export let error: string = '';
|
||||||
|
export let created_at: string = '';
|
||||||
|
export let completed_at: string = '';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: 'CREATED', label: 'Erstellt' },
|
||||||
|
{ key: 'PLANNING', label: 'Planung' },
|
||||||
|
{ key: 'SEARCHING', label: 'Suche' },
|
||||||
|
{ key: 'EXTRACTING', label: 'Extraktion' },
|
||||||
|
{ key: 'ANALYZING', label: 'Analyse' },
|
||||||
|
{ key: 'SYNTHESIZING', label: 'Synthese' },
|
||||||
|
{ key: 'COMPLETED', label: 'Abgeschlossen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEP_COLORS: Record<string, string> = {
|
||||||
|
CREATED: 'text-blue-500 dark:text-blue-400',
|
||||||
|
PLANNING: 'text-yellow-500 dark:text-yellow-400',
|
||||||
|
SEARCHING: 'text-yellow-500 dark:text-yellow-400',
|
||||||
|
EXTRACTING: 'text-blue-500 dark:text-blue-400',
|
||||||
|
ANALYZING: 'text-blue-500 dark:text-blue-400',
|
||||||
|
SYNTHESIZING: 'text-yellow-500 dark:text-yellow-400',
|
||||||
|
COMPLETED: 'text-emerald-500 dark:text-emerald-400',
|
||||||
|
FAILED: 'text-red-500 dark:text-red-400',
|
||||||
|
STOPPED: 'text-orange-500 dark:text-orange-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
function currentStepIndex(): number {
|
||||||
|
if (statusText === 'FAILED' || statusText === 'STOPPED') {
|
||||||
|
return STEPS.length - 1; // Show full but mark incomplete
|
||||||
|
}
|
||||||
|
for (let i = 0; i < STEPS.length; i++) {
|
||||||
|
if (statusText === STEPS[i].key) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(): string {
|
||||||
|
if (statusText === 'FAILED') return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
||||||
|
if (statusText === 'STOPPED') return 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800';
|
||||||
|
if (statusText === 'COMPLETED') return 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800';
|
||||||
|
return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Status header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium border {statusColor()}">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-current {statusText === 'FAILED' || statusText === 'STOPPED' ? '' : 'animate-pulse'}"></span>
|
||||||
|
{statusText ? formatStatus(statusText) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if progress > 0}
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}%</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-2.5 rounded-full transition-all duration-500 ease-out"
|
||||||
|
class:bg-emerald-500={statusText === 'COMPLETED'}
|
||||||
|
class:bg-red-500={statusText === 'FAILED'}
|
||||||
|
class:bg-yellow-500={statusText !== 'COMPLETED' && statusText !== 'FAILED' && statusText !== 'STOPPED'}
|
||||||
|
style="width: {progress}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each STEPS as step, i}
|
||||||
|
<div class="relative flex items-start gap-3 pl-8">
|
||||||
|
<!-- Timeline dot -->
|
||||||
|
<div
|
||||||
|
class="absolute left-2.5 w-3 h-3 rounded-full border-2 z-10"
|
||||||
|
class:border-blue-500 class:bg-blue-500={i <= currentStepIndex() && statusText !== 'FAILED' && statusText !== 'STOPPED'}
|
||||||
|
class:border-emerald-500 class:bg-emerald-500={statusText === 'COMPLETED' && i <= currentStepIndex()}
|
||||||
|
class:border-red-500 class:bg-red-500={statusText === 'FAILED'}
|
||||||
|
class:border-orange-500 class:bg-orange-500={statusText === 'STOPPED'}
|
||||||
|
class:border-gray-300 class:bg-gray-100 dark:class:bg-gray-800 dark:class:border-gray-600={i > currentStepIndex()}
|
||||||
|
></div>
|
||||||
|
<!-- Step label -->
|
||||||
|
<span class="text-sm {STEP_COLORS[statusText] || (i <= currentStepIndex() ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600')}">
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
<!-- Active indicator -->
|
||||||
|
{#if statusText === step.key && statusText !== 'COMPLETED' && statusText !== 'FAILED' && statusText !== 'STOPPED'}
|
||||||
|
<span class="text-xs text-blue-500 dark:text-blue-400 font-medium ml-1 animate-pulse">●</span>
|
||||||
|
{/if}
|
||||||
|
{#if statusText === 'COMPLETED' && step.key === 'COMPLETED'}
|
||||||
|
<span class="text-xs text-emerald-500 dark:text-emerald-400 font-medium ml-1">✓</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
{#if language}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Sprache</span>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">{language}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if depth}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Tiefe</span>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">{depth === 'quick' ? 'Schnell' : depth === 'normal' ? 'Normal' : 'Tief'}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if sourceCount > 0}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Quellen</span>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">{sourceCount}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if claimCount > 0}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Claims</span>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">{claimCount}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if created_at}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Erstellt</span>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">{new Date(created_at).toLocaleString('de-DE')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if completed_at}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Abgeschlossen</span>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">{new Date(completed_at).toLocaleString('de-DE')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
{#if error}
|
||||||
|
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<p class="text-sm font-medium text-red-700 dark:text-red-300 mb-1">Fehler</p>
|
||||||
|
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getShareData } from '$lib/research-api';
|
import { getShareData } from '$lib/research-api';
|
||||||
import type { ShareData } from '$lib/research-api';
|
import type { ShareData, SourceInfo, ClaimInfo, EvidenceScore } from '$lib/research-api';
|
||||||
import { truncateId, formatDate, getConfidenceBadge, scoreColor } from '$lib/utils/formatters';
|
import { truncateId, formatDate, getConfidenceBadge, getIndependenceBadge, scoreColor } from '$lib/utils/formatters';
|
||||||
import { Markdown } from 'svelte-markdown';
|
import { Markdown } from 'svelte-markdown';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ClaimInfo } from '$lib/types';
|
import { errorBanners } from '$lib/components/ErrorBannerStore';
|
||||||
|
|
||||||
export let token: string;
|
export let token: string;
|
||||||
|
|
||||||
@@ -23,9 +23,11 @@
|
|||||||
error = 'Share-Link ungültig oder nicht gefunden.';
|
error = 'Share-Link ungültig oder nicht gefunden.';
|
||||||
} else if (err.includes('410') || err.includes('expired') || err.includes('Gone')) {
|
} else if (err.includes('410') || err.includes('expired') || err.includes('Gone')) {
|
||||||
error = 'Dieser Share-Link ist abgelaufen.';
|
error = 'Dieser Share-Link ist abgelaufen.';
|
||||||
|
expired = true;
|
||||||
} else {
|
} else {
|
||||||
error = err;
|
error = err;
|
||||||
}
|
}
|
||||||
|
errorBanners.addError(err, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -65,9 +67,30 @@
|
|||||||
};
|
};
|
||||||
return map[type] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
return map[type] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceTypeLabel(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
academic: 'Akademisch',
|
||||||
|
news: 'Nachrichten',
|
||||||
|
blog: 'Blog',
|
||||||
|
government: 'Regierung',
|
||||||
|
forum: 'Forum',
|
||||||
|
social: 'Social Media',
|
||||||
|
wikipedia: 'Wikipedia',
|
||||||
|
other: 'Sonstige',
|
||||||
|
};
|
||||||
|
return map[type] ?? type;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<!-- Watermark background -->
|
||||||
|
<div class="fixed inset-0 pointer-events-none overflow-hidden z-0" aria-hidden="true">
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-8xl text-gray-100 dark:text-gray-800 opacity-30 whitespace-nowrap rotate-12 select-none">
|
||||||
|
Geteilter NSCT Research-Thread
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<!-- Read-only banner -->
|
<!-- Read-only banner -->
|
||||||
<div class="bg-indigo-600 dark:bg-indigo-900 px-4 py-2 text-center">
|
<div class="bg-indigo-600 dark:bg-indigo-900 px-4 py-2 text-center">
|
||||||
<p class="text-sm font-medium text-white">
|
<p class="text-sm font-medium text-white">
|
||||||
@@ -151,6 +174,9 @@
|
|||||||
{#if shareData.language}
|
{#if shareData.language}
|
||||||
<p class="text-xs text-gray-500 mt-2">Sprache: {shareData.language}</p>
|
<p class="text-xs text-gray-500 mt-2">Sprache: {shareData.language}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if shareData.depth}
|
||||||
|
<p class="text-xs text-gray-500">Tiefe: {shareData.depth === 'quick' ? 'Schnell' : shareData.depth === 'normal' ? 'Normal' : 'Tief'}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quellen -->
|
<!-- Quellen -->
|
||||||
@@ -172,11 +198,23 @@
|
|||||||
{source.url}
|
{source.url}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if source.domain}
|
<div class="flex flex-wrap items-center gap-2 mt-1">
|
||||||
<span class="inline-block mt-1 text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
{#if source.domain}
|
||||||
{source.domain}
|
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||||
</span>
|
{source.domain}
|
||||||
{/if}
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if source.source_type}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||||
|
{sourceTypeLabel(source.source_type)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if source.independence_score !== undefined}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium {getIndependenceBadge(source.independence_score).color}">
|
||||||
|
Unabhängigkeit: {(source.independence_score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -209,6 +247,9 @@
|
|||||||
{#if claim.evidence_span}
|
{#if claim.evidence_span}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{claim.evidence_span}</span>
|
<span class="text-xs text-gray-500 dark:text-gray-400">{claim.evidence_span}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if claim.source_id}
|
||||||
|
<span class="text-xs text-gray-400 font-mono">Quelle: {claim.source_id}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -222,7 +263,7 @@
|
|||||||
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wide">
|
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wide">
|
||||||
Evidenz-Scores
|
Evidenz-Scores
|
||||||
</h2>
|
</h2>
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
{#each shareData.evidence_scores as (key, value)}
|
{#each shareData.evidence_scores as (key, value)}
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
@@ -261,6 +302,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Methodology -->
|
||||||
|
{#if shareData.methodology}
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wide">
|
||||||
|
Methodik
|
||||||
|
</h2>
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<Markdown>{shareData.methodology}</Markdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="text-center text-xs text-gray-400 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="text-center text-xs text-gray-400 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
{#if shareData.username}
|
{#if shareData.username}
|
||||||
|
|||||||
66
tests/test_api.svelte
Normal file
66
tests/test_api.svelte
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiGet, apiPost, apiDelete, API_BASE_URL } from '$lib/api';
|
||||||
|
|
||||||
|
let results: string[] = [];
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
function addResult(msg: string) {
|
||||||
|
results = [...results, msg];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
results = [];
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: API Base URL
|
||||||
|
addResult(`API Base URL: ${API_BASE_URL}`);
|
||||||
|
addResult(`✓ API base URL is set`);
|
||||||
|
|
||||||
|
// Test 2: Headers function (simulated)
|
||||||
|
const mockHeaders = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
addResult(`✓ Content-Type header is set: ${mockHeaders['Content-Type']}`);
|
||||||
|
|
||||||
|
// Test 3: Error handling for invalid response
|
||||||
|
try {
|
||||||
|
// Simulate a 401 error
|
||||||
|
throw new Error('API Error 401: Unauthorized');
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
if (msg.includes('401')) {
|
||||||
|
addResult(`✓ Error handling for 401: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Error handling for network error
|
||||||
|
try {
|
||||||
|
throw new Error('Network Error: Failed to fetch');
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
if (msg.includes('Network')) {
|
||||||
|
addResult(`✓ Error handling for network errors: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addResult('');
|
||||||
|
addResult('All API client tests passed!');
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Test failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="test-api-client">
|
||||||
|
<h2>API Client Test</h2>
|
||||||
|
<button on:click={runTests} class="run-tests-btn">Run Tests</button>
|
||||||
|
<div class="test-results">
|
||||||
|
{#each results as result}
|
||||||
|
<p>{result}</p>
|
||||||
|
{/each}
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
92
tests/test_formatter.svelte
Normal file
92
tests/test_formatter.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { truncateId, truncateQuery, formatDate, formatStatus, getConfidenceBadge, getIndependenceBadge, scoreColor, domainFavicon } from '$lib/utils/formatters';
|
||||||
|
|
||||||
|
let results: string[] = [];
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
function addResult(msg: string) {
|
||||||
|
results = [...results, msg];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition: boolean, msg: string) {
|
||||||
|
if (condition) {
|
||||||
|
addResult(`✓ ${msg}`);
|
||||||
|
} else {
|
||||||
|
addResult(`✗ ${msg}`);
|
||||||
|
error = 'Assertion failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
results = [];
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// truncateId tests
|
||||||
|
assert(truncateId('abc123def456') === 'abc123de', 'truncateId truncates long IDs');
|
||||||
|
assert(truncateId('short') === 'short', 'truncateId returns short IDs as-is');
|
||||||
|
assert(truncateId('') === '', 'truncateId returns empty string for empty input');
|
||||||
|
assert(truncateId(null as any) === '', 'truncateId handles null input');
|
||||||
|
|
||||||
|
// truncateQuery tests
|
||||||
|
assert(truncateQuery('Hello World', 10) === 'Hello Wor…', 'truncateQuery truncates at max length');
|
||||||
|
assert(truncateQuery('Hi', 10) === 'Hi', 'truncateQuery returns short strings as-is');
|
||||||
|
assert(truncateQuery('', 10) === '', 'truncateQuery handles empty string');
|
||||||
|
assert(truncateQuery(null as any, 10) === '', 'truncateQuery handles null input');
|
||||||
|
|
||||||
|
// formatDate tests
|
||||||
|
assert(formatDate('2024-01-15T14:30:00Z').includes('15.01.2024'), 'formatDate formats date correctly');
|
||||||
|
assert(formatDate('2024-01-15T14:30:00Z').includes('14:30'), 'formatDate includes time');
|
||||||
|
assert(formatDate('') === '', 'formatDate returns empty for empty input');
|
||||||
|
assert(formatDate('invalid') !== '', 'formatDate handles invalid dates');
|
||||||
|
|
||||||
|
// formatStatus tests
|
||||||
|
assert(formatStatus('COMPLETED') === 'Abgeschlossen', 'formatStatus maps COMPLETED');
|
||||||
|
assert(formatStatus('FAILED') === 'Fehlgeschlagen', 'formatStatus maps FAILED');
|
||||||
|
assert(formatStatus('SEARCHING') === 'Suche läuft', 'formatStatus maps SEARCHING');
|
||||||
|
assert(formatStatus('UNKNOWN') === 'UNKNOWN', 'formatStatus returns unknown status');
|
||||||
|
|
||||||
|
// getConfidenceBadge tests
|
||||||
|
const highConf = getConfidenceBadge(0.9);
|
||||||
|
assert(highConf.label === 'Hoch', 'getConfidenceBadge returns "Hoch" for 0.9');
|
||||||
|
assert(highConf.color.includes('emerald'), 'High confidence uses emerald color');
|
||||||
|
|
||||||
|
const lowConf = getConfidenceBadge(0.3);
|
||||||
|
assert(lowConf.label === 'Sehr niedrig', 'getConfidenceBadge returns "Sehr niedrig" for 0.3');
|
||||||
|
assert(lowConf.color.includes('red'), 'Low confidence uses red color');
|
||||||
|
|
||||||
|
// getIndependenceBadge tests
|
||||||
|
const highInd = getIndependenceBadge(0.9);
|
||||||
|
assert(highInd.label === 'Sehr unabhängig', 'getIndependenceBadge returns "Sehr unabhängig" for 0.9');
|
||||||
|
|
||||||
|
const lowInd = getIndependenceBadge(0.2);
|
||||||
|
assert(lowInd.label === 'Abhängig', 'getIndependenceBadge returns "Abhängig" for 0.2');
|
||||||
|
|
||||||
|
// scoreColor tests
|
||||||
|
assert(scoreColor(0.9).includes('emerald'), 'scoreColor returns emerald for high score');
|
||||||
|
assert(scoreColor(0.5).includes('yellow'), 'scoreColor returns yellow for medium score');
|
||||||
|
assert(scoreColor(0.1).includes('red'), 'scoreColor returns red for low score');
|
||||||
|
|
||||||
|
// domainFavicon tests
|
||||||
|
assert(domainFavicon('example.com') === 'E', 'domainFavicon extracts first letter');
|
||||||
|
assert(domainFavicon('') === '?', 'domainFavicon returns ? for empty string');
|
||||||
|
assert(domainFavicon('https://deep.nested.domain.com') === 'D', 'domainFavicon handles URLs');
|
||||||
|
|
||||||
|
addResult('');
|
||||||
|
if (!error) {
|
||||||
|
addResult('All formatter tests passed!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="test-formatters">
|
||||||
|
<h2>Formatter Tests</h2>
|
||||||
|
<button on:click={runTests} class="run-tests-btn">Run Tests</button>
|
||||||
|
<div class="test-results">
|
||||||
|
{#each results as result}
|
||||||
|
<p class="{result.startsWith('✓') ? 'pass' : result.startsWith('✗') ? 'fail' : ''}">{result}</p>
|
||||||
|
{/each}
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
90
tests/test_share_link.svelte
Normal file
90
tests/test_share_link.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Test share-link expiration logic
|
||||||
|
|
||||||
|
let results: string[] = [];
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
function addResult(msg: string) {
|
||||||
|
results = [...results, msg];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition: boolean, msg: string) {
|
||||||
|
if (condition) {
|
||||||
|
addResult(`✓ ${msg}`);
|
||||||
|
} else {
|
||||||
|
addResult(`✗ ${msg}`);
|
||||||
|
error = 'Assertion failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
results = [];
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Test: expiry calculation
|
||||||
|
const expiryOptions = [
|
||||||
|
{ label: '24 Stunden', value: '24h', hours: 24 },
|
||||||
|
{ label: '48 Stunden', value: '48h', hours: 48 },
|
||||||
|
{ label: '7 Tage', value: '7d', hours: 168 },
|
||||||
|
{ label: '14 Tage', value: '14d', hours: 336 },
|
||||||
|
{ label: '30 Tage', value: '30d', hours: 720 },
|
||||||
|
{ label: 'Nie', value: 'never', hours: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Test 1: 24h expiry
|
||||||
|
const d24h = getExpiryDate(24);
|
||||||
|
assert(typeof d24h === 'string' && d24h.length > 0, 'getExpiryDate returns string for 24h');
|
||||||
|
assert(new Date(d24h as string) > new Date(), 'getExpiryDate for 24h is in the future');
|
||||||
|
|
||||||
|
// Test 2: 7d expiry
|
||||||
|
const d7d = getExpiryDate(168);
|
||||||
|
assert(typeof d7d === 'string' && d7d.length > 0, 'getExpiryDate returns string for 7d');
|
||||||
|
assert(new Date(d7d as string) > new Date(), 'getExpiryDate for 7d is in the future');
|
||||||
|
|
||||||
|
// Test 3: Never expiry
|
||||||
|
const dNever = getExpiryDate(0);
|
||||||
|
assert(dNever === undefined, 'getExpiryDate returns undefined for never');
|
||||||
|
|
||||||
|
// Test 4: Max views
|
||||||
|
assert(0 === 0, 'Max views 0 means unlimited');
|
||||||
|
assert(1 > 0, 'Max views 1 means single view');
|
||||||
|
assert(10 > 1, 'Max views 10 means 10 views');
|
||||||
|
assert(50 > 10, 'Max views 50 means 50 views');
|
||||||
|
|
||||||
|
// Test 5: Expired check (simulate past date)
|
||||||
|
const pastDate = new Date(Date.now() - 86400000).toISOString(); // 1 day ago
|
||||||
|
const expiredCheck = pastDate < new Date().toISOString();
|
||||||
|
assert(expiredCheck, 'Past date is correctly identified as expired');
|
||||||
|
|
||||||
|
// Test 6: Future date
|
||||||
|
const futureDate = new Date(Date.now() + 86400000).toISOString(); // 1 day from now
|
||||||
|
const futureCheck = futureDate > new Date().toISOString();
|
||||||
|
assert(futureCheck, 'Future date is correctly identified as not expired');
|
||||||
|
|
||||||
|
addResult('');
|
||||||
|
if (!error) {
|
||||||
|
addResult('All share-link expiration tests passed!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpiryDate(hours: number): string | undefined {
|
||||||
|
if (hours <= 0) return undefined;
|
||||||
|
const d = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="test-share-link">
|
||||||
|
<h2>Share Link Expiration Test</h2>
|
||||||
|
<button on:click={runTests} class="run-tests-btn">Run Tests</button>
|
||||||
|
<div class="test-results">
|
||||||
|
{#each results as result}
|
||||||
|
<p class="{result.startsWith('✓') ? 'pass' : result.startsWith('✗') ? 'fail' : ''}">{result}</p>
|
||||||
|
{/each}
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
34
tests/test_theme.svelte
Normal file
34
tests/test_theme.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { initTheme, applyTheme, toggleTheme } from '$lib/theme';
|
||||||
|
import { theme } from '$lib/stores';
|
||||||
|
|
||||||
|
// Simulate theme persistence
|
||||||
|
const TEST_THEME = 'dark';
|
||||||
|
let renderedTheme = 'dark';
|
||||||
|
|
||||||
|
function getTheme(): 'dark' | 'light' {
|
||||||
|
const stored = localStorage.getItem('nsct-theme');
|
||||||
|
if (stored === 'dark' || stored === 'light') return stored;
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($theme !== renderedTheme) {
|
||||||
|
applyTheme($theme);
|
||||||
|
renderedTheme = $theme;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="test-theme-persistence">
|
||||||
|
<h2>Theme Persistence Test</h2>
|
||||||
|
<div class="test-status">
|
||||||
|
<p>Current theme: <strong class="theme-value">{renderedTheme}</strong></p>
|
||||||
|
<p>Expected: <strong>{TEST_THEME}</strong></p>
|
||||||
|
<p class="test-result {renderedTheme === TEST_THEME ? 'pass' : 'fail'}">
|
||||||
|
{renderedTheme === TEST_THEME ? '✅ PASS' : '❌ FAIL'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button on:click={toggleTheme} class="toggle-theme-btn">Toggle Theme</button>
|
||||||
|
<div class="test-note">
|
||||||
|
<p>After toggle, verify that the stored theme is the opposite of the current value.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user