Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser
- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3) - Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor - Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS - Configurare adresă platformă externalizată (build args / .env / config.js) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
cec967f953
321 changed files with 80506 additions and 0 deletions
50
web/.dockerignore
Normal file
50
web/.dockerignore
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
|
||||
# Build output
|
||||
dist
|
||||
build
|
||||
|
||||
# Environment (injected via build args)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker (don't copy Dockerfiles into image)
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
e2e
|
||||
tests
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
vitest.config.ts
|
||||
playwright.config.ts
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
18
web/.env.example
Normal file
18
web/.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# DIDI Web Application Environment Variables
|
||||
# Copy this file to .env.local and fill in the values
|
||||
|
||||
# Backend API URL via Kong Gateway (required - no fallback)
|
||||
# All traffic including Keycloak auth is routed through this gateway
|
||||
# Staging: http://10.11.10.15:21000 or http://localhost:21000
|
||||
VITE_API_URL=http://10.11.10.15:21000
|
||||
|
||||
# Note: Keycloak auth is routed through Kong at /auth/*
|
||||
# No separate VITE_KEYCLOAK_URL needed
|
||||
|
||||
# Keycloak Realm (configured in keycloak.service.ts)
|
||||
# Default: didi-clients
|
||||
# VITE_KEYCLOAK_REALM=didi-clients
|
||||
|
||||
# Keycloak Client ID (configured in keycloak.service.ts)
|
||||
# Default: didi-web-app
|
||||
# VITE_KEYCLOAK_CLIENT_ID=didi-web-app
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
58
web/Dockerfile
Normal file
58
web/Dockerfile
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Multi-stage build for optimized production image
|
||||
# Stage 1: Build the application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
# `npm install` instead of `npm ci` to skip strict platform-pinned optional deps
|
||||
# (lock file references AIX/ppc64 esbuild binary that doesn't apply on linux/x64).
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Accept env vars as build args
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_KEYCLOAK_URL
|
||||
|
||||
# Create .env file from build args (if provided)
|
||||
# All traffic (API + Keycloak auth) is routed through VITE_API_URL;
|
||||
# VITE_KEYCLOAK_URL overrides the Keycloak base (same-origin /auth proxy)
|
||||
RUN touch .env; \
|
||||
if [ -n "$VITE_API_URL" ]; then \
|
||||
echo "VITE_API_URL=${VITE_API_URL}" >> .env; \
|
||||
fi; \
|
||||
if [ -n "$VITE_KEYCLOAK_URL" ]; then \
|
||||
echo "VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL}" >> .env; \
|
||||
fi
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production image with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy SSL cert (self-signed for local HTTPS, required by Keycloak PKCE)
|
||||
COPY ssl/server.crt /etc/nginx/ssl/server.crt
|
||||
COPY ssl/server.key /etc/nginx/ssl/server.key
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 80 443
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-check-certificate --quiet --tries=1 --spider https://localhost/health || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
23
web/eslint.config.js
Normal file
23
web/eslint.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
30
web/index.html
Normal file
30
web/index.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!doctype html>
|
||||
<html lang="en" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- Disable browser auto-translate (Chrome/Edge/Safari). Auto-translate
|
||||
rewrites text nodes inside React's tree, which causes "removeChild
|
||||
not a child" crashes when components re-render. -->
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta name="robots" content="notranslate" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>didi</title>
|
||||
|
||||
<!-- Canonical domain redirect: www → non-www (safety net for Kong/DNS misses) -->
|
||||
<script>
|
||||
if (location.hostname.startsWith('www.')) {
|
||||
location.replace(location.href.replace('://www.', '://'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
168
web/nginx.conf
Normal file
168
web/nginx.conf
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# /health pe HTTP — pentru healthcheck fără TLS
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Docker DNS pentru upstream-uri dinamice
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
|
||||
# ── API + auth pe HTTP simplu (aplicația mobilă pe LAN nu acceptă
|
||||
# certificatul self-signed; SPA-ul web rămâne pe HTTPS) ──
|
||||
location ^~ /api/ {
|
||||
set $kong_upstream didi-kong;
|
||||
proxy_pass http://$kong_upstream:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host localhost;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location ^~ /agent-v3/ {
|
||||
set $kong_upstream didi-kong;
|
||||
proxy_pass http://$kong_upstream:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host localhost;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location ^~ /auth/ {
|
||||
set $kc_upstream didi-keycloak;
|
||||
proxy_pass http://$kc_upstream:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
proxy_set_header X-Forwarded-Port 80;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
http2 on;
|
||||
|
||||
# Self-signed cert (Keycloak PKCE cere Web Crypto = secure context)
|
||||
ssl_certificate /etc/nginx/ssl/server.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/server.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Upload-uri mari (video până la 500MB)
|
||||
client_max_body_size 500M;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/x-javascript application/xml+rss
|
||||
application/javascript application/json;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
# Docker DNS — upstream-urile se rezolvă dinamic (rezistă la restart de containere)
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
|
||||
# ============================================================
|
||||
# Same-origin către stack-ul local (didi-network):
|
||||
# /api/* -> Kong (rutele Kong sunt host-bound; trimitem Host: localhost)
|
||||
# /agent-v3/* -> Kong
|
||||
# /auth/* -> Keycloak (prefixul /auth e nativ în instalare)
|
||||
# ============================================================
|
||||
location ^~ /api/ {
|
||||
set $kong_upstream didi-kong;
|
||||
proxy_pass http://$kong_upstream:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host localhost;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
# Procesarea video poate dura până la 11 min
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location ^~ /agent-v3/ {
|
||||
set $kong_upstream didi-kong;
|
||||
proxy_pass http://$kong_upstream:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host localhost;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 660s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
location ^~ /auth/ {
|
||||
set $kc_upstream didi-keycloak;
|
||||
proxy_pass http://$kc_upstream:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
# Buffere pentru headerele mari Keycloak (JWT)
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Main application — SPA fallback (catch-all LAST)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
13021
web/package-lock.json
generated
Normal file
13021
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
56
web/package.json
Normal file
56
web/package.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "didiweb",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@react-keycloak/web": "^3.4.0",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@react-three/drei": "^9.114.0",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"i18next": "^25.10.5",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"keycloak-js": "^26.2.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^16.6.2",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.4.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.1.6"
|
||||
}
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
web/public/downloads/didi-extension-latest.zip
Normal file
BIN
web/public/downloads/didi-extension-latest.zip
Normal file
Binary file not shown.
BIN
web/public/downloads/didi-extension-v3.2.6.zip
Normal file
BIN
web/public/downloads/didi-extension-v3.2.6.zip
Normal file
Binary file not shown.
BIN
web/public/downloads/didi.apk
Normal file
BIN
web/public/downloads/didi.apk
Normal file
Binary file not shown.
11
web/public/favicon.svg
Normal file
11
web/public/favicon.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#05050f"/>
|
||||
<text
|
||||
x="16"
|
||||
y="24"
|
||||
font-family="'Inter', sans-serif"
|
||||
font-size="20"
|
||||
font-weight="700"
|
||||
text-anchor="middle"
|
||||
fill="#FFFFFF">d</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
BIN
web/public/fonts/Inter-Bold.ttf
Normal file
BIN
web/public/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/Inter-Italic.ttf
Normal file
BIN
web/public/fonts/Inter-Italic.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/Inter-Regular.ttf
Normal file
BIN
web/public/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
BIN
web/public/fonts/Inter-SemiBold.ttf
Normal file
BIN
web/public/fonts/Inter-SemiBold.ttf
Normal file
Binary file not shown.
BIN
web/public/logo.png
Normal file
BIN
web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
7
web/public/silent-check-sso.html
Normal file
7
web/public/silent-check-sso.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<html>
|
||||
<body>
|
||||
<script>
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
126
web/scripts/wcag-audit.sh
Executable file
126
web/scripts/wcag-audit.sh
Executable file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env bash
|
||||
# WCAG 2.1 AA audit script — runs Lighthouse + axe-core against key pages.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/wcag-audit.sh # Default: https://didi365.eu
|
||||
# BASE_URL=http://localhost:5173 ./scripts/wcag-audit.sh
|
||||
#
|
||||
# Required deps (auto-install if missing):
|
||||
# - @axe-core/cli
|
||||
# - lighthouse
|
||||
#
|
||||
# Output:
|
||||
# - reports/wcag/<timestamp>/lighthouse-<page>.html
|
||||
# - reports/wcag/<timestamp>/axe-<page>.json
|
||||
# - reports/wcag/<timestamp>/SUMMARY.md
|
||||
#
|
||||
# Audit produces a formal report for PNRR recepție (cerință E5 caiet sarcini).
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-https://didi365.eu}"
|
||||
TIMESTAMP=$(date -u +"%Y%m%d-%H%M%S")
|
||||
OUT_DIR="reports/wcag/${TIMESTAMP}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
PAGES=(
|
||||
"/"
|
||||
"/dashboard"
|
||||
"/demo"
|
||||
"/email-verified"
|
||||
)
|
||||
|
||||
echo "=== WCAG 2.1 AA audit — $(date -u) ==="
|
||||
echo "Base URL: $BASE_URL"
|
||||
echo "Output: $OUT_DIR"
|
||||
echo ""
|
||||
|
||||
# Auto-install tools if missing
|
||||
if ! command -v lighthouse >/dev/null 2>&1; then
|
||||
echo "Installing lighthouse globally..."
|
||||
npm install -g lighthouse@latest >/dev/null 2>&1
|
||||
fi
|
||||
if ! command -v axe >/dev/null 2>&1; then
|
||||
echo "Installing @axe-core/cli globally..."
|
||||
npm install -g @axe-core/cli@latest >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
SUMMARY="$OUT_DIR/SUMMARY.md"
|
||||
cat > "$SUMMARY" <<EOF
|
||||
# WCAG 2.1 AA Audit — $(date -u +"%Y-%m-%d")
|
||||
|
||||
Base URL: \`$BASE_URL\`
|
||||
Tools: Lighthouse + axe-core (industry-standard a11y testing)
|
||||
|
||||
## Pages audited
|
||||
EOF
|
||||
|
||||
for page in "${PAGES[@]}"; do
|
||||
SAFE_NAME=$(echo "$page" | tr '/' '_' | sed 's/^_//' | sed 's/^$/root/')
|
||||
URL="${BASE_URL}${page}"
|
||||
echo "--- Auditing $URL ---"
|
||||
|
||||
# Lighthouse (full performance + a11y + SEO)
|
||||
lighthouse "$URL" \
|
||||
--only-categories=accessibility,seo \
|
||||
--output=html,json \
|
||||
--output-path="$OUT_DIR/lighthouse-$SAFE_NAME" \
|
||||
--chrome-flags="--headless --no-sandbox" \
|
||||
--quiet 2>&1 || echo "Lighthouse failed for $URL"
|
||||
|
||||
# axe-core (WCAG-specific violations)
|
||||
axe "$URL" \
|
||||
--tags wcag2a,wcag2aa,wcag21a,wcag21aa \
|
||||
--save "$OUT_DIR/axe-$SAFE_NAME.json" \
|
||||
--exit 0 2>&1 || echo "Axe failed for $URL"
|
||||
|
||||
# Extract scores for summary
|
||||
if [ -f "$OUT_DIR/lighthouse-$SAFE_NAME.report.json" ]; then
|
||||
A11Y_SCORE=$(python3 -c "
|
||||
import json
|
||||
d = json.load(open('$OUT_DIR/lighthouse-$SAFE_NAME.report.json'))
|
||||
print(int(d['categories']['accessibility']['score'] * 100))
|
||||
" 2>/dev/null || echo "N/A")
|
||||
SEO_SCORE=$(python3 -c "
|
||||
import json
|
||||
d = json.load(open('$OUT_DIR/lighthouse-$SAFE_NAME.report.json'))
|
||||
print(int(d['categories'].get('seo', {}).get('score', 0) * 100))
|
||||
" 2>/dev/null || echo "N/A")
|
||||
else
|
||||
A11Y_SCORE="N/A"; SEO_SCORE="N/A"
|
||||
fi
|
||||
|
||||
AXE_VIOLATIONS=$(python3 -c "
|
||||
import json
|
||||
try:
|
||||
d = json.load(open('$OUT_DIR/axe-$SAFE_NAME.json'))
|
||||
if isinstance(d, list) and d:
|
||||
d = d[0]
|
||||
print(len(d.get('violations', [])))
|
||||
except: print('N/A')
|
||||
" 2>/dev/null || echo "N/A")
|
||||
|
||||
echo "" >> "$SUMMARY"
|
||||
echo "### \`$page\`" >> "$SUMMARY"
|
||||
echo "- Lighthouse A11y score: **$A11Y_SCORE / 100**" >> "$SUMMARY"
|
||||
echo "- Lighthouse SEO score: **$SEO_SCORE / 100**" >> "$SUMMARY"
|
||||
echo "- Axe-core violations (WCAG 2.1 AA): **$AXE_VIOLATIONS**" >> "$SUMMARY"
|
||||
echo "- Reports: [lighthouse]($(basename "$OUT_DIR")/lighthouse-$SAFE_NAME.report.html), [axe]($(basename "$OUT_DIR")/axe-$SAFE_NAME.json)" >> "$SUMMARY"
|
||||
done
|
||||
|
||||
echo "" >> "$SUMMARY"
|
||||
echo "## WCAG 2.1 AA Compliance threshold" >> "$SUMMARY"
|
||||
echo "" >> "$SUMMARY"
|
||||
echo "- **Target**: Lighthouse A11y ≥ 95, axe-core violations = 0" >> "$SUMMARY"
|
||||
echo "- **Caiet sarcini cere**: WCAG 2.1 AA demonstrabil (contrast 4.5:1, focus visible, keyboard nav, ARIA, screen readers)" >> "$SUMMARY"
|
||||
echo "" >> "$SUMMARY"
|
||||
echo "## Next steps if violations found" >> "$SUMMARY"
|
||||
echo "" >> "$SUMMARY"
|
||||
echo "1. Open \`axe-<page>.json\` — see \`violations[]\` array" >> "$SUMMARY"
|
||||
echo "2. Each violation has \`description\`, \`helpUrl\`, \`nodes[]\` with selector" >> "$SUMMARY"
|
||||
echo "3. Fix in source code" >> "$SUMMARY"
|
||||
echo "4. Re-run script — verify violations = 0" >> "$SUMMARY"
|
||||
|
||||
echo ""
|
||||
echo "=== Audit complete ==="
|
||||
echo "Summary: $SUMMARY"
|
||||
echo "Open lighthouse HTML reports in browser for visual review."
|
||||
42
web/src/App.css
Normal file
42
web/src/App.css
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
62
web/src/App.tsx
Normal file
62
web/src/App.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import DisinformationDemo from './pages/DisinformationDemo';
|
||||
import EmailVerified from './pages/EmailVerified';
|
||||
import ErrorBoundary from './components/ErrorBoundary/ErrorBoundary';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
|
||||
// Configure React Query with auth-aware defaults
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // Data fresh for 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on auth errors - these need user action
|
||||
if (error instanceof Error && 'status' in error) {
|
||||
const status = (error as { status: number }).status;
|
||||
if (status === 401 || status === 403) return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
refetchOnWindowFocus: false, // Refresh stale data when user returns
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/demo" element={<DisinformationDemo />} />
|
||||
<Route path="/email-verified" element={<EmailVerified />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
web/src/assets/react.svg
Normal file
1
web/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
875
web/src/components/AgentAnalysis/AgentAnalysis.tsx
Normal file
875
web/src/components/AgentAnalysis/AgentAnalysis.tsx
Normal file
|
|
@ -0,0 +1,875 @@
|
|||
/**
|
||||
* Agent Analysis Component
|
||||
* Displays disinformation analysis with progressive updates
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { agentService } from '../../services/agent.service';
|
||||
import { localized } from '../../utils/i18n-fields';
|
||||
import type {
|
||||
AnalysisProgress,
|
||||
AnalysisResponse,
|
||||
DetectedTechnique,
|
||||
VerifiedClaim,
|
||||
AssessedSource,
|
||||
FinalVerdict,
|
||||
} from '../../services/agent.service';
|
||||
|
||||
// =============================================================================
|
||||
// Styled Components
|
||||
// =============================================================================
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const InputSection = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const TextArea = styled.textarea`
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
`;
|
||||
|
||||
const AnalyzeButton = styled.button<{ disabled?: boolean }>`
|
||||
padding: 12px 32px;
|
||||
background: ${props => props.disabled ? '#94a3b8' : '#3b82f6'};
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: background 0.2s;
|
||||
margin-top: 12px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
`;
|
||||
|
||||
// Progress Section
|
||||
const ProgressSection = styled.div`
|
||||
background: #f8fafc;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const ProgressHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const ProgressTitle = styled.h3`
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #1e293b;
|
||||
`;
|
||||
|
||||
const ProgressStatus = styled.span<{ status: string }>`
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: ${props => {
|
||||
switch (props.status) {
|
||||
case 'completed': return '#dcfce7';
|
||||
case 'failed': return '#fee2e2';
|
||||
case 'processing': return '#dbeafe';
|
||||
default: return '#f1f5f9';
|
||||
}
|
||||
}};
|
||||
color: ${props => {
|
||||
switch (props.status) {
|
||||
case 'completed': return '#166534';
|
||||
case 'failed': return '#dc2626';
|
||||
case 'processing': return '#1d4ed8';
|
||||
default: return '#64748b';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const StepsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
`;
|
||||
|
||||
const StepBadge = styled.div<{ status: string }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: ${props => {
|
||||
switch (props.status) {
|
||||
case 'completed': return '#dcfce7';
|
||||
case 'running': return '#dbeafe';
|
||||
case 'failed': return '#fee2e2';
|
||||
default: return '#f1f5f9';
|
||||
}
|
||||
}};
|
||||
color: ${props => {
|
||||
switch (props.status) {
|
||||
case 'completed': return '#166534';
|
||||
case 'running': return '#1d4ed8';
|
||||
case 'failed': return '#dc2626';
|
||||
default: return '#94a3b8';
|
||||
}
|
||||
}};
|
||||
animation: ${props => props.status === 'running' ? pulse : 'none'} 1.5s infinite;
|
||||
`;
|
||||
|
||||
const StepIcon = styled.span`
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const StepDuration = styled.span`
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
const ProgressBar = styled.div`
|
||||
height: 8px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ProgressFill = styled.div<{ percent: number; status: string }>`
|
||||
height: 100%;
|
||||
width: ${props => props.percent}%;
|
||||
background: ${props => props.status === 'failed' ? '#ef4444' : '#3b82f6'};
|
||||
transition: width 0.3s ease;
|
||||
`;
|
||||
|
||||
const TimeInfo = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
`;
|
||||
|
||||
// Results Section
|
||||
const ResultsSection = styled.div`
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
const VerdictCard = styled.div<{ color: string }>`
|
||||
background: ${props => {
|
||||
switch (props.color) {
|
||||
case 'red': return 'linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%)';
|
||||
case 'orange': return 'linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%)';
|
||||
case 'yellow': return 'linear-gradient(135deg, #fefce8 0%, #fef9c3 100%)';
|
||||
case 'green': return 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)';
|
||||
default: return 'linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)';
|
||||
}
|
||||
}};
|
||||
border-left: 4px solid ${props => {
|
||||
switch (props.color) {
|
||||
case 'red': return '#ef4444';
|
||||
case 'orange': return '#f97316';
|
||||
case 'yellow': return '#eab308';
|
||||
case 'green': return '#22c55e';
|
||||
default: return '#94a3b8';
|
||||
}
|
||||
}};
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const VerdictHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const VerdictInfo = styled.div``;
|
||||
|
||||
const VerdictLabel = styled.div`
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
const VerdictCode = styled.h2<{ color: string }>`
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: ${props => {
|
||||
switch (props.color) {
|
||||
case 'red': return '#dc2626';
|
||||
case 'orange': return '#ea580c';
|
||||
case 'yellow': return '#ca8a04';
|
||||
case 'green': return '#16a34a';
|
||||
default: return '#475569';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const VerdictName = styled.div`
|
||||
font-size: 15px;
|
||||
color: #475569;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
const ScoreCircle = styled.div<{ score: number }>`
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${props => {
|
||||
if (props.score >= 80) return '#fef2f2';
|
||||
if (props.score >= 60) return '#fff7ed';
|
||||
if (props.score >= 40) return '#fefce8';
|
||||
return '#f0fdf4';
|
||||
}};
|
||||
border: 3px solid ${props => {
|
||||
if (props.score >= 80) return '#ef4444';
|
||||
if (props.score >= 60) return '#f97316';
|
||||
if (props.score >= 40) return '#eab308';
|
||||
return '#22c55e';
|
||||
}};
|
||||
`;
|
||||
|
||||
const ScoreValue = styled.div`
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
`;
|
||||
|
||||
const ScoreLabel = styled.div`
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
`;
|
||||
|
||||
const MetaRow = styled.div`
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const MetaItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const MetaIcon = styled.span`
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const MetaText = styled.span`
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
`;
|
||||
|
||||
// Section Cards
|
||||
const SectionCard = styled.div`
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div<{ expanded?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
background: ${props => props.expanded ? '#f8fafc' : 'white'};
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h4`
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SectionBadge = styled.span<{ color?: string }>`
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: ${props => props.color || '#f1f5f9'};
|
||||
color: ${props => props.color ? 'white' : '#64748b'};
|
||||
`;
|
||||
|
||||
const SectionContent = styled.div<{ expanded: boolean }>`
|
||||
padding: ${props => props.expanded ? '16px 20px' : '0 20px'};
|
||||
max-height: ${props => props.expanded ? '2000px' : '0'};
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
border-top: ${props => props.expanded ? '1px solid #e2e8f0' : 'none'};
|
||||
`;
|
||||
|
||||
const ExpandIcon = styled.span<{ expanded: boolean }>`
|
||||
transition: transform 0.2s;
|
||||
transform: rotate(${props => props.expanded ? '180deg' : '0deg'});
|
||||
`;
|
||||
|
||||
// Technique Item
|
||||
const TechniqueItem = styled.div`
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TechniqueHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const TechniqueName = styled.span`
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
`;
|
||||
|
||||
const TechniqueConfidence = styled.span<{ value: number }>`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.value >= 80 ? '#16a34a' : props.value >= 60 ? '#ca8a04' : '#64748b'};
|
||||
`;
|
||||
|
||||
const TechniqueEvidence = styled.p`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const TechniqueMeta = styled.div`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
`;
|
||||
|
||||
// Claim Item
|
||||
const ClaimItem = styled.div`
|
||||
padding: 12px 16px;
|
||||
border-left: 3px solid;
|
||||
border-left-color: ${props => props.color || '#94a3b8'};
|
||||
background: #f8fafc;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ClaimText = styled.p`
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const ClaimStatus = styled.span<{ color: string }>`
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: ${props => {
|
||||
switch (props.color) {
|
||||
case 'red': return '#fee2e2';
|
||||
case 'orange': return '#ffedd5';
|
||||
case 'green': return '#dcfce7';
|
||||
default: return '#f1f5f9';
|
||||
}
|
||||
}};
|
||||
color: ${props => {
|
||||
switch (props.color) {
|
||||
case 'red': return '#dc2626';
|
||||
case 'orange': return '#ea580c';
|
||||
case 'green': return '#16a34a';
|
||||
default: return '#64748b';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
// Source Item
|
||||
const SourceItem = styled.div`
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SourceHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const SourceDomain = styled.span`
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
`;
|
||||
|
||||
const SourceScore = styled.span<{ score: number }>`
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${props => props.score >= 50 ? '#16a34a' : props.score >= 30 ? '#ca8a04' : '#dc2626'};
|
||||
`;
|
||||
|
||||
const RedFlagsList = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
const RedFlag = styled.span`
|
||||
padding: 4px 8px;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
padding: 16px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #dc2626;
|
||||
margin-top: 16px;
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
interface AgentAnalysisProps {
|
||||
onAnalysisComplete?: (result: AnalysisResponse) => void;
|
||||
}
|
||||
|
||||
export const AgentAnalysis: React.FC<AgentAnalysisProps> = ({ onAnalysisComplete }) => {
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [progress, setProgress] = useState<AnalysisProgress | null>(null);
|
||||
const [result, setResult] = useState<AnalysisResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Section expansion state
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
techniques: true,
|
||||
claims: true,
|
||||
sources: false,
|
||||
dimensions: false,
|
||||
});
|
||||
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
if (!inputText.trim()) return;
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setResult(null);
|
||||
|
||||
const sessionId = agentService.generateSessionId();
|
||||
sessionIdRef.current = sessionId;
|
||||
|
||||
// Track last progress to avoid unnecessary re-renders
|
||||
let lastProgressJson = "";
|
||||
|
||||
const startProgressPolling = () => {
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const progressData = await agentService.getProgress(sessionId);
|
||||
if (progressData) {
|
||||
const currentJson = JSON.stringify(progressData);
|
||||
if (currentJson !== lastProgressJson) {
|
||||
lastProgressJson = currentJson;
|
||||
setProgress(progressData);
|
||||
}
|
||||
|
||||
if (progressData.status === 'completed' || progressData.status === 'failed') {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Progress poll error:', err);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
try {
|
||||
// Start analysis — poll AFTER request is sent so session exists on server
|
||||
const analysisPromise = agentService.analyzeText(inputText, {
|
||||
sessionId,
|
||||
pipeline: 'disinformation',
|
||||
});
|
||||
|
||||
// Start polling after a short delay to let the server create the session
|
||||
startProgressPolling();
|
||||
|
||||
const analysisResult = await analysisPromise;
|
||||
|
||||
// Clear polling
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (analysisResult.success) {
|
||||
setResult(analysisResult);
|
||||
onAnalysisComplete?.(analysisResult);
|
||||
} else {
|
||||
setError(analysisResult.error || 'Analysis failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Analysis failed');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [inputText, onAnalysisComplete]);
|
||||
|
||||
const getStepIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return '✓';
|
||||
case 'running': return '●';
|
||||
case 'failed': return '✕';
|
||||
default: return '○';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms?: number) => {
|
||||
if (!ms) return '';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const getProgressPercent = () => {
|
||||
if (!progress) return 0;
|
||||
const completed = progress.steps.filter(s => s.status === 'completed').length;
|
||||
return Math.round((completed / progress.steps.length) * 100);
|
||||
};
|
||||
|
||||
// Get data to display (from progress partial results or final result)
|
||||
const techniques: DetectedTechnique[] =
|
||||
result?.analysis?.techniques || progress?.partialResults?.techniques || [];
|
||||
const claims: VerifiedClaim[] =
|
||||
result?.analysis?.verified_claims || progress?.partialResults?.claims || [];
|
||||
const sources: AssessedSource[] =
|
||||
result?.analysis?.sources || progress?.partialResults?.sources || [];
|
||||
const verdict: FinalVerdict | undefined =
|
||||
result?.analysis?.verdict || progress?.partialResults?.verdict;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* Input Section */}
|
||||
<InputSection>
|
||||
<TextArea
|
||||
placeholder="Introdu textul de analizat pentru dezinformare..."
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={isAnalyzing}
|
||||
/>
|
||||
<AnalyzeButton
|
||||
onClick={handleAnalyze}
|
||||
disabled={isAnalyzing || !inputText.trim()}
|
||||
>
|
||||
{isAnalyzing ? 'Analizez...' : 'Analizează'}
|
||||
</AnalyzeButton>
|
||||
</InputSection>
|
||||
|
||||
{/* Progress Section */}
|
||||
{(isAnalyzing || progress) && (
|
||||
<ProgressSection>
|
||||
<ProgressHeader>
|
||||
<ProgressTitle>Progres Analiză</ProgressTitle>
|
||||
<ProgressStatus status={progress?.status || 'pending'}>
|
||||
{progress?.status === 'completed' ? 'Complet' :
|
||||
progress?.status === 'failed' ? 'Eșuat' :
|
||||
progress?.status === 'processing' ? 'În procesare' : 'Așteptare'}
|
||||
</ProgressStatus>
|
||||
</ProgressHeader>
|
||||
|
||||
<StepsContainer>
|
||||
{progress?.steps.map((step) => (
|
||||
<StepBadge key={step.id} status={step.status}>
|
||||
<StepIcon>{getStepIcon(step.status)}</StepIcon>
|
||||
{step.nameRo || step.name}
|
||||
{step.count !== undefined && step.count !== null && ` (${step.count})`}
|
||||
{step.durationMs !== undefined && (
|
||||
<StepDuration>{formatDuration(step.durationMs)}</StepDuration>
|
||||
)}
|
||||
</StepBadge>
|
||||
))}
|
||||
</StepsContainer>
|
||||
|
||||
<ProgressBar>
|
||||
<ProgressFill
|
||||
percent={getProgressPercent()}
|
||||
status={progress?.status || 'pending'}
|
||||
/>
|
||||
</ProgressBar>
|
||||
|
||||
<TimeInfo>
|
||||
<span>{getProgressPercent()}% complet</span>
|
||||
{progress?.totalDurationMs && (
|
||||
<span>Durată totală: {formatDuration(progress.totalDurationMs)}</span>
|
||||
)}
|
||||
</TimeInfo>
|
||||
</ProgressSection>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<ErrorMessage>
|
||||
Eroare: {error}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
|
||||
{/* Results Section */}
|
||||
{(verdict || techniques.length > 0 || claims.length > 0) && (
|
||||
<ResultsSection>
|
||||
{/* Verdict Card */}
|
||||
{verdict && (
|
||||
<VerdictCard color={verdict.verdict_color}>
|
||||
<VerdictHeader>
|
||||
<VerdictInfo>
|
||||
<VerdictLabel>Verdict</VerdictLabel>
|
||||
<VerdictCode color={verdict.verdict_color}>
|
||||
{verdict.verdict_code}
|
||||
</VerdictCode>
|
||||
<VerdictName>{verdict.verdict_name}</VerdictName>
|
||||
</VerdictInfo>
|
||||
<ScoreCircle score={verdict.score}>
|
||||
<ScoreValue>{verdict.score}</ScoreValue>
|
||||
<ScoreLabel>/100</ScoreLabel>
|
||||
</ScoreCircle>
|
||||
</VerdictHeader>
|
||||
<MetaRow>
|
||||
<MetaItem>
|
||||
<MetaIcon>🎯</MetaIcon>
|
||||
<MetaText>Confidență: {verdict.confidence.level} ({Math.round(verdict.confidence.score)}%)</MetaText>
|
||||
</MetaItem>
|
||||
<MetaItem>
|
||||
<MetaIcon>⚠️</MetaIcon>
|
||||
<MetaText>Risc: {verdict.risk.name}</MetaText>
|
||||
</MetaItem>
|
||||
</MetaRow>
|
||||
</VerdictCard>
|
||||
)}
|
||||
|
||||
{/* Techniques Section */}
|
||||
{techniques.length > 0 && (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
expanded={expandedSections.techniques}
|
||||
onClick={() => toggleSection('techniques')}
|
||||
>
|
||||
<SectionTitle>
|
||||
🔍 Tehnici de Manipulare
|
||||
<SectionBadge color="#ef4444">{techniques.length}</SectionBadge>
|
||||
</SectionTitle>
|
||||
<ExpandIcon expanded={expandedSections.techniques}>▼</ExpandIcon>
|
||||
</SectionHeader>
|
||||
<SectionContent expanded={expandedSections.techniques}>
|
||||
{techniques.map((tech, idx) => (
|
||||
<TechniqueItem key={idx}>
|
||||
<TechniqueHeader>
|
||||
<TechniqueName>{localized(tech, 'technique_name')}</TechniqueName>
|
||||
<TechniqueConfidence value={tech.confidence}>
|
||||
{tech.confidence}% confidență
|
||||
</TechniqueConfidence>
|
||||
</TechniqueHeader>
|
||||
<TechniqueEvidence>{tech.evidence}</TechniqueEvidence>
|
||||
<TechniqueMeta>
|
||||
<span>Dimensiune: {tech.dimension_code}</span>
|
||||
<span>Severitate: {tech.severity}%</span>
|
||||
</TechniqueMeta>
|
||||
</TechniqueItem>
|
||||
))}
|
||||
</SectionContent>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Claims Section */}
|
||||
{claims.length > 0 && (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
expanded={expandedSections.claims}
|
||||
onClick={() => toggleSection('claims')}
|
||||
>
|
||||
<SectionTitle>
|
||||
📋 Afirmații Verificate
|
||||
<SectionBadge color="#3b82f6">{claims.length}</SectionBadge>
|
||||
</SectionTitle>
|
||||
<ExpandIcon expanded={expandedSections.claims}>▼</ExpandIcon>
|
||||
</SectionHeader>
|
||||
<SectionContent expanded={expandedSections.claims}>
|
||||
{claims.map((claim, idx) => (
|
||||
<ClaimItem key={idx} color={claim.status_color}>
|
||||
<ClaimText>"{claim.text}"</ClaimText>
|
||||
<ClaimStatus color={claim.status_color}>
|
||||
{localized(claim, 'status_name')} ({claim.confidence}%)
|
||||
</ClaimStatus>
|
||||
</ClaimItem>
|
||||
))}
|
||||
</SectionContent>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Sources Section */}
|
||||
{sources.length > 0 && (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
expanded={expandedSections.sources}
|
||||
onClick={() => toggleSection('sources')}
|
||||
>
|
||||
<SectionTitle>
|
||||
🌐 Surse Evaluate
|
||||
<SectionBadge>{sources.length}</SectionBadge>
|
||||
</SectionTitle>
|
||||
<ExpandIcon expanded={expandedSections.sources}>▼</ExpandIcon>
|
||||
</SectionHeader>
|
||||
<SectionContent expanded={expandedSections.sources}>
|
||||
{sources.map((source, idx) => (
|
||||
<SourceItem key={idx}>
|
||||
<SourceHeader>
|
||||
<SourceDomain>{source.domain}</SourceDomain>
|
||||
<SourceScore score={source.final_score}>
|
||||
{source.final_score}/100
|
||||
</SourceScore>
|
||||
</SourceHeader>
|
||||
{source.red_flags && source.red_flags.length > 0 && (
|
||||
<RedFlagsList>
|
||||
{source.red_flags.map((flag, flagIdx) => (
|
||||
<RedFlag key={flagIdx}>
|
||||
{typeof flag === 'string' ? flag : flag.flag}
|
||||
</RedFlag>
|
||||
))}
|
||||
</RedFlagsList>
|
||||
)}
|
||||
</SourceItem>
|
||||
))}
|
||||
</SectionContent>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* Meta Info */}
|
||||
{result?.meta && (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
expanded={expandedSections.meta}
|
||||
onClick={() => toggleSection('meta')}
|
||||
>
|
||||
<SectionTitle>
|
||||
⚙️ Informații Analiză
|
||||
</SectionTitle>
|
||||
<ExpandIcon expanded={expandedSections.meta || false}>▼</ExpandIcon>
|
||||
</SectionHeader>
|
||||
<SectionContent expanded={expandedSections.meta || false}>
|
||||
<MetaRow>
|
||||
<MetaItem>
|
||||
<MetaText>Durată: {formatDuration(result.meta.durationMs)}</MetaText>
|
||||
</MetaItem>
|
||||
{result.meta.model && (
|
||||
<MetaItem>
|
||||
<MetaText>Model: {result.meta.model}</MetaText>
|
||||
</MetaItem>
|
||||
)}
|
||||
{result.meta.paramsUsed && (
|
||||
<MetaItem>
|
||||
<MetaText>Parametri DB: ✓</MetaText>
|
||||
</MetaItem>
|
||||
)}
|
||||
</MetaRow>
|
||||
</SectionContent>
|
||||
</SectionCard>
|
||||
)}
|
||||
</ResultsSection>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentAnalysis;
|
||||
9
web/src/components/AgentAnalysis/index.ts
Normal file
9
web/src/components/AgentAnalysis/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { AgentAnalysis } from './AgentAnalysis';
|
||||
export type {
|
||||
AnalysisProgress,
|
||||
AnalysisResponse,
|
||||
DetectedTechnique,
|
||||
VerifiedClaim,
|
||||
AssessedSource,
|
||||
FinalVerdict,
|
||||
} from '../../services/agent.service';
|
||||
568
web/src/components/AgentMode/AgentMode.tsx
Normal file
568
web/src/components/AgentMode/AgentMode.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import React, { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors, typography, spacing } from '../../theme';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
export const AgentMode: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [agentEnabled, setAgentEnabled] = useState(false);
|
||||
const [extensionInstalled, setExtensionInstalled] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const handleToggleAgent = () => {
|
||||
if (extensionInstalled) {
|
||||
setAgentEnabled(!agentEnabled);
|
||||
} else {
|
||||
toast.warning('Please install the Chrome Extension first!');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>{t('agent.title')}</Title>
|
||||
<Subtitle>
|
||||
{t('agent.subtitle')}
|
||||
</Subtitle>
|
||||
</Header>
|
||||
|
||||
{/* Extension Status Card */}
|
||||
<StatusCard>
|
||||
<StatusHeader>
|
||||
<StatusIcon installed={extensionInstalled}>
|
||||
{extensionInstalled ? '✓' : '⚠'}
|
||||
</StatusIcon>
|
||||
<StatusInfo>
|
||||
<StatusTitle>{t('agent.browserExtension')}</StatusTitle>
|
||||
<StatusText installed={extensionInstalled}>
|
||||
{extensionInstalled ? t('agent.installed') : t('agent.notInstalled')}
|
||||
</StatusText>
|
||||
</StatusInfo>
|
||||
</StatusHeader>
|
||||
|
||||
{!extensionInstalled && (
|
||||
<InstallButton
|
||||
onClick={() => setExtensionInstalled(true)}
|
||||
aria-label="Install Chrome Extension for DIDI"
|
||||
>
|
||||
<span aria-hidden="true">📦</span> {t('agent.installExtension')}
|
||||
</InstallButton>
|
||||
)}
|
||||
</StatusCard>
|
||||
|
||||
{/* Agent Toggle */}
|
||||
<ControlCard>
|
||||
<ControlHeader>
|
||||
<ControlTitle>
|
||||
<AgentIcon aria-hidden="true">🤖</AgentIcon>
|
||||
{t('agent.agentStatus')}
|
||||
</ControlTitle>
|
||||
<ToggleSwitch>
|
||||
<ToggleInput
|
||||
type="checkbox"
|
||||
checked={agentEnabled}
|
||||
onChange={handleToggleAgent}
|
||||
disabled={!extensionInstalled}
|
||||
aria-label="Enable or disable AI agent monitoring"
|
||||
aria-checked={agentEnabled}
|
||||
/>
|
||||
<ToggleSlider enabled={agentEnabled} aria-hidden="true" />
|
||||
</ToggleSwitch>
|
||||
</ControlHeader>
|
||||
<ControlDescription>
|
||||
{agentEnabled
|
||||
? `✅ ${t('agent.agentActive')}`
|
||||
: `⏸️ ${t('agent.agentPaused')}`}
|
||||
</ControlDescription>
|
||||
</ControlCard>
|
||||
|
||||
{/* Guide Section */}
|
||||
<GuideSection>
|
||||
<GuideHeader>
|
||||
<GuideTitle>📹 {t('agent.howToUse')}</GuideTitle>
|
||||
<GuideSubtitle>
|
||||
{t('agent.watchGuide')}
|
||||
</GuideSubtitle>
|
||||
</GuideHeader>
|
||||
|
||||
<VideoGuide>
|
||||
<VideoPlaceholder>
|
||||
<PlayIcon>▶️</PlayIcon>
|
||||
<VideoTitle>{t('agent.videoTitle')}</VideoTitle>
|
||||
<VideoDescription>
|
||||
{t('agent.videoDesc')}
|
||||
</VideoDescription>
|
||||
</VideoPlaceholder>
|
||||
</VideoGuide>
|
||||
|
||||
<GuideSteps>
|
||||
<Step>
|
||||
<StepNumber>1</StepNumber>
|
||||
<StepContent>
|
||||
<StepTitle>{t('agent.step1Title')}</StepTitle>
|
||||
<StepText>{t('agent.step1Desc')}</StepText>
|
||||
</StepContent>
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
<StepNumber>2</StepNumber>
|
||||
<StepContent>
|
||||
<StepTitle>{t('agent.step2Title')}</StepTitle>
|
||||
<StepText>{t('agent.step2Desc')}</StepText>
|
||||
</StepContent>
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
<StepNumber>3</StepNumber>
|
||||
<StepContent>
|
||||
<StepTitle>{t('agent.step3Title')}</StepTitle>
|
||||
<StepText>
|
||||
{t('agent.step3Desc')}
|
||||
</StepText>
|
||||
</StepContent>
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
<StepNumber>4</StepNumber>
|
||||
<StepContent>
|
||||
<StepTitle>{t('agent.step4Title')}</StepTitle>
|
||||
<StepText>{t('agent.step4Desc')}</StepText>
|
||||
</StepContent>
|
||||
</Step>
|
||||
</GuideSteps>
|
||||
</GuideSection>
|
||||
|
||||
{/* Activity Log */}
|
||||
{agentEnabled && (
|
||||
<ActivitySection>
|
||||
<ActivityHeader>
|
||||
<ActivityTitle>{t('agent.recentActivity')}</ActivityTitle>
|
||||
<ActivityBadge>{t('agent.live')}</ActivityBadge>
|
||||
</ActivityHeader>
|
||||
|
||||
<ActivityList>
|
||||
<ActivityItem>
|
||||
<ActivityIcon>🔍</ActivityIcon>
|
||||
<ActivityInfo>
|
||||
<ActivityText>Analyzed article on Twitter</ActivityText>
|
||||
<ActivityTime>2 minutes ago</ActivityTime>
|
||||
</ActivityInfo>
|
||||
<ActivityResult type="safe">✓ Verified</ActivityResult>
|
||||
</ActivityItem>
|
||||
|
||||
<ActivityItem>
|
||||
<ActivityIcon>⚠️</ActivityIcon>
|
||||
<ActivityInfo>
|
||||
<ActivityText>Detected suspicious claim on Facebook</ActivityText>
|
||||
<ActivityTime>15 minutes ago</ActivityTime>
|
||||
</ActivityInfo>
|
||||
<ActivityResult type="warning">⚠ Flagged</ActivityResult>
|
||||
</ActivityItem>
|
||||
|
||||
<ActivityItem>
|
||||
<ActivityIcon>🔍</ActivityIcon>
|
||||
<ActivityInfo>
|
||||
<ActivityText>Checked news headline</ActivityText>
|
||||
<ActivityTime>1 hour ago</ActivityTime>
|
||||
</ActivityInfo>
|
||||
<ActivityResult type="safe">✓ Verified</ActivityResult>
|
||||
</ActivityItem>
|
||||
</ActivityList>
|
||||
</ActivitySection>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// Styled Components
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
@media (max-width: 768px) { padding: 0; }
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: ${spacing['3xl']}px;
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize['3xl']};
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.md}px;`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
color: var(--fg-muted);
|
||||
line-height: ${typography.lineHeight.relaxed};
|
||||
`;
|
||||
|
||||
const StatusCard = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 16px;
|
||||
padding: ${spacing['2xl']}px;
|
||||
margin-bottom: ${spacing.xl}px;`;
|
||||
|
||||
const StatusHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.lg}px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
const StatusIcon = styled.div<{ installed: boolean }>`
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
background: ${props => props.installed
|
||||
? 'rgba(40, 167, 69, 0.1)'
|
||||
: 'rgba(255, 193, 7, 0.1)'};
|
||||
border: 2px solid ${props => props.installed
|
||||
? colors.truthGreen
|
||||
: '#FFC107'};
|
||||
`;
|
||||
|
||||
const StatusInfo = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StatusTitle = styled.h3`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xl};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.xs}px;`;
|
||||
|
||||
const StatusText = styled.p<{ installed: boolean }>`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
color: ${props => props.installed ? colors.truthGreen : '#FFC107'};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const InstallButton = styled.button`
|
||||
width: 100%;
|
||||
padding: ${spacing.md}px ${spacing.xl}px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--fg-on-accent);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`;
|
||||
|
||||
const ControlCard = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 16px;
|
||||
padding: ${spacing['2xl']}px;
|
||||
margin-bottom: ${spacing.xl}px;`;
|
||||
|
||||
const ControlHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: ${spacing.md}px;
|
||||
`;
|
||||
|
||||
const ControlTitle = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xl};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);`;
|
||||
|
||||
const AgentIcon = styled.span`
|
||||
font-size: ${typography.fontSize['2xl']};
|
||||
`;
|
||||
|
||||
const ToggleSwitch = styled.label`
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 32px;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const ToggleInput = styled.input`
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
&:disabled + span {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToggleSlider = styled.span<{ enabled: boolean }>`
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${props => props.enabled
|
||||
? colors.truthGreen
|
||||
: 'var(--bg-active)'};
|
||||
border-radius: 32px;
|
||||
transition: 0.4s;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background: ${colors.white};
|
||||
border-radius: 50%;
|
||||
transition: 0.4s;
|
||||
transform: ${props => props.enabled ? 'translateX(28px)' : 'translateX(0)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const ControlDescription = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
color: var(--fg-muted);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const GuideSection = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 16px;
|
||||
padding: ${spacing['2xl']}px;
|
||||
margin-bottom: ${spacing.xl}px;`;
|
||||
|
||||
const GuideHeader = styled.div`
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
const GuideTitle = styled.h3`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize['2xl']};
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.sm}px;`;
|
||||
|
||||
const GuideSubtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
color: var(--fg-muted);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const VideoGuide = styled.div`
|
||||
margin-bottom: ${spacing['2xl']}px;
|
||||
`;
|
||||
|
||||
const VideoPlaceholder = styled.div`
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--accent-subtle);
|
||||
border: 2px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${spacing.md}px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
`;
|
||||
|
||||
const PlayIcon = styled.div`
|
||||
font-size: 64px;
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
const VideoTitle = styled.h4`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xl};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin: 0;`;
|
||||
|
||||
const VideoDescription = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const GuideSteps = styled.div`
|
||||
display: grid;
|
||||
gap: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
const Step = styled.div`
|
||||
display: flex;
|
||||
gap: ${spacing.lg}px;
|
||||
align-items: start;
|
||||
`;
|
||||
|
||||
const StepNumber = styled.div`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StepContent = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StepTitle = styled.h5`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.xs}px;`;
|
||||
|
||||
const StepText = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
color: var(--fg-muted);
|
||||
line-height: ${typography.lineHeight.relaxed};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ActivitySection = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 16px;
|
||||
padding: ${spacing['2xl']}px;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ActivityHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
const ActivityTitle = styled.h3`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xl};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin: 0;`;
|
||||
|
||||
const ActivityBadge = styled.div`
|
||||
padding: ${spacing.xs}px ${spacing.md}px;
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
border: 1px solid ${colors.truthGreen};
|
||||
border-radius: 12px;
|
||||
color: ${colors.truthGreen};
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
const ActivityList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing.md}px;
|
||||
`;
|
||||
|
||||
const ActivityItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.md}px;
|
||||
padding: ${spacing.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
`;
|
||||
|
||||
const ActivityIcon = styled.div`
|
||||
font-size: ${typography.fontSize['2xl']};
|
||||
`;
|
||||
|
||||
const ActivityInfo = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const ActivityText = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.xs}px;`;
|
||||
|
||||
const ActivityTime = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const ActivityResult = styled.div<{ type: 'safe' | 'warning' }>`
|
||||
padding: ${spacing.xs}px ${spacing.md}px;
|
||||
background: ${props => props.type === 'safe'
|
||||
? 'rgba(40, 167, 69, 0.1)'
|
||||
: 'rgba(255, 193, 7, 0.1)'};
|
||||
border: 1px solid ${props => props.type === 'safe'
|
||||
? colors.truthGreen
|
||||
: '#FFC107'};
|
||||
border-radius: 8px;
|
||||
color: ${props => props.type === 'safe' ? colors.truthGreen : '#FFC107'};
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
617
web/src/components/AiTamper/AiTamper.tsx
Normal file
617
web/src/components/AiTamper/AiTamper.tsx
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { typography, spacing } from '../../theme';
|
||||
import { useAsyncAnalysis } from '../../hooks/useAsyncAnalysis';
|
||||
import { validateText } from '../../utils/text-validation';
|
||||
import { validateFile } from '../../utils/file-validation';
|
||||
import { ConsentNotice, ConsentSlot, useMediaConsent } from '../ConsentNotice';
|
||||
import type { AnalysisSession } from '../../types/analysis-session';
|
||||
import { InputPreview } from '../PipelineAnalysis/sections/InputPreview';
|
||||
import { AiResults, type AiDisplayResult, toAiDisplayResult } from '../PipelineAnalysis/sections/AiResults';
|
||||
import { VerdictSection } from '../PipelineAnalysis/styles';
|
||||
|
||||
// =============================================================================
|
||||
// Input Types & Helpers
|
||||
// =============================================================================
|
||||
|
||||
type InputType = 'text' | 'image' | 'audio' | 'video';
|
||||
type AnalysisMode = 'quick' | 'full';
|
||||
|
||||
const INPUT_TYPES: { key: InputType; labelKey: string; accept?: string }[] = [
|
||||
{ key: 'text', labelKey: 'common.text' },
|
||||
{ key: 'image', labelKey: 'common.image', accept: 'image/jpeg,image/png,image/webp,image/gif' },
|
||||
{ key: 'audio', labelKey: 'common.audio', accept: 'audio/mpeg,audio/wav,audio/ogg,audio/mp4' },
|
||||
{ key: 'video', labelKey: 'common.video', accept: 'video/mp4,video/webm,video/ogg' },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const AiTamper: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
|
||||
const async = useAsyncAnalysis('ai-tampered');
|
||||
const [inputType, setInputType] = useState<InputType>('text');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [analysisMode, setAnalysisMode] = useState<AnalysisMode>('quick');
|
||||
const [displayResult, setDisplayResult] = useState<AiDisplayResult | null>(null);
|
||||
const [analyzedInput, setAnalyzedInput] = useState<{ type: InputType; text: string } | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Parse async result (AnalysisSession) into AiDisplayResult when it arrives
|
||||
useEffect(() => {
|
||||
if (!async.result) return;
|
||||
try {
|
||||
const session: AnalysisSession = async.result;
|
||||
const r = session.ai_tampered;
|
||||
if (!r) return;
|
||||
|
||||
const parsed = toAiDisplayResult(r, {
|
||||
inputType: session.input_type,
|
||||
inputText: session.input_text,
|
||||
});
|
||||
|
||||
// For audio/video, swap the input preview snapshot to the transcript text
|
||||
if (session.input_text && (session.input_type === 'audio' || session.input_type === 'video')) {
|
||||
setAnalyzedInput(prev => prev ? { ...prev, text: session.input_text! } : prev);
|
||||
}
|
||||
|
||||
setDisplayResult(parsed);
|
||||
} catch {
|
||||
// result parsing failed
|
||||
}
|
||||
}, [async.result]);
|
||||
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const validation = validateFile(file, inputType);
|
||||
if (!validation.valid) {
|
||||
setFileError(validation.error);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setFileError(null);
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
setFileError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: InputType) => {
|
||||
setInputType(type);
|
||||
setSelectedFile(null);
|
||||
setDisplayResult(null);
|
||||
setAnalyzedInput(null);
|
||||
async.reset();
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const textValidation = useMemo(() => validateText(inputText), [inputText]);
|
||||
const [mediaConsent, setMediaConsent] = useMediaConsent();
|
||||
|
||||
const canAnalyze =
|
||||
inputType === 'text' ? textValidation.valid : selectedFile !== null && mediaConsent;
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!canAnalyze) return;
|
||||
setDisplayResult(null);
|
||||
|
||||
if (inputType === 'text') {
|
||||
const txt = inputText.trim();
|
||||
setAnalyzedInput({ type: inputType, text: txt });
|
||||
async.submitText(txt, { mode: analysisMode });
|
||||
} else if (selectedFile) {
|
||||
setAnalyzedInput({ type: inputType, text: selectedFile.name });
|
||||
async.submitMedia(selectedFile, inputType);
|
||||
}
|
||||
};
|
||||
|
||||
// Aliases for template compatibility
|
||||
const isAnalyzing = async.isAnalyzing;
|
||||
const error = async.error;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* Header */}
|
||||
<Header>
|
||||
<Title>{t('aiTamper.title')}</Title>
|
||||
<Subtitle>{t('aiTamper.subtitle')}</Subtitle>
|
||||
</Header>
|
||||
|
||||
{/* Input Type Selector */}
|
||||
<TypeSelector>
|
||||
{INPUT_TYPES.map(({ key, labelKey }) => (
|
||||
<TypeButton
|
||||
key={key}
|
||||
active={inputType === key}
|
||||
onClick={() => handleTypeChange(key)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</TypeButton>
|
||||
))}
|
||||
</TypeSelector>
|
||||
|
||||
{/* Analysis Mode Toggle — text only */}
|
||||
{inputType === 'text' && (
|
||||
<ModeSelector>
|
||||
<ModeButton
|
||||
active={analysisMode === 'quick'}
|
||||
onClick={() => setAnalysisMode('quick')}
|
||||
>
|
||||
<ModeLabel>{t('aiTamper.quickScan')}</ModeLabel>
|
||||
<ModeDesc>{t('aiTamper.quickScanDesc')}</ModeDesc>
|
||||
</ModeButton>
|
||||
<ModeButton
|
||||
active={analysisMode === 'full'}
|
||||
onClick={() => setAnalysisMode('full')}
|
||||
>
|
||||
<ModeLabel>{t('aiTamper.deepAnalysis')}</ModeLabel>
|
||||
<ModeDesc>{t('aiTamper.deepAnalysisDesc')}</ModeDesc>
|
||||
</ModeButton>
|
||||
</ModeSelector>
|
||||
)}
|
||||
|
||||
{/* Input Area */}
|
||||
<InputArea>
|
||||
{inputType === 'text' ? (
|
||||
<TextInput
|
||||
placeholder={t('aiTamper.textPlaceholder')}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={isAnalyzing}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FileDropZone>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={INPUT_TYPES.find(t => t.key === inputType)?.accept}
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<FileSelected>
|
||||
<FileName>{selectedFile.name}</FileName>
|
||||
<FileSize>{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</FileSize>
|
||||
<RemoveFileBtn onClick={handleRemoveFile}>Remove</RemoveFileBtn>
|
||||
</FileSelected>
|
||||
) : (
|
||||
<DropPlaceholder onClick={() => fileInputRef.current?.click()}>
|
||||
<DropLabel>Click to select {inputType} file</DropLabel>
|
||||
<DropHint>
|
||||
{inputType === 'image' && t('pipeline.imageHint')}
|
||||
{inputType === 'audio' && t('pipeline.audioHint')}
|
||||
{inputType === 'video' && t('pipeline.videoHint')}
|
||||
</DropHint>
|
||||
</DropPlaceholder>
|
||||
)}
|
||||
{fileError && <FileErrorMsg>{fileError}</FileErrorMsg>}
|
||||
</FileDropZone>
|
||||
<ConsentSlot>
|
||||
<ConsentNotice consented={mediaConsent} onChange={setMediaConsent} />
|
||||
</ConsentSlot>
|
||||
</>
|
||||
)}
|
||||
|
||||
<InputFooter>
|
||||
<CharCount style={inputType === 'text' ? { color: textValidation.level === 'error' ? '#ef4444' : textValidation.level === 'warning' ? '#eab308' : '#22c55e' } : undefined}>
|
||||
{inputType === 'text'
|
||||
? (textValidation.error || textValidation.warning || `${inputText.trim().length} characters`)
|
||||
: selectedFile ? t('common.readyToAnalyze') : t('common.noFileSelected')}
|
||||
</CharCount>
|
||||
<AnalyzeBtn
|
||||
onClick={handleAnalyze}
|
||||
disabled={isAnalyzing || !canAnalyze}
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Spinner />
|
||||
{async.statusText || (inputType === 'text' && analysisMode === 'quick' ? t('aiTamper.scanning') : t('common.analyzing'))}
|
||||
</>
|
||||
) : (
|
||||
inputType === 'text'
|
||||
? (analysisMode === 'quick' ? t('aiTamper.quickScan') : t('aiTamper.deepAnalyze'))
|
||||
: t('common.analyze')
|
||||
)}
|
||||
</AnalyzeBtn>
|
||||
</InputFooter>
|
||||
</InputArea>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<ErrorBox>
|
||||
<ErrorIcon>!</ErrorIcon>
|
||||
{error}
|
||||
</ErrorBox>
|
||||
)}
|
||||
|
||||
{/* Backend Warnings */}
|
||||
{async.warnings.length > 0 && (
|
||||
<WarningBox>
|
||||
{async.warnings.map((w, i) => <div key={i}>{w}</div>)}
|
||||
</WarningBox>
|
||||
)}
|
||||
|
||||
{/* Input Preview (shown once result lands) */}
|
||||
{displayResult && analyzedInput && (
|
||||
<InputPreview inputType={analyzedInput.type} text={analyzedInput.text} />
|
||||
)}
|
||||
|
||||
{/* Results — editorial layout */}
|
||||
{displayResult && (
|
||||
<VerdictSection>
|
||||
<AiResults
|
||||
result={displayResult}
|
||||
isRo={isRo}
|
||||
onTryDeepScan={
|
||||
inputType === 'text' && analysisMode === 'quick'
|
||||
? () => { setAnalysisMode('full'); setDisplayResult(null); }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</VerdictSection>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Styled Components — input form & banners (results use shared editorial)
|
||||
// =============================================================================
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
@media (max-width: 768px) { padding: 0; }
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: clamp(1.375rem, 1.1rem + 1vw, 1.75rem);
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin: 0 0 ${spacing.xs}px 0;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
/* Input Type Selector */
|
||||
const TypeSelector = styled.div`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 12px;
|
||||
margin-bottom: ${spacing.md}px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
`;
|
||||
|
||||
const TypeButton = styled.button<{ active?: boolean; disabled?: boolean }>`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${props => props.active ? typography.fontWeight.semibold : typography.fontWeight.medium};
|
||||
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: all 0.2s ease;
|
||||
|
||||
background: ${props => props.active ? 'var(--accent-subtle)' : 'transparent'};
|
||||
color: ${props => props.active
|
||||
? 'var(--accent-text)'
|
||||
: props.disabled ? 'var(--fg-disabled)' : 'var(--fg-secondary)'};
|
||||
|
||||
${props => props.active && `box-shadow: 0 0 0 1px var(--accent-border);`}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${props => props.active ? 'var(--accent-subtle)' : 'var(--bg-hover)'};
|
||||
}
|
||||
`;
|
||||
|
||||
/* Analysis Mode Selector */
|
||||
const ModeSelector = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const ModeButton = styled.button<{ active?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid ${props => props.active
|
||||
? 'var(--accent-border)' : 'var(--border-subtle)'};
|
||||
border-radius: 10px;
|
||||
background: ${props => props.active
|
||||
? 'var(--accent-subtle)' : 'var(--bg-surface)'};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
`;
|
||||
|
||||
const ModeLabel = styled.span`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
`;
|
||||
|
||||
const ModeDesc = styled.span`
|
||||
font-size: 11px;
|
||||
color: var(--fg-subtle);
|
||||
`;
|
||||
|
||||
/* Input Area */
|
||||
const InputArea = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
`;
|
||||
|
||||
const TextInput = styled.textarea`
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
padding: ${spacing.lg}px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 15px;
|
||||
color: var(--fg-primary);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
|
||||
&:focus { outline: none; }
|
||||
&::placeholder { color: var(--fg-subtle); }
|
||||
`;
|
||||
|
||||
const InputFooter = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${spacing.sm}px ${spacing.lg}px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: ${spacing.md}px ${spacing.lg}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CharCount = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--fg-subtle);
|
||||
`;
|
||||
|
||||
const spin = keyframes`
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
`;
|
||||
|
||||
const Spinner = styled.span`
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: ${spin} 0.7s linear infinite;
|
||||
`;
|
||||
|
||||
const AnalyzeBtn = styled.button<{ disabled?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 24px;
|
||||
background: ${props => props.disabled
|
||||
? 'var(--accent-subtle)'
|
||||
: 'var(--accent)'};
|
||||
color: ${props => props.disabled ? 'var(--fg-disabled)' : 'var(--fg-on-accent)'};
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
/* File Upload */
|
||||
const FileDropZone = styled.div`
|
||||
padding: ${spacing.lg}px;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const DropPlaceholder = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 40px;
|
||||
border: 2px dashed var(--accent-border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
`;
|
||||
|
||||
const DropLabel = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
const DropHint = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--fg-subtle);
|
||||
`;
|
||||
|
||||
const FileErrorMsg = styled.div`
|
||||
font-size: 13px; color: #ef4444; margin-top: 8px; text-align: center;
|
||||
`;
|
||||
|
||||
const FileSelected = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.md}px;
|
||||
padding: 14px 20px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const FileName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-primary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const FileSize = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const RemoveFileBtn = styled.button`
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #f87171;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] & {
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
color: #dc2626;
|
||||
&:hover { background: rgba(220, 38, 38, 0.05); }
|
||||
}
|
||||
`;
|
||||
|
||||
/* Error */
|
||||
const ErrorBox = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px ${spacing.lg}px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 10px;
|
||||
color: #f87171;
|
||||
font-size: ${typography.fontSize.sm};
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
|
||||
[data-theme="light"] & {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
`;
|
||||
|
||||
const WarningBox = styled.div`
|
||||
padding: 12px ${spacing.lg}px;
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
border-radius: 10px;
|
||||
color: #eab308;
|
||||
font-size: ${typography.fontSize.sm};
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
[data-theme="light"] & {
|
||||
background: #fefce8;
|
||||
border-color: #fde68a;
|
||||
color: #a16207;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorIcon = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
1
web/src/components/AiTamper/index.ts
Normal file
1
web/src/components/AiTamper/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { AiTamper } from './AiTamper';
|
||||
2173
web/src/components/AnalysisResults/AnalysisResults.tsx
Normal file
2173
web/src/components/AnalysisResults/AnalysisResults.tsx
Normal file
File diff suppressed because it is too large
Load diff
751
web/src/components/AnalysisTab/AnalysisTab.tsx
Normal file
751
web/src/components/AnalysisTab/AnalysisTab.tsx
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { AnalysisResults } from '../AnalysisResults/AnalysisResults';
|
||||
import { MisinformationV2Results } from '../MisinformationV2Results/MisinformationV2Results';
|
||||
import { parseAnalysisResult } from '../../utils/parseAnalysisResult';
|
||||
import { useAsyncAnalysis } from '../../hooks/useAsyncAnalysis';
|
||||
import { validateText } from '../../utils/text-validation';
|
||||
import { validateUrl } from '../../utils/url-validation';
|
||||
import { validateFile } from '../../utils/file-validation';
|
||||
import { ConsentNotice, useMediaConsent } from '../ConsentNotice';
|
||||
import {
|
||||
Container,
|
||||
MainContent,
|
||||
Header,
|
||||
Title,
|
||||
Subtitle,
|
||||
ContentArea,
|
||||
TextArea,
|
||||
MediaUploadSection,
|
||||
MediaUploadLabel,
|
||||
RemoveMediaButton,
|
||||
UploadArea,
|
||||
UploadAreaLarge,
|
||||
UploadIcon,
|
||||
UploadIconLarge,
|
||||
UploadText,
|
||||
UploadTextLarge,
|
||||
UploadHint,
|
||||
UploadLabel,
|
||||
HiddenFileInput,
|
||||
FilePreview,
|
||||
FilePreviewLarge,
|
||||
FileIconLarge,
|
||||
FileInfo,
|
||||
FileName,
|
||||
FileSize,
|
||||
ChangeFileLabel,
|
||||
ContextTextSection,
|
||||
ContextTextLabel,
|
||||
OptionalBadge,
|
||||
RemoveTextButton,
|
||||
ActionButtons,
|
||||
ClearButton,
|
||||
AnalyzeButton,
|
||||
Spinner,
|
||||
ResultsSection,
|
||||
ResultsHeader,
|
||||
ResultsContent,
|
||||
ResultItem,
|
||||
ResultLabel,
|
||||
ResultValue,
|
||||
ResultValuePre,
|
||||
ErrorMessage,
|
||||
ErrorIcon,
|
||||
ErrorText,
|
||||
HistorySection,
|
||||
HistoryHeader,
|
||||
HistoryTitle,
|
||||
HistoryCount,
|
||||
HistoryList,
|
||||
HistoryItem,
|
||||
HistoryItemHeader,
|
||||
HistoryItemIndex,
|
||||
HistoryItemTime,
|
||||
HistoryItemInput,
|
||||
HistoryItemStatus,
|
||||
URLInputSection,
|
||||
URLInputLabel,
|
||||
HelpText,
|
||||
URLInput,
|
||||
ProgressContainer,
|
||||
ProgressTitle,
|
||||
ProgressSteps,
|
||||
ProgressStep,
|
||||
StepIcon,
|
||||
StepConnector,
|
||||
ProgressBarContainer,
|
||||
ProgressBarFill,
|
||||
ProgressMessage,
|
||||
ProgressPercentage,
|
||||
} from './styles/analysisTab.styles';
|
||||
|
||||
type ContentType = 'text' | 'image' | 'video' | 'audio' | 'url';
|
||||
type MediaType = 'image' | 'video' | 'audio' | null;
|
||||
type ProgressStep = 'idle' | 'submitting' | 'processing' | 'analyzing' | 'complete' | 'error';
|
||||
|
||||
interface AnalysisProgress {
|
||||
step: ProgressStep;
|
||||
percentage: number;
|
||||
message: string;
|
||||
runId: string | null;
|
||||
}
|
||||
|
||||
interface AnalysisTabProps {
|
||||
selectedType: ContentType;
|
||||
}
|
||||
|
||||
export const AnalysisTab: React.FC<AnalysisTabProps> = ({ selectedType }) => {
|
||||
const async = useAsyncAnalysis('pipeline');
|
||||
const [textContent, setTextContent] = useState('');
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||
const [mediaType, setMediaType] = useState<MediaType>(null);
|
||||
const [contextText, setContextText] = useState('');
|
||||
const [showMediaOption, setShowMediaOption] = useState(false);
|
||||
const [showTextOption, setShowTextOption] = useState(false);
|
||||
const [analysisResult, setAnalysisResult] = useState<any>(null);
|
||||
|
||||
// Progress tracking — derived from async hook
|
||||
const [analysisProgress, setAnalysisProgress] = useState<AnalysisProgress>({
|
||||
step: 'idle',
|
||||
percentage: 0,
|
||||
message: '',
|
||||
runId: null
|
||||
});
|
||||
|
||||
// Progressive data (for misinformation v2 real-time updates)
|
||||
const [progressiveData, setProgressiveData] = useState<any>(null);
|
||||
|
||||
// History
|
||||
const [analysisHistory, setAnalysisHistory] = useState<any[]>([]);
|
||||
|
||||
// Timer ref for cleanup on unmount
|
||||
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const analyzing = async.isAnalyzing;
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Map async hook state to analysisProgress
|
||||
useEffect(() => {
|
||||
if (async.isAnalyzing) {
|
||||
const step: ProgressStep = async.progress <= 10 ? 'submitting'
|
||||
: async.progress < 90 ? 'analyzing'
|
||||
: 'processing';
|
||||
setAnalysisProgress({
|
||||
step,
|
||||
percentage: async.progress,
|
||||
message: async.statusText || 'Analyzing...',
|
||||
runId: async.sessionId,
|
||||
});
|
||||
} else if (async.skipped) {
|
||||
setAnalysisProgress({
|
||||
step: 'error',
|
||||
percentage: 0,
|
||||
message: async.skipMessage || 'Content could not be extracted',
|
||||
runId: null,
|
||||
});
|
||||
} else if (async.error) {
|
||||
setAnalysisProgress({
|
||||
step: 'error',
|
||||
percentage: 0,
|
||||
message: async.error,
|
||||
runId: null,
|
||||
});
|
||||
} else if (async.result) {
|
||||
setAnalysisProgress({
|
||||
step: 'complete',
|
||||
percentage: 100,
|
||||
message: 'Analysis complete!',
|
||||
runId: async.sessionId,
|
||||
});
|
||||
}
|
||||
}, [async.isAnalyzing, async.progress, async.statusText, async.error, async.skipped, async.skipMessage, async.result, async.sessionId]);
|
||||
|
||||
// Parse async result when it arrives
|
||||
useEffect(() => {
|
||||
if (!async.result) return;
|
||||
try {
|
||||
const data = (async.result as any).data || async.result;
|
||||
setAnalysisResult(data);
|
||||
setProgressiveData(data);
|
||||
|
||||
// Add to history
|
||||
const historyItem = {
|
||||
id: async.sessionId || Date.now().toString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
input: selectedType === 'text' ? textContent
|
||||
: selectedType === 'url' ? `URL: ${urlInput}`
|
||||
: `${selectedType}: ${mediaFile?.name || 'file'}`,
|
||||
result: data,
|
||||
type: selectedType,
|
||||
status: 'completed',
|
||||
};
|
||||
setAnalysisHistory(prev => [historyItem, ...prev]);
|
||||
|
||||
// Reset progress after short delay
|
||||
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
||||
resetTimerRef.current = setTimeout(() => {
|
||||
setAnalysisProgress({ step: 'idle', percentage: 0, message: '', runId: null });
|
||||
resetTimerRef.current = null;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// result parsing failed
|
||||
}
|
||||
}, [async.result]);
|
||||
|
||||
// Update progressive data from queue status
|
||||
useEffect(() => {
|
||||
if (async.session) {
|
||||
setProgressiveData(async.session);
|
||||
}
|
||||
}, [async.session]);
|
||||
|
||||
// Reset state when type changes from parent
|
||||
useEffect(() => {
|
||||
setTextContent('');
|
||||
setUrlInput('');
|
||||
setMediaFile(null);
|
||||
setMediaType(null);
|
||||
setContextText('');
|
||||
setShowMediaOption(false);
|
||||
setShowTextOption(false);
|
||||
setAnalysisResult(null);
|
||||
setProgressiveData(null);
|
||||
async.reset();
|
||||
}, [selectedType]);
|
||||
|
||||
const handleMediaAdd = (type: MediaType) => {
|
||||
setMediaType(type);
|
||||
setShowMediaOption(true);
|
||||
};
|
||||
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const mediaCategory = mediaType || selectedType;
|
||||
const validation = validateFile(file, mediaCategory);
|
||||
if (!validation.valid) {
|
||||
setFileError(validation.error);
|
||||
return;
|
||||
}
|
||||
setFileError(null);
|
||||
setMediaFile(file);
|
||||
};
|
||||
|
||||
const handleAnalyze = () => {
|
||||
setAnalysisResult(null);
|
||||
setProgressiveData(null);
|
||||
|
||||
if (selectedType === 'text') {
|
||||
async.submitText(textContent.trim());
|
||||
} else if (selectedType === 'url') {
|
||||
async.submitUrl(urlInput.trim());
|
||||
} else if (mediaFile) {
|
||||
async.submitMedia(mediaFile, selectedType, contextText || undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setTextContent('');
|
||||
setUrlInput('');
|
||||
setMediaFile(null);
|
||||
setMediaType(null);
|
||||
setContextText('');
|
||||
setShowMediaOption(false);
|
||||
setShowTextOption(false);
|
||||
};
|
||||
|
||||
const textValidation = useMemo(() => validateText(textContent), [textContent]);
|
||||
const urlValidation = useMemo(() => validateUrl(urlInput), [urlInput]);
|
||||
|
||||
// Consimțământ explicit pentru procesarea fișierelor media (persistat în localStorage)
|
||||
const [mediaConsent, setMediaConsent] = useMediaConsent();
|
||||
const needsMediaConsent = (selectedType !== 'text' && selectedType !== 'url') || mediaFile !== null;
|
||||
|
||||
const canAnalyze = () => {
|
||||
if (selectedType === 'text') {
|
||||
return textValidation.valid;
|
||||
} else if (selectedType === 'url') {
|
||||
return urlValidation.valid;
|
||||
} else {
|
||||
return mediaFile !== null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* Main Content - Full Width */}
|
||||
<MainContent>
|
||||
<Header>
|
||||
<Title>Content Analysis</Title>
|
||||
<Subtitle>Select content type and add optional context</Subtitle>
|
||||
</Header>
|
||||
|
||||
{/* Limit Warning Banner - Commented out temporarily for debugging */}
|
||||
{/* <LimitWarningBanner action="run_pipeline" /> */}
|
||||
|
||||
{/* Content Input Area */}
|
||||
<ContentArea>
|
||||
{selectedType === 'url' ? (
|
||||
<>
|
||||
{/* URL Input */}
|
||||
<URLInputSection>
|
||||
<URLInputLabel>
|
||||
🔗 Article URL
|
||||
<HelpText>The system will scrape and analyze the web article automatically</HelpText>
|
||||
</URLInputLabel>
|
||||
<URLInput
|
||||
type="url"
|
||||
placeholder="https://www.example.com/article..."
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
/>
|
||||
{urlInput.trim() && urlValidation.error && (
|
||||
<HelpText style={{ color: '#ef4444' }}>{urlValidation.error}</HelpText>
|
||||
)}
|
||||
{urlInput.trim() && urlValidation.valid && (
|
||||
<HelpText style={{ color: '#22c55e' }}>Valid URL</HelpText>
|
||||
)}
|
||||
</URLInputSection>
|
||||
</>
|
||||
) : selectedType === 'text' ? (
|
||||
<>
|
||||
{/* Text Input */}
|
||||
<TextArea
|
||||
placeholder="Enter text to analyze..."
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
/>
|
||||
{textContent.trim().length > 0 && (
|
||||
<HelpText style={{ color: textValidation.level === 'error' ? '#ef4444' : textValidation.level === 'warning' ? '#eab308' : '#22c55e' }}>
|
||||
{textValidation.error || textValidation.warning || `${textContent.trim().length} characters`}
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
{/* Media Upload when added */}
|
||||
{showMediaOption && mediaType && (
|
||||
<MediaUploadSection>
|
||||
<MediaUploadLabel>
|
||||
{mediaType === 'image' && '🖼️ Image'}
|
||||
{mediaType === 'video' && '🎬 Video'}
|
||||
{mediaType === 'audio' && '🎵 Audio'}
|
||||
<RemoveMediaButton onClick={() => {
|
||||
setShowMediaOption(false);
|
||||
setMediaType(null);
|
||||
setMediaFile(null);
|
||||
}}>
|
||||
✕
|
||||
</RemoveMediaButton>
|
||||
</MediaUploadLabel>
|
||||
|
||||
{mediaFile ? (
|
||||
<FilePreview>
|
||||
<FileInfo>
|
||||
<FileName>{mediaFile.name}</FileName>
|
||||
<FileSize>{(mediaFile.size / 1024 / 1024).toFixed(2)} MB</FileSize>
|
||||
</FileInfo>
|
||||
<ChangeFileLabel htmlFor={`file-change-${mediaType}`}>
|
||||
Change
|
||||
</ChangeFileLabel>
|
||||
</FilePreview>
|
||||
) : (
|
||||
<UploadArea>
|
||||
<UploadIcon>
|
||||
{mediaType === 'image' && '🖼️'}
|
||||
{mediaType === 'video' && '🎬'}
|
||||
{mediaType === 'audio' && '🎵'}
|
||||
</UploadIcon>
|
||||
<UploadText>
|
||||
Drop {mediaType} here or{' '}
|
||||
<UploadLabel htmlFor={`file-input-${mediaType}`}>
|
||||
browse
|
||||
</UploadLabel>
|
||||
</UploadText>
|
||||
</UploadArea>
|
||||
)}
|
||||
|
||||
<HiddenFileInput
|
||||
id={`file-input-${mediaType}`}
|
||||
type="file"
|
||||
accept={
|
||||
mediaType === 'image' ? 'image/*' :
|
||||
mediaType === 'video' ? 'video/*' :
|
||||
'.mp3,.wav,.ogg,.m4a,.aac,.flac,.wma'
|
||||
}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<HiddenFileInput
|
||||
id={`file-change-${mediaType}`}
|
||||
type="file"
|
||||
accept={
|
||||
mediaType === 'image' ? 'image/*' :
|
||||
mediaType === 'video' ? 'video/*' :
|
||||
'.mp3,.wav,.ogg,.m4a,.aac,.flac,.wma'
|
||||
}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{fileError && <FileErrorMsg>{fileError}</FileErrorMsg>}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<ConsentNotice consented={mediaConsent} onChange={setMediaConsent} />
|
||||
</div>
|
||||
</MediaUploadSection>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Media Upload (Primary) */}
|
||||
<MediaUploadSection>
|
||||
{mediaFile ? (
|
||||
<FilePreviewLarge>
|
||||
<FileIconLarge>
|
||||
{selectedType === 'image' && '🖼️'}
|
||||
{selectedType === 'video' && '🎬'}
|
||||
{selectedType === 'audio' && '🎵'}
|
||||
</FileIconLarge>
|
||||
<FileInfo>
|
||||
<FileName>{mediaFile.name}</FileName>
|
||||
<FileSize>{(mediaFile.size / 1024 / 1024).toFixed(2)} MB</FileSize>
|
||||
</FileInfo>
|
||||
<ChangeFileLabel htmlFor="primary-file-change">
|
||||
Change File
|
||||
</ChangeFileLabel>
|
||||
</FilePreviewLarge>
|
||||
) : (
|
||||
<UploadAreaLarge>
|
||||
<UploadIconLarge>
|
||||
{selectedType === 'image' && '🖼️'}
|
||||
{selectedType === 'video' && '🎬'}
|
||||
{selectedType === 'audio' && '🎵'}
|
||||
</UploadIconLarge>
|
||||
<UploadTextLarge>
|
||||
Drop {selectedType} here or{' '}
|
||||
<UploadLabel htmlFor="primary-file-input">
|
||||
browse files
|
||||
</UploadLabel>
|
||||
</UploadTextLarge>
|
||||
<UploadHint>
|
||||
{selectedType === 'image' && 'Supports: JPG, PNG, GIF, WebP — max 20 MB'}
|
||||
{selectedType === 'video' && 'Supports: MP4, WebM, MOV — max 100 MB'}
|
||||
{selectedType === 'audio' && 'Supports: MP3, WAV, OGG, M4A — max 50 MB'}
|
||||
</UploadHint>
|
||||
</UploadAreaLarge>
|
||||
)}
|
||||
|
||||
<HiddenFileInput
|
||||
id="primary-file-input"
|
||||
type="file"
|
||||
accept={
|
||||
selectedType === 'image' ? 'image/*' :
|
||||
selectedType === 'video' ? 'video/*' :
|
||||
'.mp3,.wav,.ogg,.m4a,.aac,.flac,.wma'
|
||||
}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<HiddenFileInput
|
||||
id="primary-file-change"
|
||||
type="file"
|
||||
accept={
|
||||
selectedType === 'image' ? 'image/*' :
|
||||
selectedType === 'video' ? 'video/*' :
|
||||
'.mp3,.wav,.ogg,.m4a,.aac,.flac,.wma'
|
||||
}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<ConsentNotice consented={mediaConsent} onChange={setMediaConsent} />
|
||||
</div>
|
||||
</MediaUploadSection>
|
||||
{fileError && <FileErrorMsg>{fileError}</FileErrorMsg>}
|
||||
|
||||
{/* Context Text Area - Always visible with optional label */}
|
||||
<ContextTextSection>
|
||||
<ContextTextLabel>
|
||||
Context Text <OptionalBadge>optional</OptionalBadge>
|
||||
{showTextOption && contextText && (
|
||||
<RemoveTextButton onClick={() => {
|
||||
setShowTextOption(false);
|
||||
setContextText('');
|
||||
}}>
|
||||
✕
|
||||
</RemoveTextButton>
|
||||
)}
|
||||
</ContextTextLabel>
|
||||
<TextArea
|
||||
placeholder="Add description or context..."
|
||||
value={contextText}
|
||||
onChange={(e) => {
|
||||
setContextText(e.target.value);
|
||||
if (e.target.value && !showTextOption) {
|
||||
setShowTextOption(true);
|
||||
}
|
||||
}}
|
||||
rows={4}
|
||||
/>
|
||||
</ContextTextSection>
|
||||
</>
|
||||
)}
|
||||
</ContentArea>
|
||||
|
||||
{/* Progress Tracking */}
|
||||
{analysisProgress.step !== 'idle' && (
|
||||
<ProgressContainer>
|
||||
<ProgressTitle>
|
||||
{analysisProgress.step === 'complete' ? 'Analysis Complete!' :
|
||||
analysisProgress.step === 'error' ? 'Analysis Failed' :
|
||||
'Analyzing your content...'}
|
||||
</ProgressTitle>
|
||||
|
||||
<ProgressSteps>
|
||||
<ProgressStep status={
|
||||
analysisProgress.step === 'submitting' ? 'active' :
|
||||
['processing', 'analyzing', 'complete'].includes(analysisProgress.step) ? 'complete' :
|
||||
analysisProgress.step === 'error' ? 'error' : 'pending'
|
||||
}>
|
||||
<StepIcon status={
|
||||
analysisProgress.step === 'submitting' ? 'active' :
|
||||
['processing', 'analyzing', 'complete'].includes(analysisProgress.step) ? 'complete' :
|
||||
analysisProgress.step === 'error' ? 'error' : 'pending'
|
||||
}>
|
||||
{['processing', 'analyzing', 'complete'].includes(analysisProgress.step) ? '✓' : '1'}
|
||||
</StepIcon>
|
||||
Submitted
|
||||
</ProgressStep>
|
||||
|
||||
<StepConnector active={['processing', 'analyzing', 'complete'].includes(analysisProgress.step)} />
|
||||
|
||||
<ProgressStep status={
|
||||
analysisProgress.step === 'processing' ? 'active' :
|
||||
['analyzing', 'complete'].includes(analysisProgress.step) ? 'complete' :
|
||||
analysisProgress.step === 'error' && analysisProgress.percentage > 10 ? 'error' : 'pending'
|
||||
}>
|
||||
<StepIcon status={
|
||||
analysisProgress.step === 'processing' ? 'active' :
|
||||
['analyzing', 'complete'].includes(analysisProgress.step) ? 'complete' :
|
||||
analysisProgress.step === 'error' && analysisProgress.percentage > 10 ? 'error' : 'pending'
|
||||
}>
|
||||
{['analyzing', 'complete'].includes(analysisProgress.step) ? '✓' : '2'}
|
||||
</StepIcon>
|
||||
Processing
|
||||
</ProgressStep>
|
||||
|
||||
<StepConnector active={['analyzing', 'complete'].includes(analysisProgress.step)} />
|
||||
|
||||
<ProgressStep status={
|
||||
analysisProgress.step === 'analyzing' ? 'active' :
|
||||
analysisProgress.step === 'complete' ? 'complete' :
|
||||
analysisProgress.step === 'error' && analysisProgress.percentage > 50 ? 'error' : 'pending'
|
||||
}>
|
||||
<StepIcon status={
|
||||
analysisProgress.step === 'analyzing' ? 'active' :
|
||||
analysisProgress.step === 'complete' ? 'complete' :
|
||||
analysisProgress.step === 'error' && analysisProgress.percentage > 50 ? 'error' : 'pending'
|
||||
}>
|
||||
{analysisProgress.step === 'complete' ? '✓' : '3'}
|
||||
</StepIcon>
|
||||
Analyzing
|
||||
</ProgressStep>
|
||||
|
||||
<StepConnector active={analysisProgress.step === 'complete'} />
|
||||
|
||||
<ProgressStep status={
|
||||
analysisProgress.step === 'complete' ? 'complete' :
|
||||
analysisProgress.step === 'error' ? 'error' : 'pending'
|
||||
}>
|
||||
<StepIcon status={
|
||||
analysisProgress.step === 'complete' ? 'complete' :
|
||||
analysisProgress.step === 'error' ? 'error' : 'pending'
|
||||
}>
|
||||
{analysisProgress.step === 'complete' ? '✓' : analysisProgress.step === 'error' ? '!' : '4'}
|
||||
</StepIcon>
|
||||
Done
|
||||
</ProgressStep>
|
||||
</ProgressSteps>
|
||||
|
||||
<ProgressBarContainer>
|
||||
<ProgressBarFill
|
||||
percentage={analysisProgress.percentage}
|
||||
status={analysisProgress.step}
|
||||
/>
|
||||
</ProgressBarContainer>
|
||||
|
||||
<ProgressMessage status={analysisProgress.step}>
|
||||
{analysisProgress.message}
|
||||
{analysisProgress.step !== 'error' && analysisProgress.step !== 'complete' && (
|
||||
<> - <ProgressPercentage>{analysisProgress.percentage}%</ProgressPercentage></>
|
||||
)}
|
||||
</ProgressMessage>
|
||||
</ProgressContainer>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<ActionButtons>
|
||||
<ClearButton onClick={handleClear} disabled={!canAnalyze()}>
|
||||
Clear
|
||||
</ClearButton>
|
||||
<AnalyzeButton onClick={handleAnalyze} disabled={!canAnalyze() || (needsMediaConsent && !mediaConsent) || analyzing}>
|
||||
{analyzing ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🔍 Analyze Content
|
||||
</>
|
||||
)}
|
||||
</AnalyzeButton>
|
||||
</ActionButtons>
|
||||
|
||||
{/* Progressive Results - MisinformationV2 (text pipelines ONLY) */}
|
||||
{selectedType === 'text' && progressiveData && (
|
||||
<ResultsSection>
|
||||
<ResultsHeader>
|
||||
{progressiveData.status === 'completed' ? 'Analysis Complete ✓' : 'Analysis in Progress ⏳'}
|
||||
</ResultsHeader>
|
||||
<ResultsContent>
|
||||
<MisinformationV2Results
|
||||
outputData={progressiveData.status === 'completed' ? {
|
||||
aggregator: progressiveData.verdict?.data,
|
||||
worth_analyzing_gate: progressiveData.preliminary_analysis?.data,
|
||||
worth_analyzing: progressiveData.preliminary_analysis?.data,
|
||||
claim_extraction: progressiveData.claim_extraction?.data,
|
||||
text_analysis: progressiveData.semantic_analysis?.data,
|
||||
source_credibility: progressiveData.source_credibility?.data,
|
||||
web_evidence_search: progressiveData.evidence?.data
|
||||
} : null}
|
||||
progressiveData={progressiveData}
|
||||
/>
|
||||
</ResultsContent>
|
||||
</ResultsSection>
|
||||
)}
|
||||
|
||||
{/* Analysis Results - Final (non-text pipelines, or text without progressive data) */}
|
||||
{!analyzing && analysisResult && (selectedType !== 'text' || !progressiveData) && (
|
||||
<ResultsSection>
|
||||
<ResultsHeader>Analysis Results</ResultsHeader>
|
||||
{async.warnings.length > 0 && (
|
||||
<div style={{ padding: '10px 16px', background: 'rgba(234,179,8,0.08)', border: '1px solid rgba(234,179,8,0.2)', borderRadius: '8px', color: '#eab308', fontSize: '13px', marginBottom: '12px' }}>
|
||||
{async.warnings.map((w, i) => <div key={i}>{w}</div>)}
|
||||
</div>
|
||||
)}
|
||||
{async.skipped ? (
|
||||
<SkipBox>
|
||||
<SkipText>{async.skipMessage || 'Content could not be extracted from this URL'}</SkipText>
|
||||
<SkipAction onClick={() => { async.reset(); }}>
|
||||
Upload manual
|
||||
</SkipAction>
|
||||
</SkipBox>
|
||||
) : (analysisResult.error || async.error) ? (
|
||||
<ErrorMessage>
|
||||
<ErrorIcon>⚠️</ErrorIcon>
|
||||
<ErrorText>{analysisResult.error || async.error}</ErrorText>
|
||||
</ErrorMessage>
|
||||
) : (
|
||||
<ResultsContent>
|
||||
{(() => {
|
||||
// Try parsing output_data (old format) or use result directly (new async format)
|
||||
const outputData = analysisResult.output_data || analysisResult.components || analysisResult;
|
||||
const { data, rawText, isMisinformationV2, rawOutput } = parseAnalysisResult(outputData);
|
||||
|
||||
// Misinformation Detection v2 Pipeline - Custom Component with Progressive Updates
|
||||
if (isMisinformationV2 && rawOutput) {
|
||||
return <MisinformationV2Results
|
||||
outputData={rawOutput}
|
||||
progressiveData={progressiveData}
|
||||
/>;
|
||||
}
|
||||
|
||||
// Standard pipelines (Universal Analyzer v3, etc.)
|
||||
if (data?.verdict) {
|
||||
return <AnalysisResults data={data as any} />;
|
||||
}
|
||||
|
||||
// New async format: verdict at top level
|
||||
if (analysisResult.verdict) {
|
||||
return <AnalysisResults data={analysisResult} />;
|
||||
}
|
||||
|
||||
if (rawText) {
|
||||
return (
|
||||
<ResultItem>
|
||||
<ResultLabel>Response:</ResultLabel>
|
||||
<ResultValue>{rawText}</ResultValue>
|
||||
</ResultItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to raw output
|
||||
return (
|
||||
<ResultItem>
|
||||
<ResultLabel>Output:</ResultLabel>
|
||||
<ResultValuePre>
|
||||
{JSON.stringify(outputData, null, 2)}
|
||||
</ResultValuePre>
|
||||
</ResultItem>
|
||||
);
|
||||
})()}
|
||||
</ResultsContent>
|
||||
)}
|
||||
</ResultsSection>
|
||||
)}
|
||||
|
||||
{/* Analysis History */}
|
||||
{analysisHistory.length > 0 && (
|
||||
<HistorySection>
|
||||
<HistoryHeader>
|
||||
<HistoryTitle>Analysis History</HistoryTitle>
|
||||
<HistoryCount>{analysisHistory.length} {analysisHistory.length === 1 ? 'analysis' : 'analyses'}</HistoryCount>
|
||||
</HistoryHeader>
|
||||
<HistoryList>
|
||||
{analysisHistory.map((item, index) => (
|
||||
<HistoryItem key={item.id} onClick={() => setAnalysisResult(item.result)}>
|
||||
<HistoryItemHeader>
|
||||
<HistoryItemIndex>#{index + 1}</HistoryItemIndex>
|
||||
<HistoryItemTime>
|
||||
{new Date(item.timestamp).toLocaleString()}
|
||||
</HistoryItemTime>
|
||||
</HistoryItemHeader>
|
||||
<HistoryItemInput>{item.input.substring(0, 100)}{item.input.length > 100 ? '...' : ''}</HistoryItemInput>
|
||||
<HistoryItemStatus status={item.result.status}>
|
||||
{item.result.status}
|
||||
</HistoryItemStatus>
|
||||
</HistoryItem>
|
||||
))}
|
||||
</HistoryList>
|
||||
</HistorySection>
|
||||
)}
|
||||
</MainContent>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// Skip UI styled components
|
||||
const SkipBox = styled.div`
|
||||
display: flex; flex-direction: column; gap: 10px; padding: 16px 20px;
|
||||
background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.25);
|
||||
border-radius: 10px; margin-bottom: 16px;
|
||||
[data-theme="light"] & { background: #fffbeb; border-color: #fde68a; }
|
||||
`;
|
||||
const SkipText = styled.div`
|
||||
color: #eab308; font-size: 14px; line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
[data-theme="light"] & { color: #b45309; }
|
||||
`;
|
||||
const SkipAction = styled.button`
|
||||
align-self: flex-start; padding: 8px 16px; background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border); border-radius: 8px; color: var(--accent-text);
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;
|
||||
&:hover { background: var(--accent-subtle); border-color: var(--accent); }
|
||||
`;
|
||||
const FileErrorMsg = styled.div`
|
||||
font-size: 13px; color: #ef4444; margin-top: 8px; text-align: center;
|
||||
`;
|
||||
978
web/src/components/AnalysisTab/styles/analysisTab.styles.ts
Normal file
978
web/src/components/AnalysisTab/styles/analysisTab.styles.ts
Normal file
|
|
@ -0,0 +1,978 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { colors, typography, spacing } from '../../../theme';
|
||||
|
||||
export const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
export const MainContent = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const Header = styled.div`
|
||||
margin-bottom: ${spacing['2xl']}px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const Title = styled.h2`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize['3xl']};
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
`;
|
||||
|
||||
export const Subtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
color: var(--fg-primary);
|
||||
`;
|
||||
|
||||
export const TypeSelectorContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing.sm}px;
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
perspective: 1000px;
|
||||
padding-top: 80px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding-top: 0;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TypeButton = styled.button<{ active: boolean; disabled?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: ${spacing.md}px;
|
||||
padding: ${spacing.md}px ${spacing.lg}px;
|
||||
background: ${props => props.disabled
|
||||
? 'var(--bg-surface)'
|
||||
: props.active
|
||||
? 'var(--accent-subtle)'
|
||||
: 'var(--bg-surface)'};
|
||||
border: 2px solid ${props => props.disabled
|
||||
? 'var(--accent-border)'
|
||||
: props.active
|
||||
? 'var(--accent)'
|
||||
: 'var(--accent-border)'};
|
||||
border-radius: 12px;
|
||||
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
|
||||
opacity: ${props => props.disabled ? 0.4 : 1};
|
||||
pointer-events: ${props => props.disabled ? 'none' : 'auto'};
|
||||
position: relative;
|
||||
z-index: ${props => props.active ? 10 : 1};
|
||||
transform-style: preserve-3d;
|
||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
box-shadow: ${props => props.active
|
||||
? 'var(--shadow-md)'
|
||||
: 'var(--shadow-sm)'};
|
||||
transform: ${props => props.active
|
||||
? 'translateX(10px) scale(1.02)'
|
||||
: 'translateX(0) scale(1)'};
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-subtle);
|
||||
opacity: ${props => props.active ? 1 : 0};
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${props => props.active
|
||||
? 'var(--accent-subtle)'
|
||||
: 'var(--bg-hover)'};
|
||||
border-color: var(--accent);
|
||||
transform: translateX(15px) scale(1.03);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateX(8px) scale(0.98);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
flex-direction: column;
|
||||
padding: ${spacing.lg}px;
|
||||
transform: ${props => props.active ? 'scale(1.05)' : 'scale(1)'};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: ${spacing.md}px;
|
||||
min-height: 44px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TypeIcon = styled.span`
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
font-size: 36px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TypeLabel = styled.span`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
`;
|
||||
|
||||
export const ContentArea = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 16px;
|
||||
padding: ${spacing['2xl']}px;
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
min-height: 300px;
|
||||
`;
|
||||
|
||||
export const TextArea = styled.textarea`
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
padding: ${spacing.lg}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
line-height: ${typography.lineHeight.relaxed};
|
||||
resize: vertical;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
`;
|
||||
|
||||
export const AddMediaSection = styled.div`
|
||||
margin-top: ${spacing.xl}px;
|
||||
padding-top: ${spacing.xl}px;
|
||||
border-top: 1px solid var(--accent-border);
|
||||
`;
|
||||
|
||||
export const AddMediaLabel = styled.div`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.md}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
export const MediaOptions = styled.div`
|
||||
display: flex;
|
||||
gap: ${spacing.md}px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
export const MediaOptionButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
padding: ${spacing.sm}px ${spacing.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 8px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
span {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`;
|
||||
|
||||
export const MediaUploadSection = styled.div`
|
||||
margin-top: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
export const MediaUploadLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.md}px;
|
||||
`;
|
||||
|
||||
export const RemoveMediaButton = styled.button`
|
||||
background: rgba(230, 57, 70, 0.1);
|
||||
border: 1px solid ${colors.cautionRed};
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: ${colors.cautionRed};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(230, 57, 70, 0.2);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
`;
|
||||
|
||||
export const UploadArea = styled.div`
|
||||
border: 2px dashed var(--accent-border);
|
||||
border-radius: 12px;
|
||||
padding: ${spacing.xl}px;
|
||||
text-align: center;
|
||||
background: var(--bg-surface);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
`;
|
||||
|
||||
export const UploadAreaLarge = styled(UploadArea)`
|
||||
padding: ${spacing['3xl']}px;
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const UploadIcon = styled.div`
|
||||
font-size: 32px;
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
`;
|
||||
|
||||
export const UploadIconLarge = styled(UploadIcon)`
|
||||
font-size: 64px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
export const UploadText = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-primary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
export const UploadTextLarge = styled(UploadText)`
|
||||
font-size: ${typography.fontSize.lg};
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
`;
|
||||
|
||||
export const UploadHint = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xs};
|
||||
color: var(--fg-secondary);
|
||||
margin: ${spacing.sm}px 0 0 0;
|
||||
`;
|
||||
|
||||
export const UploadLabel = styled.label`
|
||||
color: var(--fg-primary);
|
||||
cursor: pointer;
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
export const HiddenFileInput = styled.input`
|
||||
display: none;
|
||||
`;
|
||||
|
||||
export const FilePreview = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 8px;
|
||||
padding: ${spacing.md}px;
|
||||
`;
|
||||
|
||||
export const FilePreviewLarge = styled(FilePreview)`
|
||||
flex-direction: column;
|
||||
gap: ${spacing.lg}px;
|
||||
padding: ${spacing.xl}px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const FileIconLarge = styled.div`
|
||||
font-size: 80px;
|
||||
`;
|
||||
|
||||
export const FileInfo = styled.div`
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
export const FileName = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: var(--fg-primary);
|
||||
margin: 0 0 ${spacing.xs}px 0;
|
||||
`;
|
||||
|
||||
export const FileSize = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-secondary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
export const ChangeFileLabel = styled.label`
|
||||
display: inline-block;
|
||||
padding: ${spacing.sm}px ${spacing.md}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 6px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ContextTextSection = styled.div`
|
||||
margin-top: ${spacing.xl}px;
|
||||
padding-top: ${spacing.xl}px;
|
||||
border-top: 1px solid var(--accent-border);
|
||||
`;
|
||||
|
||||
export const ContextTextLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.md}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
export const OptionalBadge = styled.span`
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
margin-left: ${spacing.sm}px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 4px;
|
||||
font-size: ${typography.fontSize.xs};
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0;
|
||||
color: var(--fg-secondary);
|
||||
font-weight: ${typography.fontWeight.normal};
|
||||
`;
|
||||
|
||||
export const RemoveTextButton = styled(RemoveMediaButton)``;
|
||||
|
||||
export const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
gap: ${spacing.md}px;
|
||||
justify-content: flex-end;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
|
||||
& > button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ClearButton = styled.button`
|
||||
padding: ${spacing.md}px ${spacing.xl}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 8px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AnalyzeButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
padding: ${spacing.md}px ${spacing['2xl']}px;
|
||||
background: var(--bg-surface);
|
||||
border: 2px solid var(--accent-border);
|
||||
border-radius: 8px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Spinner = styled.div`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--accent-border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const PipelineSection = styled.div`
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
padding-bottom: ${spacing.xl}px;
|
||||
border-bottom: 1px solid var(--accent-border);
|
||||
`;
|
||||
|
||||
export const PipelineLabel = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.md}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
export const PipelineSelect = styled.select`
|
||||
width: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
padding: ${spacing.lg}px ${spacing.xl}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23009198' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right ${spacing.lg}px center;
|
||||
background-size: 20px;
|
||||
padding-right: ${spacing['3xl']}px;
|
||||
|
||||
/* Prevent text overflow */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background-color: var(--accent-subtle);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
option {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg-primary);
|
||||
padding: ${spacing.md}px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
|
||||
/* Better text wrapping in dropdown */
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LoadingSpinner = styled.div`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--accent-border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
`;
|
||||
|
||||
export const ResultsSection = styled.div`
|
||||
margin-top: ${spacing['2xl']}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 16px;
|
||||
padding: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
export const ResultsHeader = styled.h3`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xl};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin: 0 0 ${spacing.lg}px 0;
|
||||
`;
|
||||
|
||||
export const ResultsContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
export const ResultItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing.sm}px;
|
||||
`;
|
||||
|
||||
export const ResultLabel = styled.div`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: var(--fg-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
export const ResultValue = styled.div`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
color: var(--fg-primary);
|
||||
padding: ${spacing.md}px;
|
||||
background: var(--accent-subtle);
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
export const ResultValuePre = styled.pre`
|
||||
font-family: ${typography.fontFamily.code || 'monospace'};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-primary);
|
||||
padding: ${spacing.lg}px;
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
export const ErrorMessage = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.md}px;
|
||||
padding: ${spacing.lg}px;
|
||||
background: rgba(230, 57, 70, 0.1);
|
||||
border: 1px solid ${colors.cautionRed};
|
||||
border-radius: 8px;
|
||||
|
||||
[data-theme="light"] & {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border-color: rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ErrorIcon = styled.div`
|
||||
font-size: 24px;
|
||||
`;
|
||||
|
||||
export const ErrorText = styled.div`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
color: ${colors.cautionRed};
|
||||
flex: 1;
|
||||
|
||||
[data-theme="light"] & {
|
||||
color: #dc2626;
|
||||
}
|
||||
`;
|
||||
|
||||
export const HistorySection = styled.div`
|
||||
margin-top: ${spacing['3xl']}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 16px;
|
||||
padding: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
export const HistoryHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
padding-bottom: ${spacing.md}px;
|
||||
border-bottom: 1px solid var(--accent-border);
|
||||
`;
|
||||
|
||||
export const HistoryTitle = styled.h3`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xl};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
export const HistoryCount = styled.span`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-primary);
|
||||
padding: ${spacing.xs}px ${spacing.md}px;
|
||||
background: var(--accent-subtle);
|
||||
border-radius: 12px;
|
||||
`;
|
||||
|
||||
export const HistoryList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing.md}px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding-right: ${spacing.sm}px;
|
||||
|
||||
/* Custom scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--accent-subtle);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-border);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const HistoryItem = styled.div`
|
||||
padding: ${spacing.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
`;
|
||||
|
||||
export const HistoryItemHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
`;
|
||||
|
||||
export const HistoryItemIndex = styled.span`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
`;
|
||||
|
||||
export const HistoryItemTime = styled.span`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xs};
|
||||
color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
export const HistoryItemInput = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-primary);
|
||||
margin: ${spacing.sm}px 0;
|
||||
line-height: ${typography.lineHeight.relaxed};
|
||||
`;
|
||||
|
||||
export const HistoryItemStatus = styled.span<{ status: string }>`
|
||||
display: inline-block;
|
||||
padding: ${spacing.xs}px ${spacing.sm}px;
|
||||
background: ${props => props.status === 'completed'
|
||||
? 'var(--accent-subtle)'
|
||||
: 'rgba(230, 57, 70, 0.1)'};
|
||||
color: ${props => props.status === 'completed'
|
||||
? 'var(--fg-primary)'
|
||||
: colors.cautionRed};
|
||||
border-radius: 6px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xs};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
text-transform: uppercase;
|
||||
|
||||
[data-theme="light"] & {
|
||||
background: ${props => props.status === 'completed'
|
||||
? 'var(--accent-subtle)'
|
||||
: 'rgba(220, 38, 38, 0.08)'};
|
||||
color: ${props => props.status === 'completed'
|
||||
? 'var(--fg-primary)'
|
||||
: '#dc2626'};
|
||||
}
|
||||
`;
|
||||
|
||||
// URL Input Components
|
||||
export const URLInputSection = styled.div`
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
export const URLInputLabel = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${spacing.xs}px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.md}px;
|
||||
`;
|
||||
|
||||
export const HelpText = styled.span`
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.normal};
|
||||
color: var(--fg-secondary);
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
export const URLInput = styled.input`
|
||||
width: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
padding: ${spacing.lg}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
`;
|
||||
|
||||
// Progress Tracking Components
|
||||
export const ProgressContainer = styled.div`
|
||||
margin: ${spacing.xl}px 0;
|
||||
padding: ${spacing.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
`;
|
||||
|
||||
export const ProgressTitle = styled.div`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const ProgressSteps = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: ${spacing.md}px;
|
||||
padding: 0 ${spacing.sm}px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ProgressStep = styled.div<{ status: 'pending' | 'active' | 'complete' | 'error' }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.xs}px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: ${props => ({
|
||||
pending: 'var(--fg-muted)',
|
||||
active: 'var(--fg-primary)',
|
||||
complete: 'var(--fg-primary)',
|
||||
error: colors.cautionRed
|
||||
})[props.status]};
|
||||
transition: color 0.3s ease;
|
||||
|
||||
${props => props.status === 'active' && `
|
||||
animation: stepPulse 1.5s ease-in-out infinite;
|
||||
`}
|
||||
|
||||
@keyframes stepPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
`;
|
||||
|
||||
export const StepIcon = styled.span<{ status: 'pending' | 'active' | 'complete' | 'error' }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
background: ${props => ({
|
||||
pending: 'var(--bg-hover)',
|
||||
active: 'var(--accent-subtle)',
|
||||
complete: 'var(--accent-subtle)',
|
||||
error: 'rgba(230, 57, 70, 0.2)'
|
||||
})[props.status]};
|
||||
border: 2px solid ${props => ({
|
||||
pending: 'var(--border-strong)',
|
||||
active: 'var(--accent)',
|
||||
complete: 'var(--accent)',
|
||||
error: colors.cautionRed
|
||||
})[props.status]};
|
||||
transition: all 0.3s ease;
|
||||
`;
|
||||
|
||||
export const StepConnector = styled.div<{ active: boolean }>`
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
margin: 0 ${spacing.sm}px;
|
||||
background: ${props => props.active
|
||||
? 'var(--accent)'
|
||||
: 'var(--border-strong)'};
|
||||
transition: background 0.3s ease;
|
||||
`;
|
||||
|
||||
export const ProgressBarContainer = styled.div`
|
||||
height: 8px;
|
||||
background: var(--accent-subtle);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
`;
|
||||
|
||||
export const ProgressBarFill = styled.div<{ percentage: number; status: string }>`
|
||||
height: 100%;
|
||||
width: ${props => props.percentage}%;
|
||||
background: ${props => props.status === 'error'
|
||||
? colors.cautionRed
|
||||
: 'var(--accent)'};
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export const ProgressMessage = styled.div<{ status?: string }>`
|
||||
text-align: center;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: ${props => props.status === 'error' ? colors.cautionRed : 'var(--fg-primary)'};
|
||||
|
||||
[data-theme="light"] & {
|
||||
color: ${props => props.status === 'error' ? '#dc2626' : 'var(--fg-primary)'};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ProgressPercentage = styled.span`
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
`;
|
||||
512
web/src/components/ClaimAnalysis/ClaimAnalysis.tsx
Normal file
512
web/src/components/ClaimAnalysis/ClaimAnalysis.tsx
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { typography, spacing } from '../../theme';
|
||||
import { useAsyncAnalysis } from '../../hooks/useAsyncAnalysis';
|
||||
import { validateText } from '../../utils/text-validation';
|
||||
import { validateUrl } from '../../utils/url-validation';
|
||||
import { validateFile } from '../../utils/file-validation';
|
||||
import { ConsentNotice, ConsentSlot, useMediaConsent } from '../ConsentNotice';
|
||||
import type { AnalysisSession, ClaimsResult } from '../../types/analysis-session';
|
||||
import { InputPreview } from '../PipelineAnalysis/sections/InputPreview';
|
||||
import { ClaimsResults } from '../PipelineAnalysis/sections/ClaimsResults';
|
||||
import { VerdictSection } from '../PipelineAnalysis/styles';
|
||||
|
||||
// =============================================================================
|
||||
// Input Types & Helpers
|
||||
// =============================================================================
|
||||
|
||||
type InputType = 'text' | 'image' | 'audio' | 'video' | 'url';
|
||||
|
||||
const INPUT_TYPES: { key: InputType; labelKey: string; accept?: string }[] = [
|
||||
{ key: 'text', labelKey: 'common.text' },
|
||||
{ key: 'image', labelKey: 'common.image', accept: 'image/jpeg,image/png,image/webp,image/gif' },
|
||||
{ key: 'audio', labelKey: 'common.audio', accept: 'audio/mpeg,audio/wav,audio/ogg,audio/mp4' },
|
||||
{ key: 'video', labelKey: 'common.video', accept: 'video/mp4,video/webm,video/ogg' },
|
||||
{ key: 'url', labelKey: 'common.url' },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const ClaimAnalysis: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
|
||||
const async = useAsyncAnalysis('claims');
|
||||
const [inputType, setInputType] = useState<InputType>('text');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [parsedResult, setParsedResult] = useState<ClaimsResult | null>(null);
|
||||
const [analyzedInput, setAnalyzedInput] = useState<{ type: InputType; text: string } | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Parse async result (AnalysisSession) into ClaimsResult when it arrives
|
||||
useEffect(() => {
|
||||
if (!async.result) return;
|
||||
try {
|
||||
const session: AnalysisSession = async.result;
|
||||
if (session.claims) {
|
||||
setParsedResult(session.claims);
|
||||
}
|
||||
if (session.input_text && session.input_type !== 'text' && session.input_type !== 'url') {
|
||||
setAnalyzedInput(prev => prev ? { ...prev, text: session.input_text! } : prev);
|
||||
}
|
||||
} catch {
|
||||
// result parsing failed
|
||||
}
|
||||
}, [async.result]);
|
||||
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const validation = validateFile(file, inputType);
|
||||
if (!validation.valid) {
|
||||
setFileError(validation.error);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setFileError(null);
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
setFileError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: InputType) => {
|
||||
setInputType(type);
|
||||
setSelectedFile(null);
|
||||
setParsedResult(null);
|
||||
setAnalyzedInput(null);
|
||||
async.reset();
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const textValidation = useMemo(() => validateText(inputText), [inputText]);
|
||||
const urlValidation = useMemo(() => validateUrl(inputUrl), [inputUrl]);
|
||||
|
||||
const [mediaConsent, setMediaConsent] = useMediaConsent();
|
||||
|
||||
const canAnalyze =
|
||||
inputType === 'text' ? textValidation.valid :
|
||||
inputType === 'url' ? urlValidation.valid :
|
||||
selectedFile !== null && mediaConsent;
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!canAnalyze) return;
|
||||
setParsedResult(null);
|
||||
|
||||
if (inputType === 'text') {
|
||||
const txt = inputText.trim();
|
||||
setAnalyzedInput({ type: inputType, text: txt });
|
||||
async.submitText(txt);
|
||||
} else if (inputType === 'url') {
|
||||
const url = inputUrl.trim();
|
||||
setAnalyzedInput({ type: inputType, text: url });
|
||||
async.submitUrl(url);
|
||||
} else if (selectedFile) {
|
||||
setAnalyzedInput({ type: inputType, text: selectedFile.name });
|
||||
async.submitMedia(selectedFile, inputType);
|
||||
}
|
||||
};
|
||||
|
||||
// Aliases for template compatibility
|
||||
const isAnalyzing = async.isAnalyzing;
|
||||
const error = async.error;
|
||||
const result = parsedResult;
|
||||
const { skipped, skipMessage } = async;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>{t('claimsComponent.title')}</Title>
|
||||
<Subtitle>{t('claimsComponent.subtitle')}</Subtitle>
|
||||
</Header>
|
||||
|
||||
{/* Input Type Selector */}
|
||||
<TypeSelector>
|
||||
{INPUT_TYPES.map(({ key, labelKey }) => (
|
||||
<TypeButton
|
||||
key={key}
|
||||
active={inputType === key}
|
||||
onClick={() => handleTypeChange(key)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</TypeButton>
|
||||
))}
|
||||
</TypeSelector>
|
||||
|
||||
{/* Input Area */}
|
||||
<InputArea>
|
||||
{inputType === 'text' ? (
|
||||
<TextInput
|
||||
placeholder={t('claimsComponent.textPlaceholder')}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={isAnalyzing}
|
||||
/>
|
||||
) : inputType === 'url' ? (
|
||||
<UrlInputWrapper>
|
||||
<UrlInput
|
||||
type="url"
|
||||
placeholder={t('claimsComponent.urlPlaceholder')}
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
disabled={isAnalyzing}
|
||||
/>
|
||||
</UrlInputWrapper>
|
||||
) : (
|
||||
<>
|
||||
<FileDropZone>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={INPUT_TYPES.find(t => t.key === inputType)?.accept}
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<FileSelected>
|
||||
<FileName>{selectedFile.name}</FileName>
|
||||
<FileSize>{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</FileSize>
|
||||
<RemoveFileBtn onClick={handleRemoveFile}>Remove</RemoveFileBtn>
|
||||
</FileSelected>
|
||||
) : (
|
||||
<DropPlaceholder onClick={() => fileInputRef.current?.click()}>
|
||||
<DropLabel>Click to select {inputType} file</DropLabel>
|
||||
<DropHint>
|
||||
{inputType === 'image' && t('pipeline.imageHint')}
|
||||
{inputType === 'audio' && t('pipeline.audioHint')}
|
||||
{inputType === 'video' && t('pipeline.videoHint')}
|
||||
</DropHint>
|
||||
</DropPlaceholder>
|
||||
)}
|
||||
{fileError && <FileErrorMsg>{fileError}</FileErrorMsg>}
|
||||
</FileDropZone>
|
||||
<ConsentSlot>
|
||||
<ConsentNotice consented={mediaConsent} onChange={setMediaConsent} />
|
||||
</ConsentSlot>
|
||||
</>
|
||||
)}
|
||||
|
||||
<InputFooter>
|
||||
<CharCount style={
|
||||
inputType === 'text'
|
||||
? { color: textValidation.level === 'error' ? '#ef4444' : textValidation.level === 'warning' ? '#eab308' : '#22c55e' }
|
||||
: inputType === 'url' && urlValidation.error
|
||||
? { color: '#ef4444' }
|
||||
: inputType === 'url' && urlValidation.valid
|
||||
? { color: '#22c55e' }
|
||||
: undefined
|
||||
}>
|
||||
{inputType === 'text'
|
||||
? (textValidation.error || textValidation.warning || `${inputText.trim().length} characters`)
|
||||
: inputType === 'url'
|
||||
? (urlValidation.error || (inputUrl.trim() ? t('common.validUrl') : t('common.enterUrl')))
|
||||
: selectedFile ? t('common.readyToAnalyze') : t('common.noFileSelected')}
|
||||
</CharCount>
|
||||
<AnalyzeBtn onClick={handleAnalyze} disabled={isAnalyzing || !canAnalyze}>
|
||||
{isAnalyzing ? (
|
||||
<><Spinner />{async.statusText || t('common.analyzing')}</>
|
||||
) : t('claimsComponent.verifyClaims')}
|
||||
</AnalyzeBtn>
|
||||
</InputFooter>
|
||||
</InputArea>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<ErrorBox><ErrorIcon>!</ErrorIcon>{error}</ErrorBox>
|
||||
)}
|
||||
|
||||
{/* Skipped */}
|
||||
{skipped && skipMessage && (
|
||||
<SkipBox>
|
||||
<SkipText>{skipMessage}</SkipText>
|
||||
<SkipAction onClick={() => { async.reset(); handleTypeChange('image'); }}>
|
||||
{t('common.uploadManual')}
|
||||
</SkipAction>
|
||||
</SkipBox>
|
||||
)}
|
||||
|
||||
{/* Backend Warnings */}
|
||||
{async.warnings.length > 0 && (
|
||||
<WarningBox>
|
||||
{async.warnings.map((w, i) => <div key={i}>{w}</div>)}
|
||||
</WarningBox>
|
||||
)}
|
||||
|
||||
{/* Input Preview (shown once result lands) */}
|
||||
{result && analyzedInput && (
|
||||
<InputPreview inputType={analyzedInput.type} text={analyzedInput.text} />
|
||||
)}
|
||||
|
||||
{/* Results — editorial layout */}
|
||||
{result && (
|
||||
<VerdictSection>
|
||||
<ClaimsResults result={result} isRo={isRo} />
|
||||
</VerdictSection>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Styled Components — input form & banners (results use shared editorial)
|
||||
// =============================================================================
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
@media (max-width: 768px) { padding: 0; }
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: clamp(1.375rem, 1.1rem + 1vw, 1.75rem);
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin: 0 0 ${spacing.xs}px 0;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
/* Type Selector */
|
||||
const TypeSelector = styled.div`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 12px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
`;
|
||||
|
||||
const TypeButton = styled.button<{ active?: boolean }>`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${props => props.active ? typography.fontWeight.semibold : typography.fontWeight.medium};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: ${props => props.active ? 'var(--accent-subtle)' : 'transparent'};
|
||||
color: ${props => props.active ? 'var(--accent-text)' : 'var(--fg-secondary)'};
|
||||
${props => props.active && `box-shadow: 0 0 0 1px var(--accent-border);`}
|
||||
&:hover { background: ${props => props.active ? 'var(--accent-subtle)' : 'var(--bg-hover)'}; }
|
||||
`;
|
||||
|
||||
/* Input Area */
|
||||
const InputArea = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
transition: border-color 0.2s;
|
||||
&:focus-within { border-color: var(--border-focus); }
|
||||
`;
|
||||
|
||||
const TextInput = styled.textarea`
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
padding: ${spacing.lg}px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 15px;
|
||||
color: var(--fg-primary);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
&:focus { outline: none; }
|
||||
&::placeholder { color: var(--fg-subtle); }
|
||||
`;
|
||||
|
||||
const UrlInputWrapper = styled.div`
|
||||
padding: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
const UrlInput = styled.input`
|
||||
width: 100%;
|
||||
padding: 14px ${spacing.lg}px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 15px;
|
||||
color: var(--fg-primary);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
&:focus { outline: none; border-color: var(--border-focus); }
|
||||
&::placeholder { color: var(--fg-subtle); }
|
||||
`;
|
||||
|
||||
const FileDropZone = styled.div`
|
||||
padding: ${spacing.lg}px;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const DropPlaceholder = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 40px;
|
||||
border: 2px dashed var(--accent-border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
&:hover { border-color: var(--accent); background: var(--accent-subtle); }
|
||||
`;
|
||||
|
||||
const DropLabel = styled.span`
|
||||
font-size: 14px; font-weight: 500; color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
const DropHint = styled.span`
|
||||
font-size: 12px; color: var(--fg-subtle);
|
||||
`;
|
||||
|
||||
const FileErrorMsg = styled.div`
|
||||
font-size: 13px; color: #ef4444; margin-top: 8px; text-align: center;
|
||||
`;
|
||||
|
||||
const FileSelected = styled.div`
|
||||
display: flex; align-items: center; gap: ${spacing.md}px;
|
||||
padding: 14px 20px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 10px; width: 100%;
|
||||
`;
|
||||
|
||||
const FileName = styled.span`
|
||||
font-size: 14px; font-weight: 500; color: var(--fg-primary);
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
`;
|
||||
|
||||
const FileSize = styled.span`
|
||||
font-size: 12px; color: var(--fg-muted); flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const RemoveFileBtn = styled.button`
|
||||
padding: 4px 10px; border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 6px;
|
||||
background: transparent; color: #f87171; font-size: 11px; font-weight: 600;
|
||||
cursor: pointer; flex-shrink: 0; transition: all 0.15s;
|
||||
&:hover { background: rgba(239, 68, 68, 0.1); }
|
||||
[data-theme="light"] & {
|
||||
border-color: rgba(220, 38, 38, 0.2); color: #dc2626;
|
||||
&:hover { background: rgba(220, 38, 38, 0.05); }
|
||||
}
|
||||
`;
|
||||
|
||||
const InputFooter = styled.div`
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: ${spacing.sm}px ${spacing.lg}px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: ${spacing.md}px ${spacing.lg}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CharCount = styled.span`
|
||||
font-size: 12px; color: var(--fg-subtle);
|
||||
`;
|
||||
|
||||
const spin = keyframes`from { transform: rotate(0deg); } to { transform: rotate(360deg); }`;
|
||||
|
||||
const Spinner = styled.span`
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-top-color: white;
|
||||
border-radius: 50%; animation: ${spin} 0.7s linear infinite;
|
||||
`;
|
||||
|
||||
const AnalyzeBtn = styled.button<{ disabled?: boolean }>`
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 24px;
|
||||
background: ${props => props.disabled ? 'var(--accent-subtle)' : 'var(--accent)'};
|
||||
color: ${props => props.disabled ? 'var(--fg-disabled)' : 'var(--fg-on-accent)'};
|
||||
border: none; border-radius: 8px;
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: all 0.2s;
|
||||
&:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
/* Error */
|
||||
const ErrorBox = styled.div`
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px ${spacing.lg}px;
|
||||
background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 10px; color: #f87171; font-size: ${typography.fontSize.sm};
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
[data-theme="light"] & { background: #fef2f2; border-color: #fecaca; color: #dc2626; }
|
||||
`;
|
||||
|
||||
const ErrorIcon = styled.span`
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: rgba(239, 68, 68, 0.2); font-size: 11px; font-weight: 700; flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const SkipBox = styled.div`
|
||||
display: flex; flex-direction: column; gap: 10px; padding: 16px ${spacing.lg}px;
|
||||
background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.25);
|
||||
border-radius: 10px; margin-bottom: ${spacing.lg}px;
|
||||
[data-theme="light"] & { background: #fffbeb; border-color: #fde68a; }
|
||||
`;
|
||||
const SkipText = styled.div`
|
||||
color: #eab308; font-size: ${typography.fontSize.sm}; line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
[data-theme="light"] & { color: #b45309; }
|
||||
`;
|
||||
const SkipAction = styled.button`
|
||||
align-self: flex-start; padding: 8px 16px; background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border); border-radius: 8px; color: var(--accent-text);
|
||||
font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
&:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
`;
|
||||
|
||||
const WarningBox = styled.div`
|
||||
padding: 12px ${spacing.lg}px;
|
||||
background: rgba(234, 179, 8, 0.08); border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
border-radius: 10px; color: #eab308; font-size: ${typography.fontSize.sm};
|
||||
margin-bottom: ${spacing.lg}px; display: flex; flex-direction: column; gap: 4px;
|
||||
[data-theme="light"] & { background: #fefce8; border-color: #fde68a; color: #a16207; }
|
||||
`;
|
||||
1
web/src/components/ClaimAnalysis/index.ts
Normal file
1
web/src/components/ClaimAnalysis/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ClaimAnalysis } from './ClaimAnalysis';
|
||||
147
web/src/components/ConsentNotice/ConsentNotice.tsx
Normal file
147
web/src/components/ConsentNotice/ConsentNotice.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Consimțământ explicit pentru procesarea fișierelor media (cerință caiet de sarcini).
|
||||
// Înainte de prima trimitere a unui fișier (imagine/audio/video), utilizatorul
|
||||
// trebuie să bifeze acordul. Acordul se persistă în localStorage, iar după bifare
|
||||
// rămâne o menționare discretă permanentă la upload.
|
||||
import React, { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { typography, spacing } from '../../theme';
|
||||
|
||||
interface ConsentNoticeProps {
|
||||
consented: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const ConsentNotice: React.FC<ConsentNoticeProps> = ({ consented, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [privacyOpen, setPrivacyOpen] = useState(false);
|
||||
|
||||
const privacyLink = (
|
||||
<PrivacyLinkBtn type="button" onClick={() => setPrivacyOpen(true)}>
|
||||
{t('consent.privacyLink')}
|
||||
</PrivacyLinkBtn>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{consented ? (
|
||||
/* Menționare discretă permanentă după acordarea consimțământului */
|
||||
<ConsentHint>
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
<span>
|
||||
{t('consent.note')} {privacyLink}
|
||||
</span>
|
||||
</ConsentHint>
|
||||
) : (
|
||||
<ConsentBox>
|
||||
<ConsentLabel>
|
||||
<ConsentCheckbox
|
||||
type="checkbox"
|
||||
checked={consented}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('consent.checkboxLabel')} {privacyLink}
|
||||
</span>
|
||||
</ConsentLabel>
|
||||
</ConsentBox>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={privacyOpen}
|
||||
onClose={() => setPrivacyOpen(false)}
|
||||
title={t('consent.privacyTitle')}
|
||||
maxWidth="640px"
|
||||
>
|
||||
<PrivacyText>
|
||||
<p>{t('consent.privacyBody1')}</p>
|
||||
<p>{t('consent.privacyBody2')}</p>
|
||||
<p>{t('consent.privacyBody3')}</p>
|
||||
</PrivacyText>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsentNotice;
|
||||
|
||||
// =============================================================================
|
||||
// Styled Components
|
||||
// =============================================================================
|
||||
|
||||
/** Wrapper de poziționare pentru integrarea în InputArea (sub zona de upload). */
|
||||
export const ConsentSlot = styled.div`
|
||||
padding: 0 ${spacing.lg}px ${spacing.md}px;
|
||||
`;
|
||||
|
||||
const ConsentBox = styled.div`
|
||||
padding: 12px 14px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 10px;
|
||||
`;
|
||||
|
||||
const ConsentLabel = styled.label`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
const ConsentCheckbox = styled.input`
|
||||
margin: 2px 0 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const ConsentHint = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 0.75rem;
|
||||
color: var(--fg-muted);
|
||||
|
||||
& svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
`;
|
||||
|
||||
const PrivacyLinkBtn = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: var(--accent-text);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const PrivacyText = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--fg-secondary);
|
||||
|
||||
& p {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
2
web/src/components/ConsentNotice/index.ts
Normal file
2
web/src/components/ConsentNotice/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { ConsentNotice, ConsentSlot } from './ConsentNotice';
|
||||
export { useMediaConsent, hasMediaConsent } from './useMediaConsent';
|
||||
34
web/src/components/ConsentNotice/useMediaConsent.ts
Normal file
34
web/src/components/ConsentNotice/useMediaConsent.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Stare persistată (localStorage) pentru consimțământul de procesare a
|
||||
// fișierelor media — separat de componentă pentru compatibilitate react-refresh.
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'didi_media_upload_consent';
|
||||
|
||||
/** Read the persisted consent flag (safe against blocked storage). */
|
||||
export const hasMediaConsent = (): boolean => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook: media-upload consent state, persisted in localStorage so the user
|
||||
* is not asked again on every submission.
|
||||
*/
|
||||
export const useMediaConsent = (): [boolean, (value: boolean) => void] => {
|
||||
const [consented, setConsented] = useState<boolean>(hasMediaConsent);
|
||||
|
||||
const update = useCallback((value: boolean) => {
|
||||
setConsented(value);
|
||||
try {
|
||||
if (value) localStorage.setItem(STORAGE_KEY, 'true');
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// localStorage indisponibil — consimțământul rămâne doar în sesiunea curentă
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [consented, update];
|
||||
};
|
||||
364
web/src/components/DomainAnalysis/DomainAnalysis.tsx
Normal file
364
web/src/components/DomainAnalysis/DomainAnalysis.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { typography, spacing } from '../../theme';
|
||||
import { useAsyncAnalysis } from '../../hooks/useAsyncAnalysis';
|
||||
import { validateText } from '../../utils/text-validation';
|
||||
import { validateUrl } from '../../utils/url-validation';
|
||||
import { validateFile } from '../../utils/file-validation';
|
||||
import type { AnalysisSession, SourceAssessmentResult } from '../../types/analysis-session';
|
||||
import { InputPreview } from '../PipelineAnalysis/sections/InputPreview';
|
||||
import { SourceResults } from '../PipelineAnalysis/sections/SourceResults';
|
||||
import { VerdictSection } from '../PipelineAnalysis/styles';
|
||||
|
||||
// =============================================================================
|
||||
// Input Types
|
||||
// =============================================================================
|
||||
|
||||
type InputType = 'text' | 'image' | 'audio' | 'video' | 'url';
|
||||
|
||||
const INPUT_TYPES: { key: InputType; labelKey: string; enabled: boolean; accept?: string }[] = [
|
||||
{ key: 'text', labelKey: 'common.text', enabled: true },
|
||||
{ key: 'image', labelKey: 'common.image', enabled: true, accept: 'image/jpeg,image/png,image/webp,image/gif' },
|
||||
{ key: 'audio', labelKey: 'common.audio', enabled: true, accept: 'audio/mpeg,audio/wav,audio/ogg,audio/mp4' },
|
||||
{ key: 'video', labelKey: 'common.video', enabled: true, accept: 'video/mp4,video/webm,video/ogg' },
|
||||
{ key: 'url', labelKey: 'common.url', enabled: true },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const DomainAnalysis: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
|
||||
const async = useAsyncAnalysis('source-assessment');
|
||||
const [inputType, setInputType] = useState<InputType>('text');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [parsedResult, setParsedResult] = useState<SourceAssessmentResult | null>(null);
|
||||
const [analyzedInput, setAnalyzedInput] = useState<{ type: InputType; text: string } | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!async.result) return;
|
||||
try {
|
||||
const session: AnalysisSession = async.result;
|
||||
if (session.source_assessment) {
|
||||
setParsedResult(session.source_assessment);
|
||||
}
|
||||
if (session.input_text && session.input_type !== 'text' && session.input_type !== 'url') {
|
||||
setAnalyzedInput(prev => prev ? { ...prev, text: session.input_text! } : prev);
|
||||
}
|
||||
} catch {
|
||||
// parsing failed
|
||||
}
|
||||
}, [async.result]);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const validation = validateFile(file, inputType);
|
||||
if (!validation.valid) {
|
||||
setFileError(validation.error);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setFileError(null);
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
setFileError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: InputType) => {
|
||||
setInputType(type);
|
||||
setSelectedFile(null);
|
||||
setFileError(null);
|
||||
setParsedResult(null);
|
||||
setAnalyzedInput(null);
|
||||
async.reset();
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const textValidation = useMemo(() => validateText(inputText), [inputText]);
|
||||
const urlValidation = useMemo(() => validateUrl(inputUrl), [inputUrl]);
|
||||
|
||||
const canAnalyze =
|
||||
inputType === 'text' ? textValidation.valid :
|
||||
inputType === 'url' ? urlValidation.valid :
|
||||
selectedFile !== null;
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!canAnalyze) return;
|
||||
setParsedResult(null);
|
||||
|
||||
if (inputType === 'text') {
|
||||
const txt = inputText.trim();
|
||||
setAnalyzedInput({ type: inputType, text: txt });
|
||||
async.submitText(txt);
|
||||
} else if (inputType === 'url') {
|
||||
const url = inputUrl.trim();
|
||||
setAnalyzedInput({ type: inputType, text: url });
|
||||
async.submitUrl(url);
|
||||
} else if (selectedFile) {
|
||||
setAnalyzedInput({ type: inputType, text: selectedFile.name });
|
||||
async.submitMedia(selectedFile, inputType);
|
||||
}
|
||||
};
|
||||
|
||||
const isAnalyzing = async.isAnalyzing;
|
||||
const error = async.error;
|
||||
const r = parsedResult;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>{t('domain.title')}</Title>
|
||||
<Subtitle>{t('domain.subtitle')}</Subtitle>
|
||||
</Header>
|
||||
|
||||
{/* Input Type Tabs */}
|
||||
<TypeTabs>
|
||||
{INPUT_TYPES.filter(it => it.enabled).map((it) => (
|
||||
<TypeTab
|
||||
key={it.key}
|
||||
active={inputType === it.key}
|
||||
onClick={() => handleTypeChange(it.key)}
|
||||
>
|
||||
{t(it.labelKey)}
|
||||
</TypeTab>
|
||||
))}
|
||||
</TypeTabs>
|
||||
|
||||
{/* Input Area */}
|
||||
<InputArea>
|
||||
{inputType === 'text' && (
|
||||
<>
|
||||
<TextArea
|
||||
placeholder={t('domain.textPlaceholder')}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={isAnalyzing}
|
||||
rows={6}
|
||||
/>
|
||||
{inputText.trim() && textValidation.error && (
|
||||
<ValidationMsg color="#ef4444">{textValidation.error}</ValidationMsg>
|
||||
)}
|
||||
{inputText.trim() && textValidation.valid && (
|
||||
<ValidationMsg color="#22c55e">Valid ({inputText.trim().length} chars)</ValidationMsg>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{inputType === 'url' && (
|
||||
<>
|
||||
<UrlInput
|
||||
type="text"
|
||||
placeholder={t('domain.urlPlaceholder')}
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && canAnalyze && !isAnalyzing) handleAnalyze(); }}
|
||||
disabled={isAnalyzing}
|
||||
/>
|
||||
{inputUrl.trim() && urlValidation.error && (
|
||||
<ValidationMsg color="#ef4444">{urlValidation.error}</ValidationMsg>
|
||||
)}
|
||||
{inputUrl.trim() && urlValidation.valid && (
|
||||
<ValidationMsg color="#22c55e">{t('common.validUrl')}</ValidationMsg>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(inputType === 'image' || inputType === 'audio' || inputType === 'video') && (
|
||||
<>
|
||||
<FileInputArea>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={INPUT_TYPES.find(t => t.key === inputType)?.accept}
|
||||
onChange={handleFileSelect}
|
||||
disabled={isAnalyzing}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<FileSelected>
|
||||
<FileName>{selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(1)} MB)</FileName>
|
||||
<RemoveFileBtn onClick={handleRemoveFile}>Remove</RemoveFileBtn>
|
||||
</FileSelected>
|
||||
) : (
|
||||
<FileDropZone onClick={() => fileInputRef.current?.click()}>
|
||||
Click to select {inputType} file
|
||||
</FileDropZone>
|
||||
)}
|
||||
</FileInputArea>
|
||||
{fileError && <ValidationMsg color="#ef4444">{fileError}</ValidationMsg>}
|
||||
</>
|
||||
)}
|
||||
|
||||
<AnalyzeBtn onClick={handleAnalyze} disabled={isAnalyzing || !canAnalyze}>
|
||||
{isAnalyzing ? <><Spinner /> {async.statusText || t('common.analyzing')}</> : t('domain.analyzeSource')}
|
||||
</AnalyzeBtn>
|
||||
</InputArea>
|
||||
|
||||
{error && <ErrorBox><ErrorIcon>!</ErrorIcon>{error}</ErrorBox>}
|
||||
|
||||
{async.warnings.length > 0 && (
|
||||
<WarningBox>{async.warnings.map((w, i) => <div key={i}>{w}</div>)}</WarningBox>
|
||||
)}
|
||||
|
||||
{/* Input Preview (shown once result lands) */}
|
||||
{r && analyzedInput && (
|
||||
<InputPreview inputType={analyzedInput.type} text={analyzedInput.text} />
|
||||
)}
|
||||
|
||||
{/* Results — editorial layout */}
|
||||
{r && (
|
||||
<VerdictSection>
|
||||
<SourceResults result={r} isRo={isRo} />
|
||||
</VerdictSection>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Styled Components — input form & banners (results use shared editorial)
|
||||
// =============================================================================
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%; max-width: 1800px; margin: 0 auto;
|
||||
padding: 0;
|
||||
@media (max-width: 768px) { padding: 0; }
|
||||
`;
|
||||
const Header = styled.div`margin-bottom: ${spacing.xl}px;`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: clamp(1.375rem, 1.1rem + 1vw, 1.75rem);
|
||||
font-weight: ${typography.fontWeight.bold}; color: var(--fg-primary); margin: 0 0 ${spacing.xs}px 0;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted); margin: 0;
|
||||
`;
|
||||
|
||||
/* Input Type Tabs */
|
||||
const TypeTabs = styled.div`
|
||||
display: flex; gap: 4px; margin-bottom: ${spacing.lg}px;
|
||||
background: var(--bg-surface); border-radius: 10px; padding: 4px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
@media (max-width: 480px) { flex-wrap: wrap; }
|
||||
`;
|
||||
|
||||
const TypeTab = styled.button<{ active?: boolean }>`
|
||||
flex: 1; padding: 8px 12px; border: none; border-radius: 8px; cursor: pointer;
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${p => p.active ? typography.fontWeight.semibold : typography.fontWeight.medium};
|
||||
transition: all 0.2s;
|
||||
background: ${p => p.active ? 'var(--accent-subtle)' : 'transparent'};
|
||||
color: ${p => p.active ? 'var(--fg-primary)' : 'var(--fg-muted)'};
|
||||
&:hover { background: var(--accent-subtle); }
|
||||
`;
|
||||
|
||||
/* Input Area */
|
||||
const InputArea = styled.div`
|
||||
display: flex; flex-direction: column; gap: ${spacing.md}px; margin-bottom: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
const TextArea = styled.textarea`
|
||||
width: 100%; padding: 12px ${spacing.lg}px; min-height: 120px; resize: vertical;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px; font-family: ${typography.fontFamily.primary}; font-size: 14px;
|
||||
color: var(--fg-primary); line-height: 1.6; transition: border-color 0.2s;
|
||||
&:focus { outline: none; border-color: var(--border-focus); }
|
||||
&::placeholder { color: var(--fg-subtle); }
|
||||
`;
|
||||
|
||||
const UrlInput = styled.input`
|
||||
width: 100%; padding: 12px ${spacing.lg}px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px; font-family: ${typography.fontFamily.primary}; font-size: 15px;
|
||||
color: var(--fg-primary); transition: border-color 0.2s;
|
||||
&:focus { outline: none; border-color: var(--border-focus); }
|
||||
&::placeholder { color: var(--fg-subtle); }
|
||||
`;
|
||||
|
||||
const FileInputArea = styled.div``;
|
||||
|
||||
const FileDropZone = styled.div`
|
||||
padding: 32px; text-align: center; border: 2px dashed var(--accent-border);
|
||||
border-radius: 10px; cursor: pointer; color: var(--fg-muted);
|
||||
font-size: ${typography.fontSize.sm}; transition: all 0.2s;
|
||||
&:hover { border-color: var(--accent); background: var(--accent-subtle); }
|
||||
`;
|
||||
|
||||
const FileSelected = styled.div`
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 12px ${spacing.lg}px; background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border); border-radius: 10px;
|
||||
`;
|
||||
|
||||
const FileName = styled.span`
|
||||
font-size: ${typography.fontSize.sm}; color: var(--fg-secondary);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
`;
|
||||
|
||||
const RemoveFileBtn = styled.button`
|
||||
padding: 4px 12px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 6px; color: #f87171; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
&:hover { background: rgba(239, 68, 68, 0.2); }
|
||||
`;
|
||||
|
||||
const ValidationMsg = styled.div<{ color: string }>`
|
||||
font-size: 12px; color: ${p => p.color}; padding-left: 2px;
|
||||
`;
|
||||
|
||||
const spin = keyframes`from { transform: rotate(0deg); } to { transform: rotate(360deg); }`;
|
||||
|
||||
const Spinner = styled.span`
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-top-color: white;
|
||||
border-radius: 50%; animation: ${spin} 0.7s linear infinite;
|
||||
`;
|
||||
|
||||
const AnalyzeBtn = styled.button<{ disabled?: boolean }>`
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 12px 28px; align-self: flex-start;
|
||||
background: ${p => p.disabled ? 'var(--accent-subtle)' : 'var(--accent)'};
|
||||
color: ${p => p.disabled ? 'var(--fg-disabled)' : 'var(--fg-on-accent)'};
|
||||
border: none; border-radius: 10px;
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: ${p => p.disabled ? 'not-allowed' : 'pointer'}; transition: all 0.2s; white-space: nowrap;
|
||||
&:hover:not(:disabled) { transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
@media (max-width: 480px) { width: 100%; }
|
||||
`;
|
||||
|
||||
const ErrorBox = styled.div`
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px ${spacing.lg}px; background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2); border-radius: 10px;
|
||||
color: #f87171; font-size: ${typography.fontSize.sm}; margin-bottom: ${spacing.lg}px;
|
||||
[data-theme="light"] & { background: #fef2f2; border-color: #fecaca; color: #dc2626; }
|
||||
`;
|
||||
|
||||
const WarningBox = styled.div`
|
||||
padding: 12px ${spacing.lg}px; background: rgba(234, 179, 8, 0.08);
|
||||
border: 1px solid rgba(234, 179, 8, 0.2); border-radius: 10px;
|
||||
color: #eab308; font-size: ${typography.fontSize.sm}; margin-bottom: ${spacing.lg}px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
[data-theme="light"] & { background: #fefce8; border-color: #fde68a; color: #a16207; }
|
||||
`;
|
||||
|
||||
const ErrorIcon = styled.span`
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: rgba(239, 68, 68, 0.2); font-size: 11px; font-weight: 700; flex-shrink: 0;
|
||||
`;
|
||||
1
web/src/components/DomainAnalysis/index.ts
Normal file
1
web/src/components/DomainAnalysis/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { DomainAnalysis } from './DomainAnalysis';
|
||||
134
web/src/components/ErrorBoundary/ErrorBoundary.tsx
Normal file
134
web/src/components/ErrorBoundary/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React, { Component, type ReactNode } from 'react';
|
||||
import i18n from '../../i18n';
|
||||
import {
|
||||
ErrorContainer,
|
||||
ErrorCard,
|
||||
ErrorIcon,
|
||||
ErrorTitle,
|
||||
ErrorMessage,
|
||||
ErrorDetails,
|
||||
ErrorDetailsTitle,
|
||||
ErrorCode,
|
||||
ButtonGroup,
|
||||
ReloadButton,
|
||||
HomeButton,
|
||||
ErrorFooter,
|
||||
} from './styles/errorBoundary.styles';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
autoRecoveryAttempts: number;
|
||||
}
|
||||
|
||||
// Errors most likely caused by EXTERNAL DOM mutation (browser auto-translate,
|
||||
// extensions, ad-blockers) rather than a real bug in our code. We retry these
|
||||
// once silently — if it works, the user never sees the error screen.
|
||||
const AUTO_RECOVERABLE_PATTERNS = [
|
||||
'removeChild',
|
||||
'insertBefore',
|
||||
'NotFoundError',
|
||||
'The node to be removed is not a child',
|
||||
];
|
||||
const MAX_AUTO_RECOVERY = 1;
|
||||
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
private recoveryTimer: number | null = null;
|
||||
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
autoRecoveryAttempts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// If the error looks like an external-DOM-mutation glitch and we
|
||||
// haven't already tried recovering once this mount, schedule a silent
|
||||
// re-render. This rescues the user from "Ups!" pages caused by Chrome
|
||||
// auto-translate or browser extensions.
|
||||
const msg = `${error?.name ?? ''} ${error?.message ?? ''}`;
|
||||
const looksRecoverable = AUTO_RECOVERABLE_PATTERNS.some((p) => msg.includes(p));
|
||||
if (looksRecoverable && this.state.autoRecoveryAttempts < MAX_AUTO_RECOVERY) {
|
||||
this.setState((s) => ({
|
||||
error,
|
||||
errorInfo,
|
||||
autoRecoveryAttempts: s.autoRecoveryAttempts + 1,
|
||||
}));
|
||||
this.recoveryTimer = window.setTimeout(() => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
}, 350);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ error, errorInfo });
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.recoveryTimer !== null) {
|
||||
window.clearTimeout(this.recoveryTimer);
|
||||
this.recoveryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<ErrorContainer>
|
||||
<ErrorCard>
|
||||
<ErrorIcon>⚠️</ErrorIcon>
|
||||
<ErrorTitle>{i18n.t('error.oops')}</ErrorTitle>
|
||||
<ErrorMessage>
|
||||
{i18n.t('error.unexpectedError')}
|
||||
</ErrorMessage>
|
||||
|
||||
{this.state.error && (
|
||||
<ErrorDetails>
|
||||
<ErrorDetailsTitle>{i18n.t('error.errorDetails')}</ErrorDetailsTitle>
|
||||
<ErrorCode>{this.state.error.toString()}</ErrorCode>
|
||||
</ErrorDetails>
|
||||
)}
|
||||
|
||||
<ButtonGroup>
|
||||
<ReloadButton onClick={this.handleReload}>
|
||||
🔄 {i18n.t('error.reloadPage')}
|
||||
</ReloadButton>
|
||||
<HomeButton onClick={() => window.location.href = '/'}>
|
||||
🏠 {i18n.t('error.goHome')}
|
||||
</HomeButton>
|
||||
</ButtonGroup>
|
||||
|
||||
<ErrorFooter>
|
||||
{i18n.t('error.persistsContact')}
|
||||
</ErrorFooter>
|
||||
</ErrorCard>
|
||||
</ErrorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
158
web/src/components/ErrorBoundary/styles/errorBoundary.styles.ts
Normal file
158
web/src/components/ErrorBoundary/styles/errorBoundary.styles.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { colors, typography, spacing } from '../../../theme';
|
||||
|
||||
export const ErrorContainer = styled.div`
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: ${spacing.xl}px;
|
||||
background: var(--bg-canvas);
|
||||
`;
|
||||
|
||||
export const ErrorCard = styled.div`
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 16px;
|
||||
padding: ${spacing['3xl']}px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideIn 0.5s ease-out;
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ErrorIcon = styled.div`
|
||||
font-size: 80px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ErrorTitle = styled.h1`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize['2xl']};
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.md}px;
|
||||
`;
|
||||
|
||||
export const ErrorMessage = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
color: var(--fg-muted);
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
line-height: ${typography.lineHeight.relaxed};
|
||||
`;
|
||||
|
||||
export const ErrorDetails = styled.div`
|
||||
background: var(--bg-canvas);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
padding: ${spacing.lg}px;
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
export const ErrorDetailsTitle = styled.div`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: ${colors.cautionRed};
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
export const ErrorCode = styled.code`
|
||||
font-family: ${typography.fontFamily.code};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-primary);
|
||||
word-break: break-word;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
gap: ${spacing.md}px;
|
||||
justify-content: center;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
export const ReloadButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
padding: ${spacing.md}px ${spacing.xl}px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: var(--fg-on-accent);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const HomeButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
padding: ${spacing.md}px ${spacing.xl}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 12px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ErrorFooter = styled.div`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
font-style: italic;
|
||||
`;
|
||||
61
web/src/components/LanguageSwitcher/LanguageSwitcher.tsx
Normal file
61
web/src/components/LanguageSwitcher/LanguageSwitcher.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { spacing, borderRadius, animation, sizing } from '../../theme';
|
||||
|
||||
export const LanguageSwitcher: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const currentLang = i18n.language?.startsWith('ro') ? 'ro' : 'en';
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = currentLang === 'en' ? 'ro' : 'en';
|
||||
i18n.changeLanguage(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<SwitchButton
|
||||
onClick={handleToggle}
|
||||
aria-label={`Switch language to ${currentLang === 'en' ? 'Romanian' : 'English'}`}
|
||||
title={currentLang === 'en' ? 'Switch to Romanian' : 'Switch to English'}
|
||||
>
|
||||
<LangCode>{currentLang.toUpperCase()}</LangCode>
|
||||
</SwitchButton>
|
||||
);
|
||||
};
|
||||
|
||||
const SwitchButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${sizing.button.lg}px;
|
||||
height: ${sizing.button.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
cursor: pointer;
|
||||
color: var(--fg-primary);
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent-border);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(0, 145, 152, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const LangCode = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
`;
|
||||
1
web/src/components/LanguageSwitcher/index.ts
Normal file
1
web/src/components/LanguageSwitcher/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LanguageSwitcher } from './LanguageSwitcher';
|
||||
132
web/src/components/LimitWarningBanner/LimitWarningBanner.tsx
Normal file
132
web/src/components/LimitWarningBanner/LimitWarningBanner.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/** @jsxImportSource @emotion/react */
|
||||
import { css } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUsageStats } from '../../hooks/useSubscription';
|
||||
|
||||
interface LimitWarningBannerProps {
|
||||
action: 'run_pipeline';
|
||||
}
|
||||
|
||||
const bannerContainerStyles = css`
|
||||
margin-bottom: 20px;
|
||||
`;
|
||||
|
||||
const warningBannerStyles = css`
|
||||
background: linear-gradient(135deg, #fff3cd 0%, #ffc107 10%);
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
animation: slideDown 0.3s ease-out;
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`;
|
||||
|
||||
const dangerBannerStyles = css`
|
||||
${warningBannerStyles}
|
||||
background: linear-gradient(135deg, #f8d7da 0%, #dc3545 10%);
|
||||
border-left-color: #dc3545;
|
||||
`;
|
||||
|
||||
const iconStyles = css`font-size: 20px; flex-shrink: 0; margin-top: 2px;`;
|
||||
const contentStyles = css`flex: 1;`;
|
||||
const titleStyles = css`margin: 0 0 4px 0; font-size: 15px; font-weight: 600; color: #333;`;
|
||||
const messageStyles = css`margin: 0 0 8px 0; font-size: 14px; color: #666; line-height: 1.5;`;
|
||||
|
||||
const progressContainerStyles = css`
|
||||
width: 100%; height: 6px; background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px; overflow: hidden; margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const progressBarStyles = (percentage: number, color: string) => css`
|
||||
height: 100%; width: ${percentage}%; background: ${color}; transition: width 0.3s ease;
|
||||
`;
|
||||
|
||||
const upgradeButtonStyles = css`
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; background: var(--accent);
|
||||
color: var(--fg-on-accent); border: none; border-radius: 6px;
|
||||
&:hover { background: var(--accent-hover); }
|
||||
font-size: 13px; font-weight: 600; cursor: pointer; text-decoration: none;
|
||||
transition: transform 0.2s;
|
||||
&:hover { transform: translateY(-2px); }
|
||||
`;
|
||||
|
||||
export const LimitWarningBanner = ({ action: _action }: LimitWarningBannerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: usageStats, isLoading } = useUsageStats();
|
||||
|
||||
if (isLoading || !usageStats?.credits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { remaining, total } = usageStats.credits;
|
||||
const canProceed = remaining > 0;
|
||||
// Guard against backend inconsistency (remaining > total)
|
||||
const effectiveTotal = Math.max(total, remaining);
|
||||
const usagePercent = effectiveTotal > 0
|
||||
? Math.round(((effectiveTotal - remaining) / effectiveTotal) * 100)
|
||||
: 0;
|
||||
|
||||
// Don't show banner if user has plenty of credits (under 75% used)
|
||||
if (canProceed && usagePercent < 75) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDanger = usagePercent >= 90 || !canProceed;
|
||||
const isWarning = usagePercent >= 75 && usagePercent < 90;
|
||||
|
||||
if (!isDanger && !isWarning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getBarColor = () => {
|
||||
if (usagePercent >= 90) return '#dc3545';
|
||||
if (usagePercent >= 75) return '#ffc107';
|
||||
return '#28a745';
|
||||
};
|
||||
|
||||
const getMessage = () => {
|
||||
if (!canProceed) {
|
||||
return t('limitBanner.exhaustedMsg', { total: effectiveTotal });
|
||||
}
|
||||
if (usagePercent >= 90) {
|
||||
return t('limitBanner.dangerMsg', { percent: usagePercent, remaining });
|
||||
}
|
||||
return t('limitBanner.warningMsg', { used: effectiveTotal - remaining, total: effectiveTotal, percent: usagePercent });
|
||||
};
|
||||
|
||||
return (
|
||||
<div css={bannerContainerStyles}>
|
||||
<div css={isDanger ? dangerBannerStyles : warningBannerStyles}>
|
||||
<div css={iconStyles}>{isDanger ? '🚫' : '⚠️'}</div>
|
||||
<div css={contentStyles}>
|
||||
<h4 css={titleStyles}>
|
||||
{!canProceed ? t('limitBanner.exhaustedTitle') : t('limitBanner.lowTitle')}
|
||||
</h4>
|
||||
<p css={messageStyles}>{getMessage()}</p>
|
||||
<div css={progressContainerStyles}>
|
||||
<div css={progressBarStyles(Math.min(usagePercent, 100), getBarColor())} />
|
||||
</div>
|
||||
{(isDanger || !canProceed) && (
|
||||
<a
|
||||
href="/settings"
|
||||
css={upgradeButtonStyles}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = '/settings#subscription';
|
||||
}}
|
||||
>
|
||||
{t('subscription.upgradePlan')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,634 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { typography, spacing } from '../../theme';
|
||||
import { useAsyncAnalysis } from '../../hooks/useAsyncAnalysis';
|
||||
import { validateText } from '../../utils/text-validation';
|
||||
import { validateUrl } from '../../utils/url-validation';
|
||||
import { validateFile } from '../../utils/file-validation';
|
||||
import { ConsentNotice, ConsentSlot, useMediaConsent } from '../ConsentNotice';
|
||||
import { TechniqueDefinitionsService, type TechniqueDefinition } from '../../services/technique-definitions.service';
|
||||
import type { AnalysisSession, TechniquesResult } from '../../types/analysis-session';
|
||||
import { InputPreview } from '../PipelineAnalysis/sections/InputPreview';
|
||||
import { TechniquesResults } from '../PipelineAnalysis/sections/TechniquesResults';
|
||||
import { VerdictSection } from '../PipelineAnalysis/styles';
|
||||
|
||||
// =============================================================================
|
||||
// Input Types
|
||||
// =============================================================================
|
||||
|
||||
type InputType = 'text' | 'image' | 'audio' | 'video' | 'url';
|
||||
|
||||
const INPUT_TYPES: { key: InputType; labelKey: string; enabled: boolean; accept?: string }[] = [
|
||||
{ key: 'text', labelKey: 'common.text', enabled: true },
|
||||
{ key: 'image', labelKey: 'common.image', enabled: true, accept: 'image/jpeg,image/png,image/webp,image/gif' },
|
||||
{ key: 'audio', labelKey: 'common.audio', enabled: true, accept: 'audio/mpeg,audio/wav,audio/ogg,audio/mp4' },
|
||||
{ key: 'video', labelKey: 'common.video', enabled: true, accept: 'video/mp4,video/webm,video/ogg' },
|
||||
{ key: 'url', labelKey: 'common.url', enabled: true },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const ManipulationTechniques: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
|
||||
const async = useAsyncAnalysis('techniques');
|
||||
const [inputType, setInputType] = useState<InputType>('text');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [parsedResult, setParsedResult] = useState<TechniquesResult | null>(null);
|
||||
const [analyzedInput, setAnalyzedInput] = useState<{ type: InputType; text: string } | null>(null);
|
||||
const [techniqueDefinitions, setTechniqueDefinitions] = useState<TechniqueDefinition[]>([]);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load technique definitions once (cached in service)
|
||||
useEffect(() => {
|
||||
TechniqueDefinitionsService.getDefinitions()
|
||||
.then(setTechniqueDefinitions)
|
||||
.catch(err => console.warn('Failed to load technique definitions:', err));
|
||||
}, []);
|
||||
|
||||
// Parse async result (AnalysisSession) into TechniquesResult when it arrives
|
||||
useEffect(() => {
|
||||
if (!async.result) return;
|
||||
try {
|
||||
const session: AnalysisSession = async.result;
|
||||
if (session.techniques) {
|
||||
setParsedResult(session.techniques);
|
||||
}
|
||||
// For media inputs, swap snapshot with the extracted text once available
|
||||
if (session.input_text && session.input_type !== 'text' && session.input_type !== 'url') {
|
||||
setAnalyzedInput(prev => prev ? { ...prev, text: session.input_text! } : prev);
|
||||
}
|
||||
} catch {
|
||||
// result parsing failed — error state handled by hook
|
||||
}
|
||||
}, [async.result]);
|
||||
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const validation = validateFile(file, inputType);
|
||||
if (!validation.valid) {
|
||||
setFileError(validation.error);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setFileError(null);
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
setFileError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: InputType) => {
|
||||
setInputType(type);
|
||||
setSelectedFile(null);
|
||||
setParsedResult(null);
|
||||
setAnalyzedInput(null);
|
||||
async.reset();
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const textValidation = useMemo(() => validateText(inputText), [inputText]);
|
||||
const urlValidation = useMemo(() => validateUrl(inputUrl), [inputUrl]);
|
||||
|
||||
const [mediaConsent, setMediaConsent] = useMediaConsent();
|
||||
|
||||
const canAnalyze =
|
||||
inputType === 'text' ? textValidation.valid :
|
||||
inputType === 'url' ? urlValidation.valid :
|
||||
selectedFile !== null && mediaConsent;
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!canAnalyze) return;
|
||||
setParsedResult(null);
|
||||
|
||||
if (inputType === 'text') {
|
||||
const txt = inputText.trim();
|
||||
setAnalyzedInput({ type: inputType, text: txt });
|
||||
async.submitText(txt);
|
||||
} else if (inputType === 'url') {
|
||||
const url = inputUrl.trim();
|
||||
setAnalyzedInput({ type: inputType, text: url });
|
||||
async.submitUrl(url);
|
||||
} else if (selectedFile) {
|
||||
setAnalyzedInput({ type: inputType, text: selectedFile.name });
|
||||
async.submitMedia(selectedFile, inputType);
|
||||
}
|
||||
};
|
||||
|
||||
// Aliases for template compatibility
|
||||
const isAnalyzing = async.isAnalyzing;
|
||||
const error = async.error;
|
||||
const result = parsedResult;
|
||||
const { skipped, skipMessage } = async;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* Header */}
|
||||
<Header>
|
||||
<Title>{t('techniques.title')}</Title>
|
||||
<Subtitle>{t('techniques.subtitle')}</Subtitle>
|
||||
</Header>
|
||||
|
||||
{/* Input Type Selector */}
|
||||
<TypeSelector>
|
||||
{INPUT_TYPES.map(({ key, labelKey, enabled }) => (
|
||||
<TypeButton
|
||||
key={key}
|
||||
active={inputType === key}
|
||||
disabled={!enabled}
|
||||
onClick={() => enabled && handleTypeChange(key)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</TypeButton>
|
||||
))}
|
||||
</TypeSelector>
|
||||
|
||||
{/* Input Area */}
|
||||
<InputArea>
|
||||
{inputType === 'text' ? (
|
||||
<TextInput
|
||||
placeholder={t('techniques.textPlaceholder')}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={isAnalyzing}
|
||||
/>
|
||||
) : inputType === 'url' ? (
|
||||
<UrlInputWrapper>
|
||||
<UrlInput
|
||||
type="url"
|
||||
placeholder={t('techniques.urlPlaceholder')}
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
disabled={isAnalyzing}
|
||||
/>
|
||||
</UrlInputWrapper>
|
||||
) : (
|
||||
<>
|
||||
<FileDropZone>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={INPUT_TYPES.find(t => t.key === inputType)?.accept}
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<FileSelected>
|
||||
<FileName>{selectedFile.name}</FileName>
|
||||
<FileSize>{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</FileSize>
|
||||
<RemoveFileBtn onClick={handleRemoveFile}>Remove</RemoveFileBtn>
|
||||
</FileSelected>
|
||||
) : (
|
||||
<DropPlaceholder onClick={() => fileInputRef.current?.click()}>
|
||||
<DropLabel>Click to select {inputType} file</DropLabel>
|
||||
<DropHint>
|
||||
{inputType === 'image' && t('pipeline.imageHint')}
|
||||
{inputType === 'audio' && t('pipeline.audioHint')}
|
||||
{inputType === 'video' && t('pipeline.videoHint')}
|
||||
</DropHint>
|
||||
</DropPlaceholder>
|
||||
)}
|
||||
{fileError && <FileErrorMsg>{fileError}</FileErrorMsg>}
|
||||
</FileDropZone>
|
||||
<ConsentSlot>
|
||||
<ConsentNotice consented={mediaConsent} onChange={setMediaConsent} />
|
||||
</ConsentSlot>
|
||||
</>
|
||||
)}
|
||||
|
||||
<InputFooter>
|
||||
<CharCount style={
|
||||
inputType === 'text'
|
||||
? { color: textValidation.level === 'error' ? '#ef4444' : textValidation.level === 'warning' ? '#eab308' : '#22c55e' }
|
||||
: inputType === 'url' && urlValidation.error
|
||||
? { color: '#ef4444' }
|
||||
: inputType === 'url' && urlValidation.valid
|
||||
? { color: '#22c55e' }
|
||||
: undefined
|
||||
}>
|
||||
{inputType === 'text'
|
||||
? (textValidation.error || textValidation.warning || `${inputText.trim().length} characters`)
|
||||
: inputType === 'url'
|
||||
? (urlValidation.error || (inputUrl.trim() ? t('common.validUrl') : t('common.enterUrl')))
|
||||
: selectedFile ? t('common.readyToAnalyze') : t('common.noFileSelected')}
|
||||
</CharCount>
|
||||
<AnalyzeBtn
|
||||
onClick={handleAnalyze}
|
||||
disabled={isAnalyzing || !canAnalyze}
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Spinner />
|
||||
{async.statusText || t('common.analyzing')}
|
||||
</>
|
||||
) : (
|
||||
t('common.analyze')
|
||||
)}
|
||||
</AnalyzeBtn>
|
||||
</InputFooter>
|
||||
</InputArea>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<ErrorBox>
|
||||
<ErrorIcon>!</ErrorIcon>
|
||||
{error}
|
||||
</ErrorBox>
|
||||
)}
|
||||
|
||||
{/* Skipped */}
|
||||
{skipped && skipMessage && (
|
||||
<SkipBox>
|
||||
<SkipText>{skipMessage}</SkipText>
|
||||
<SkipAction onClick={() => { async.reset(); handleTypeChange('image'); }}>
|
||||
{t('common.uploadManual')}
|
||||
</SkipAction>
|
||||
</SkipBox>
|
||||
)}
|
||||
|
||||
{/* Backend Warnings */}
|
||||
{async.warnings.length > 0 && (
|
||||
<WarningBox>
|
||||
{async.warnings.map((w, i) => <div key={i}>{w}</div>)}
|
||||
</WarningBox>
|
||||
)}
|
||||
|
||||
{/* Input Preview (shown once result lands) */}
|
||||
{result && analyzedInput && (
|
||||
<InputPreview inputType={analyzedInput.type} text={analyzedInput.text} />
|
||||
)}
|
||||
|
||||
{/* Results — editorial layout */}
|
||||
{result && (
|
||||
<VerdictSection>
|
||||
<TechniquesResults result={result} isRo={isRo} techDefs={techniqueDefinitions} />
|
||||
</VerdictSection>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Styled Components — input form & banners (results use shared editorial components)
|
||||
// =============================================================================
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
@media (max-width: 768px) { padding: 0; }
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: clamp(1.375rem, 1.1rem + 1vw, 1.75rem);
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin: 0 0 ${spacing.xs}px 0;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
/* Input Type Selector */
|
||||
const TypeSelector = styled.div`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 12px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
`;
|
||||
|
||||
const TypeButton = styled.button<{ active?: boolean; disabled?: boolean }>`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${props => props.active ? typography.fontWeight.semibold : typography.fontWeight.medium};
|
||||
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
background: ${props => props.active
|
||||
? 'var(--accent-subtle)'
|
||||
: 'transparent'};
|
||||
color: ${props => props.active
|
||||
? 'var(--accent-text)'
|
||||
: props.disabled
|
||||
? 'var(--fg-disabled)'
|
||||
: 'var(--fg-secondary)'};
|
||||
|
||||
${props => props.active && `
|
||||
box-shadow: 0 0 0 1px var(--accent-border);
|
||||
`}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${props => props.active ? 'var(--accent-subtle)' : 'var(--bg-hover)'};
|
||||
}
|
||||
`;
|
||||
|
||||
/* Input Area */
|
||||
const InputArea = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
`;
|
||||
|
||||
const TextInput = styled.textarea`
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
padding: ${spacing.lg}px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 15px;
|
||||
color: var(--fg-primary);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
`;
|
||||
|
||||
const InputFooter = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${spacing.sm}px ${spacing.lg}px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
`;
|
||||
|
||||
const CharCount = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--fg-subtle);
|
||||
`;
|
||||
|
||||
const spin = keyframes`
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
`;
|
||||
|
||||
const Spinner = styled.span`
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: ${spin} 0.7s linear infinite;
|
||||
`;
|
||||
|
||||
const AnalyzeBtn = styled.button<{ disabled?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 24px;
|
||||
background: ${props => props.disabled
|
||||
? 'var(--accent-subtle)'
|
||||
: 'var(--accent)'};
|
||||
color: ${props => props.disabled ? 'var(--fg-disabled)' : 'var(--fg-on-accent)'};
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`;
|
||||
|
||||
/* Error */
|
||||
const ErrorBox = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px ${spacing.lg}px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 10px;
|
||||
color: #f87171;
|
||||
font-size: ${typography.fontSize.sm};
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
|
||||
[data-theme="light"] & {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
`;
|
||||
|
||||
const WarningBox = styled.div`
|
||||
padding: 12px ${spacing.lg}px;
|
||||
background: rgba(234, 179, 8, 0.08);
|
||||
border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
border-radius: 10px;
|
||||
color: #eab308;
|
||||
font-size: ${typography.fontSize.sm};
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
[data-theme="light"] & {
|
||||
background: #fefce8;
|
||||
border-color: #fde68a;
|
||||
color: #a16207;
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorIcon = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const SkipBox = styled.div`
|
||||
display: flex; flex-direction: column; gap: 10px; padding: 16px ${spacing.lg}px;
|
||||
background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.25);
|
||||
border-radius: 10px; margin-bottom: ${spacing.lg}px;
|
||||
[data-theme="light"] & { background: #fffbeb; border-color: #fde68a; }
|
||||
`;
|
||||
const SkipText = styled.div`
|
||||
color: #eab308; font-size: ${typography.fontSize.sm}; line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
[data-theme="light"] & { color: #b45309; }
|
||||
`;
|
||||
const SkipAction = styled.button`
|
||||
align-self: flex-start; padding: 8px 16px; background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border); border-radius: 8px; color: var(--accent-text);
|
||||
font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
&:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||
`;
|
||||
|
||||
/* URL Input */
|
||||
const UrlInputWrapper = styled.div`
|
||||
padding: ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
const UrlInput = styled.input`
|
||||
width: 100%;
|
||||
padding: 14px ${spacing.lg}px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 15px;
|
||||
color: var(--fg-primary);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
`;
|
||||
|
||||
/* File Upload */
|
||||
const FileDropZone = styled.div`
|
||||
padding: ${spacing.lg}px;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const DropPlaceholder = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 40px;
|
||||
border: 2px dashed var(--accent-border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
`;
|
||||
|
||||
const DropLabel = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
const DropHint = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--fg-subtle);
|
||||
`;
|
||||
|
||||
const FileErrorMsg = styled.div`
|
||||
font-size: 13px; color: #ef4444; margin-top: 8px; text-align: center;
|
||||
`;
|
||||
|
||||
const FileSelected = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.md}px;
|
||||
padding: 14px 20px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const FileName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-primary);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const FileSize = styled.span`
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const RemoveFileBtn = styled.button`
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #f87171;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] & {
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
color: #dc2626;
|
||||
&:hover { background: rgba(220, 38, 38, 0.05); }
|
||||
}
|
||||
`;
|
||||
1
web/src/components/ManipulationTechniques/index.ts
Normal file
1
web/src/components/ManipulationTechniques/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ManipulationTechniques } from './ManipulationTechniques';
|
||||
File diff suppressed because it is too large
Load diff
86
web/src/components/PipelineAnalysis/DidYouKnowCard.tsx
Normal file
86
web/src/components/PipelineAnalysis/DidYouKnowCard.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { spacing } from '../../theme';
|
||||
import { localized } from '../../utils/i18n-fields';
|
||||
import type { TechniqueDefinition } from '../../services/technique-definitions.service';
|
||||
|
||||
const CYCLE_MS = 7000;
|
||||
const FADE_MS = 400;
|
||||
|
||||
interface Props {
|
||||
definitions: TechniqueDefinition[];
|
||||
}
|
||||
|
||||
const formatName = (name: string) => {
|
||||
const part = name.includes('.') ? name.split('.').pop()! : name;
|
||||
return part.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
export const DidYouKnowCard: React.FC<Props> = ({ definitions }) => {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(() => Math.floor(Math.random() * definitions.length));
|
||||
const [fading, setFading] = useState(false);
|
||||
|
||||
const cycle = useCallback(() => {
|
||||
setFading(true);
|
||||
setTimeout(() => {
|
||||
setIndex(prev => {
|
||||
let next;
|
||||
do { next = Math.floor(Math.random() * definitions.length); } while (next === prev && definitions.length > 1);
|
||||
return next;
|
||||
});
|
||||
setFading(false);
|
||||
}, FADE_MS);
|
||||
}, [definitions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(cycle, CYCLE_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [cycle]);
|
||||
|
||||
if (!definitions.length) return null;
|
||||
const def = definitions[index];
|
||||
if (!def) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Label>{t('pipeline.didYouKnow')}</Label>
|
||||
<Content fading={fading}>
|
||||
<TechName>{formatName(localized(def, 'technique_name'))}</TechName>
|
||||
<Desc>{localized(def, 'description')}</Desc>
|
||||
<Meta>{localized(def, 'dimension_name')} — {localized(def, 'subdimension_name', 'subdimension')}</Meta>
|
||||
</Content>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const Card = styled.div`
|
||||
padding: 16px 20px; margin-bottom: ${spacing.md}px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 10px;
|
||||
border-left: 3px solid var(--accent);
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: var(--accent-text); margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const Content = styled.div<{ fading: boolean }>`
|
||||
opacity: ${p => p.fading ? 0 : 1};
|
||||
transition: opacity ${FADE_MS}ms ease;
|
||||
`;
|
||||
|
||||
const TechName = styled.div`
|
||||
font-size: 14px; font-weight: 600; color: var(--fg-primary); margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const Desc = styled.div`
|
||||
font-size: 13px; line-height: 1.5; color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
font-size: 11px; color: var(--fg-subtle); margin-top: 8px;
|
||||
`;
|
||||
287
web/src/components/PipelineAnalysis/PipelineAnalysis.tsx
Normal file
287
web/src/components/PipelineAnalysis/PipelineAnalysis.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAsyncAnalysis } from '../../hooks/useAsyncAnalysis';
|
||||
import { validateFile } from '../../utils/file-validation';
|
||||
import { TechniqueDefinitionsService, type TechniqueDefinition } from '../../services/technique-definitions.service';
|
||||
import type { AnalysisSession, VerdictResult } from '../../types/analysis-session';
|
||||
import type { InputType, ComponentStatus } from './types';
|
||||
import { COMPONENT_ORDER } from './utils';
|
||||
import {
|
||||
Container, Header, Title, Subtitle,
|
||||
ErrorBox, ErrorIcon,
|
||||
SkipBox, SkipText, SkipAction,
|
||||
WarningBox,
|
||||
} from './styles';
|
||||
import { InputForm } from './sections/InputForm';
|
||||
import { InputPreview } from './sections/InputPreview';
|
||||
import { LoadingState } from './sections/LoadingState';
|
||||
import { Verdict } from './sections/Verdict';
|
||||
import { DownloadPdfButton } from '../Reports/DownloadPdfButton';
|
||||
import { ShareReportButton } from '../Reports/ShareReportButton';
|
||||
|
||||
// Re-exports — preserve backward-compat for ../pages/History which imports
|
||||
// the detail renderers from this file path.
|
||||
export { renderTechniquesDetail } from './details/techniques';
|
||||
export { renderAiDetail } from './details/ai';
|
||||
export { renderClaimsDetail } from './details/claims';
|
||||
export { renderDomainDetail } from './details/domain';
|
||||
export { renderSourceDetail } from './details/source';
|
||||
|
||||
export const PipelineAnalysis: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const async = useAsyncAnalysis('pipeline');
|
||||
|
||||
const [inputType, setInputType] = useState<InputType>('text');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
const [components, setComponents] = useState<Record<string, ComponentStatus>>({});
|
||||
const [verdict, setVerdict] = useState<VerdictResult | null>(null);
|
||||
const [fullResult, setFullResult] = useState<Record<string, any> | null>(null);
|
||||
const [analysisSession, setAnalysisSession] = useState<AnalysisSession | null>(null);
|
||||
const [totalDuration, setTotalDuration] = useState<number | null>(null);
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<string>>(new Set());
|
||||
const [techniqueDefinitions, setTechniqueDefinitions] = useState<TechniqueDefinition[]>([]);
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
||||
// Snapshot of what was actually submitted (separate from current textarea).
|
||||
const [analyzedInput, setAnalyzedInput] = useState<{ type: InputType; text: string } | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load technique definitions once (cached in service)
|
||||
useEffect(() => {
|
||||
TechniqueDefinitionsService.getDefinitions()
|
||||
.then(setTechniqueDefinitions)
|
||||
.catch(err => console.warn('Failed to load technique definitions:', err));
|
||||
}, []);
|
||||
|
||||
// Track when analysis starts so LoadingState can show elapsed time.
|
||||
useEffect(() => {
|
||||
if (async.isAnalyzing && !startedAt) setStartedAt(Date.now());
|
||||
if (!async.isAnalyzing && startedAt && async.result) {
|
||||
// keep startedAt around — totalDuration replaces it post-finish
|
||||
}
|
||||
}, [async.isAnalyzing, async.result, startedAt]);
|
||||
|
||||
// Final result arrived (AnalysisSession)
|
||||
useEffect(() => {
|
||||
if (!async.result) return;
|
||||
const data: AnalysisSession = async.result;
|
||||
setAnalysisSession(data);
|
||||
if (data.verdict) setVerdict(data.verdict);
|
||||
|
||||
const compData: Record<string, any> = {};
|
||||
if (data.techniques) compData.techniques = data.techniques;
|
||||
if (data.ai_tampered) compData.ai_tampered = data.ai_tampered;
|
||||
if (data.claims) compData.claims = data.claims;
|
||||
if (data.domain) compData.domain = data.domain;
|
||||
if (data.source_assessment) compData.source_assessment = data.source_assessment;
|
||||
if (Object.keys(compData).length > 0) setFullResult(compData);
|
||||
if (data.total_duration_ms) setTotalDuration(data.total_duration_ms);
|
||||
|
||||
if (data.components_run) {
|
||||
const updated: Record<string, ComponentStatus> = {};
|
||||
for (const comp of data.components_run) {
|
||||
const key = comp === 'domain' && data.source_assessment ? 'source_assessment' : comp;
|
||||
updated[key] = { status: 'completed' };
|
||||
}
|
||||
for (const comp of data.components_skipped || []) {
|
||||
const key = comp === 'domain' && data.source_assessment ? 'source_assessment' : comp;
|
||||
updated[key] = { status: 'skipped' };
|
||||
}
|
||||
setComponents(updated);
|
||||
}
|
||||
}, [async.result]);
|
||||
|
||||
// Progressive results from polling — update component statuses + partial results
|
||||
useEffect(() => {
|
||||
if (!async.session) return;
|
||||
const session: AnalysisSession = async.session;
|
||||
|
||||
setComponents(prev => {
|
||||
const updated: Record<string, ComponentStatus> = { ...prev };
|
||||
const mapComp = (c: string) => c === 'domain' && session.source_assessment ? 'source_assessment' : c;
|
||||
|
||||
for (const comp of session.components_run || []) {
|
||||
const key = mapComp(comp);
|
||||
updated[key] = { ...(updated[key] || {}), status: 'completed' };
|
||||
}
|
||||
|
||||
// Mark next-in-line components as running
|
||||
if (session.status === 'running' && session._queue) {
|
||||
const total = session._queue.total_components;
|
||||
const done = (session.components_run || []).length;
|
||||
const remaining = total - done;
|
||||
let runningCount = 0;
|
||||
const mappedRun = (session.components_run || []).map(mapComp);
|
||||
for (const comp of COMPONENT_ORDER) {
|
||||
if (mappedRun.includes(comp)) continue;
|
||||
if (runningCount >= remaining) break;
|
||||
if (!updated[comp] || updated[comp].status !== 'completed') {
|
||||
updated[comp] = { ...(updated[comp] || {}), status: 'running' };
|
||||
}
|
||||
runningCount++;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
const compData: Record<string, any> = {};
|
||||
if (session.techniques) compData.techniques = session.techniques;
|
||||
if (session.ai_tampered) compData.ai_tampered = session.ai_tampered;
|
||||
if (session.claims) compData.claims = session.claims;
|
||||
if (session.domain) compData.domain = session.domain;
|
||||
if (session.source_assessment) compData.source_assessment = session.source_assessment;
|
||||
if (Object.keys(compData).length > 0) {
|
||||
setFullResult(prev => ({ ...prev, ...compData }));
|
||||
}
|
||||
}, [async.session]);
|
||||
|
||||
const resetResults = () => {
|
||||
setComponents({});
|
||||
setVerdict(null);
|
||||
setFullResult(null);
|
||||
setAnalysisSession(null);
|
||||
setTotalDuration(null);
|
||||
setExpandedComponents(new Set());
|
||||
setStartedAt(null);
|
||||
setAnalyzedInput(null);
|
||||
async.reset();
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: InputType) => {
|
||||
setInputType(type);
|
||||
setSelectedFile(null);
|
||||
setInputUrl('');
|
||||
resetResults();
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const validation = validateFile(file, inputType);
|
||||
if (!validation.valid) {
|
||||
setFileError(validation.error);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setFileError(null);
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
setFileError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleAnalyze = () => {
|
||||
resetResults();
|
||||
setStartedAt(Date.now());
|
||||
if (inputType === 'text') {
|
||||
const txt = inputText.trim();
|
||||
setAnalyzedInput({ type: inputType, text: txt });
|
||||
async.submitText(txt);
|
||||
} else if (inputType === 'url') {
|
||||
const url = inputUrl.trim();
|
||||
setAnalyzedInput({ type: inputType, text: url });
|
||||
async.submitUrl(url);
|
||||
} else if (selectedFile) {
|
||||
setAnalyzedInput({ type: inputType, text: selectedFile.name });
|
||||
async.submitMedia(selectedFile, inputType);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComponent = (name: string) => {
|
||||
setExpandedComponents(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isRunning = async.isAnalyzing;
|
||||
const error = async.error;
|
||||
const progress = async.progress;
|
||||
const { skipped, skipMessage, warnings, statusText } = async;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>{t('pipeline.title')}</Title>
|
||||
<Subtitle>{t('pipeline.subtitle')}</Subtitle>
|
||||
</Header>
|
||||
|
||||
<InputForm
|
||||
inputType={inputType}
|
||||
onTypeChange={handleTypeChange}
|
||||
inputText={inputText}
|
||||
onTextChange={setInputText}
|
||||
inputUrl={inputUrl}
|
||||
onUrlChange={setInputUrl}
|
||||
selectedFile={selectedFile}
|
||||
fileError={fileError}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
isRunning={isRunning}
|
||||
statusMsg={statusText}
|
||||
onAnalyze={handleAnalyze}
|
||||
/>
|
||||
|
||||
{error && <ErrorBox><ErrorIcon>!</ErrorIcon>{error}</ErrorBox>}
|
||||
|
||||
{skipped && skipMessage && (
|
||||
<SkipBox>
|
||||
<SkipText>{skipMessage}</SkipText>
|
||||
<SkipAction onClick={() => { async.reset(); handleTypeChange('image'); }}>
|
||||
{t('common.uploadManual')}
|
||||
</SkipAction>
|
||||
</SkipBox>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<WarningBox>
|
||||
{warnings.map((w, i) => <div key={i}>{w}</div>)}
|
||||
</WarningBox>
|
||||
)}
|
||||
|
||||
{(isRunning || progress > 0) && !verdict && (
|
||||
<LoadingState
|
||||
progress={progress}
|
||||
components={components}
|
||||
fullResult={fullResult}
|
||||
startedAt={startedAt}
|
||||
statusMsg={statusText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{verdict && analyzedInput && (
|
||||
<InputPreview inputType={analyzedInput.type} text={analyzedInput.text} />
|
||||
)}
|
||||
|
||||
{verdict && analysisSession && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginBottom: 12 }}>
|
||||
{analysisSession.session_id && (
|
||||
<ShareReportButton sessionId={analysisSession.session_id} />
|
||||
)}
|
||||
<DownloadPdfButton session={analysisSession} techDefs={techniqueDefinitions} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verdict && (
|
||||
<Verdict
|
||||
verdict={verdict}
|
||||
totalDuration={totalDuration}
|
||||
components={components}
|
||||
fullResult={fullResult}
|
||||
techniqueDefinitions={techniqueDefinitions}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
99
web/src/components/PipelineAnalysis/details/ai.tsx
Normal file
99
web/src/components/PipelineAnalysis/details/ai.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react';
|
||||
import { Cpu, BarChart2 } from 'lucide-react';
|
||||
import type { FindingAccent } from '../sections/FindingCard';
|
||||
import { FindingCard } from '../sections/FindingCard';
|
||||
import { SectionDivider } from '../sections/SectionDivider';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
|
||||
function aiAccent(prob: number): FindingAccent {
|
||||
if (prob >= 80) return 'critical';
|
||||
if (prob >= 60) return 'warning';
|
||||
if (prob >= 40) return 'info';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/** Editorial renderer used inside Verdict (Tier 4 expanded). */
|
||||
export function renderAiEditorial(data: any, isRo: boolean) {
|
||||
if (!data) return null;
|
||||
const prob = typeof data.ai_probability === 'number' ? data.ai_probability : (parseFloat(data.ai_probability) || 0);
|
||||
const verdict = typeof data.verdict === 'string' ? data.verdict : 'UNKNOWN';
|
||||
const verdictText = enumLabel(verdict).replace(/_/g, ' ');
|
||||
const indicators = data.indicators_detected || [];
|
||||
const imgIndicators = data.image_analysis?.indicators || [];
|
||||
|
||||
// Headline crafted: short + descriptive
|
||||
const headline = isRo
|
||||
? (prob >= 60
|
||||
? `Probabilitate ridicată de generare AI — ${prob.toFixed(0)}%`
|
||||
: prob >= 40
|
||||
? `Probabilitate moderată de generare AI — ${prob.toFixed(0)}%`
|
||||
: `Probabilitate scăzută de generare AI — ${prob.toFixed(0)}%`)
|
||||
: (prob >= 60
|
||||
? `High likelihood of AI generation — ${prob.toFixed(0)}%`
|
||||
: prob >= 40
|
||||
? `Moderate likelihood of AI generation — ${prob.toFixed(0)}%`
|
||||
: `Low likelihood of AI generation — ${prob.toFixed(0)}%`);
|
||||
|
||||
const eyebrowParts = [
|
||||
`${isRo ? 'Verdict' : 'Verdict'} · ${verdictText}`,
|
||||
data.disclosure_detected === true
|
||||
? (isRo ? 'cu declarație AI' : 'AI disclosure found')
|
||||
: data.disclosure_detected === false
|
||||
? (isRo ? 'fără declarație AI' : 'no AI disclosure')
|
||||
: null,
|
||||
data.coupling_context?.for_verdict?.confidence_level
|
||||
? `${isRo ? 'încredere' : 'confidence'} ${enumLabel(data.coupling_context.for_verdict.confidence_level).toLowerCase()}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
// Quote: pick a representative indicator if any
|
||||
let quote: React.ReactNode | undefined;
|
||||
if (indicators.length > 0 && indicators[0].evidence) {
|
||||
quote = indicators[0].evidence;
|
||||
} else if (imgIndicators.length > 0) {
|
||||
quote = imgIndicators.slice(0, 3).join(' · ');
|
||||
}
|
||||
|
||||
const source = (
|
||||
<>
|
||||
<BarChart2 size={13} strokeWidth={2} />
|
||||
<span>{prob.toFixed(0)}% {isRo ? 'probabilitate' : 'probability'}</span>
|
||||
{indicators.length > 0 && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>{indicators.length} {isRo ? 'indicatori' : 'indicators'}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionDivider label={isRo ? 'Detectare AI' : 'AI detection'} />
|
||||
<FindingCard
|
||||
icon={Cpu}
|
||||
accent={aiAccent(prob)}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={headline}
|
||||
quote={quote}
|
||||
source={source}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backward-compat. */
|
||||
export function renderAiDetail(data: any, _t: any) {
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
return <>{renderAiEditorial(data, isRo)}</>;
|
||||
}
|
||||
|
||||
export function renderAiSummary(data: any, t: any) {
|
||||
if (!data) return null;
|
||||
const prob = typeof data.ai_probability === 'number' ? data.ai_probability : (parseFloat(data.ai_probability) || 0);
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
{prob.toFixed(0)}% AI · {t('pipeline.aiProbabilityLabel')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
162
web/src/components/PipelineAnalysis/details/claims.tsx
Normal file
162
web/src/components/PipelineAnalysis/details/claims.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { XCircle, CheckCircle2, HelpCircle, Plus, ChevronDown } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { localized } from '../../../utils/i18n-fields';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import { FindingCard, type FindingAccent } from '../sections/FindingCard';
|
||||
import { SectionDivider } from '../sections/SectionDivider';
|
||||
import {
|
||||
CollapseBtn, CollapseBtnLeft, CollapseBtnChevron,
|
||||
StancePill, FindingSourceLink,
|
||||
} from '../styles';
|
||||
|
||||
const DEFAULT_VISIBLE = 4;
|
||||
|
||||
function claimIcon(status: string, statusColor?: string): LucideIcon {
|
||||
if (status === 'verified_false' || statusColor === 'red') return XCircle;
|
||||
if (status === 'verified_true' || statusColor === 'green') return CheckCircle2;
|
||||
return HelpCircle;
|
||||
}
|
||||
|
||||
function claimAccent(status: string, statusColor?: string): FindingAccent {
|
||||
if (status === 'verified_false' || statusColor === 'red') return 'critical';
|
||||
if (status === 'verified_true' || statusColor === 'green') return 'success';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function stanceFromString(s: string): 'contradicts' | 'supports' | 'neutral' {
|
||||
const u = (s || '').toUpperCase();
|
||||
if (u === 'CONTRADICTS') return 'contradicts';
|
||||
if (u === 'SUPPORTS') return 'supports';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function sourceHostname(url: string): string {
|
||||
return (url || '').replace(/^https?:\/\/(www\.)?/, '').split('/')[0];
|
||||
}
|
||||
|
||||
export function renderClaimsEditorial(
|
||||
data: any,
|
||||
isRo: boolean,
|
||||
expanded: boolean,
|
||||
onToggle: () => void,
|
||||
) {
|
||||
if (!data) return null;
|
||||
const claims = data.claims_verified || data.claims || [];
|
||||
if (claims.length === 0) return null;
|
||||
|
||||
const total = data.total_claims || claims.length;
|
||||
const trueCount = data.verified_true || 0;
|
||||
const falseCount = data.verified_false || 0;
|
||||
const unverified = data.unverified || 0;
|
||||
|
||||
const summaryParts = [
|
||||
`${total} ${isRo ? 'total' : 'total'}`,
|
||||
trueCount > 0 ? `${trueCount} ${isRo ? 'adevărate' : 'true'}` : null,
|
||||
falseCount > 0 ? `${falseCount} ${isRo ? 'false' : 'false'}` : null,
|
||||
unverified > 0 ? `${unverified} ${isRo ? 'neverificate' : 'unverified'}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const visible = expanded ? claims.length : Math.min(DEFAULT_VISIBLE, claims.length);
|
||||
const shown = claims.slice(0, visible);
|
||||
const remaining = claims.length - visible;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Verificarea afirmațiilor' : 'Claim verification'}
|
||||
count={summaryParts}
|
||||
/>
|
||||
{shown.map((claim: any, i: number) => {
|
||||
const icon = claimIcon(claim.status, claim.status_color);
|
||||
const accent = claimAccent(claim.status, claim.status_color);
|
||||
const statusName = localized(claim, 'status_name') || enumLabel(claim.status || '');
|
||||
const typeName = claim.type_name || enumLabel(claim.type || '');
|
||||
const eyebrowParts = [
|
||||
statusName,
|
||||
typeName,
|
||||
claim.priority ? `${isRo ? 'prioritate' : 'priority'} ${claim.priority}` : null,
|
||||
].filter(Boolean);
|
||||
const sources = (claim.sources || []).slice(0, 6);
|
||||
return (
|
||||
<FindingCard
|
||||
key={claim.id || i}
|
||||
icon={icon}
|
||||
accent={accent}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={claim.text}
|
||||
quote={claim.reasoning}
|
||||
source={sources.length > 0 ? (
|
||||
<>
|
||||
{(() => {
|
||||
const dominantStance = sources[0]?.stance ? stanceFromString(sources[0].stance) : 'neutral';
|
||||
return (
|
||||
<StancePill stance={dominantStance}>
|
||||
{isRo
|
||||
? (dominantStance === 'contradicts' ? 'Contrazice' : dominantStance === 'supports' ? 'Susține' : 'Neutru')
|
||||
: (dominantStance === 'contradicts' ? 'Contradicts' : dominantStance === 'supports' ? 'Supports' : 'Neutral')}
|
||||
</StancePill>
|
||||
);
|
||||
})()}
|
||||
{sources.map((src: any, idx: number) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <span className="sep">·</span>}
|
||||
<FindingSourceLink href={src.url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>
|
||||
{sourceHostname(src.url)}
|
||||
</FindingSourceLink>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<CollapseBtn onClick={onToggle}>
|
||||
<CollapseBtnLeft>
|
||||
<Plus size={16} strokeWidth={2} />
|
||||
<span>
|
||||
{isRo
|
||||
? `Vezi celelalte ${remaining} ${remaining === 1 ? 'afirmație' : 'afirmații'}`
|
||||
: `Show ${remaining} more ${remaining === 1 ? 'claim' : 'claims'}`}
|
||||
</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={false}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
{expanded && claims.length > DEFAULT_VISIBLE && (
|
||||
<CollapseBtn onClick={onToggle}>
|
||||
<CollapseBtnLeft>
|
||||
<ChevronDown size={16} strokeWidth={2} />
|
||||
<span>{isRo ? 'Ascunde' : 'Hide'}</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={true}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderClaimsDetail(data: any, _t: any) {
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
return <RenderClaimsStatic data={data} isRo={isRo} />;
|
||||
}
|
||||
|
||||
const RenderClaimsStatic: React.FC<{ data: any; isRo: boolean }> = ({ data, isRo }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
return <>{renderClaimsEditorial(data, isRo, expanded, () => setExpanded(o => !o))}</>;
|
||||
};
|
||||
|
||||
export function renderClaimsSummary(data: any, t: any) {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
{data.total_claims || 0} {t('pipeline.claimsLabel')}
|
||||
{data.verified_false > 0 ? ` · ${data.verified_false} ${t('pipeline.falseLabel')}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
42
web/src/components/PipelineAnalysis/details/domain.tsx
Normal file
42
web/src/components/PipelineAnalysis/details/domain.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import type { FindingAccent } from '../sections/FindingCard';
|
||||
import { FindingCard } from '../sections/FindingCard';
|
||||
|
||||
function domainAccent(trust: number): FindingAccent {
|
||||
if (trust >= 70) return 'success';
|
||||
if (trust >= 40) return 'warning';
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
export function renderDomainDetail(data: any) {
|
||||
if (!data) return null;
|
||||
const trust = data.trust_score ?? 0;
|
||||
const verdictText = enumLabel(data.verdict || '') || 'Unknown';
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
return (
|
||||
<FindingCard
|
||||
icon={Globe}
|
||||
accent={domainAccent(trust)}
|
||||
eyebrow={isRo ? `Domeniu · trust ${trust}` : `Domain · trust ${trust}`}
|
||||
headline={typeof data.domain === 'string' ? data.domain : data.domain?.name || (isRo ? 'Necunoscut' : 'Unknown')}
|
||||
quote={data.category}
|
||||
source={
|
||||
<span>{verdictText}</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderDomainSummary(data: any) {
|
||||
if (!data) return null;
|
||||
const trust = parseFloat(data.trust_score);
|
||||
const validTrust = !isNaN(trust) && trust >= 0;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
{data.domain && typeof data.domain === 'string' ? `${data.domain} · ` : ''}
|
||||
{validTrust ? `Trust ${trust}%` : 'Trust N/A'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
136
web/src/components/PipelineAnalysis/details/source.tsx
Normal file
136
web/src/components/PipelineAnalysis/details/source.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import React from 'react';
|
||||
import { ShieldAlert, AlertTriangle } from 'lucide-react';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import type { FindingAccent } from '../sections/FindingCard';
|
||||
import {
|
||||
FindingCardBox, FindingIconBox, FindingBody,
|
||||
FindingEyebrow, FindingHeadline, FindingSourceRow,
|
||||
ScoresBlock, ScoreLine, ScoreLineName, ScoreLineBar, ScoreLineFill, ScoreLineNum,
|
||||
} from '../styles';
|
||||
import { SectionDivider } from '../sections/SectionDivider';
|
||||
|
||||
const ACCENT_HEX: Record<FindingAccent, string> = {
|
||||
critical: '#ef4444',
|
||||
warning: '#f97316',
|
||||
info: '#3b82f6',
|
||||
success: '#22c55e',
|
||||
neutral: '#94a3b8',
|
||||
violet: '#7fd0d4',
|
||||
};
|
||||
|
||||
function trustAccent(trust: number): FindingAccent {
|
||||
if (trust >= 70) return 'success';
|
||||
if (trust >= 40) return 'warning';
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
function barColor(score: number): string {
|
||||
return score >= 70 ? '#22c55e' : score >= 40 ? '#f97316' : '#ef4444';
|
||||
}
|
||||
|
||||
export function renderSourceEditorial(data: any, isRo: boolean) {
|
||||
if (!data) return null;
|
||||
const trust = data.trust_score ?? 0;
|
||||
const accent = trustAccent(trust);
|
||||
const accentHex = ACCENT_HEX[accent];
|
||||
|
||||
const verdictLabel = enumLabel(data.verdict || '').replace(/_/g, ' ');
|
||||
const riskLevel = enumLabel(data.risk_level || '');
|
||||
|
||||
const pub = data.publication || {};
|
||||
const auth = data.author || {};
|
||||
const plat = data.platform || {};
|
||||
const dom = data.domain || {};
|
||||
const formula = data.formula || {};
|
||||
void formula;
|
||||
|
||||
const headline = isRo
|
||||
? `${verdictLabel} — încredere ${trust}/100${riskLevel ? ` · risc ${riskLevel.toLowerCase()}` : ''}`
|
||||
: `${verdictLabel} — trust ${trust}/100${riskLevel ? ` · ${riskLevel.toLowerCase()} risk` : ''}`;
|
||||
|
||||
const axes = [
|
||||
{
|
||||
name: isRo ? 'Publicație' : 'Publication',
|
||||
score: pub.score ?? 0,
|
||||
weight: Math.round((formula.publication_weight ?? 0.35) * 100),
|
||||
},
|
||||
{
|
||||
name: isRo ? 'Autor' : 'Author',
|
||||
score: auth.score ?? 0,
|
||||
weight: Math.round((formula.author_weight ?? 0.25) * 100),
|
||||
},
|
||||
{
|
||||
name: isRo ? 'Platformă' : 'Platform',
|
||||
score: plat.score ?? 0,
|
||||
weight: Math.round((formula.platform_weight ?? 0.15) * 100),
|
||||
},
|
||||
{
|
||||
name: isRo ? 'Domeniu' : 'Domain',
|
||||
score: dom.score ?? 0,
|
||||
weight: Math.round((formula.domain_weight ?? 0.25) * 100),
|
||||
},
|
||||
];
|
||||
|
||||
const flags: string[] = [];
|
||||
if (!pub.confirmed) flags.push(isRo ? 'fără publicație confirmată' : 'no confirmed publication');
|
||||
if (!auth.confirmed) flags.push(isRo ? 'fără autor identificat' : 'no identified author');
|
||||
if (!dom.name || dom.name === 'N/A') flags.push(isRo ? 'fără domeniu' : 'no domain');
|
||||
if (data.red_flags?.length) flags.push(...data.red_flags.map((f: string) => f.replace(/_/g, ' ').toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Evaluarea sursei' : 'Source assessment'}
|
||||
count={`${trust} / 100 · ${verdictLabel.toLowerCase()}`}
|
||||
/>
|
||||
<FindingCardBox>
|
||||
<FindingIconBox accent={accentHex} lg>
|
||||
<ShieldAlert size={20} strokeWidth={2} />
|
||||
</FindingIconBox>
|
||||
<FindingBody>
|
||||
<FindingEyebrow>{verdictLabel}</FindingEyebrow>
|
||||
<FindingHeadline>{headline}</FindingHeadline>
|
||||
|
||||
<ScoresBlock style={{ marginTop: 4 }}>
|
||||
{axes.map((ax, i) => (
|
||||
<ScoreLine key={ax.name}>
|
||||
<ScoreLineName>{ax.name}</ScoreLineName>
|
||||
<ScoreLineBar>
|
||||
<ScoreLineFill width={ax.score} color={barColor(ax.score)} delay={400 + i * 60} />
|
||||
</ScoreLineBar>
|
||||
<ScoreLineNum>{ax.score}</ScoreLineNum>
|
||||
</ScoreLine>
|
||||
))}
|
||||
</ScoresBlock>
|
||||
|
||||
{flags.length > 0 && (
|
||||
<FindingSourceRow>
|
||||
<AlertTriangle size={13} strokeWidth={2} />
|
||||
{flags.map((f, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <span className="sep">·</span>}
|
||||
<span>{f}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</FindingSourceRow>
|
||||
)}
|
||||
</FindingBody>
|
||||
</FindingCardBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSourceDetail(data: any) {
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
return <>{renderSourceEditorial(data, isRo)}</>;
|
||||
}
|
||||
|
||||
export function renderSourceSummary(data: any) {
|
||||
if (!data) return null;
|
||||
const trust = data.trust_score ?? 0;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
Trust {trust}% · {enumLabel(data.verdict || '').replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
131
web/src/components/PipelineAnalysis/details/techniques.tsx
Normal file
131
web/src/components/PipelineAnalysis/details/techniques.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import React from 'react';
|
||||
import { AlertCircle, Cpu, Layers, Target, Plus, ChevronDown } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { TechniqueDefinitionsService, type TechniqueDefinition } from '../../../services/technique-definitions.service';
|
||||
import { localized } from '../../../utils/i18n-fields';
|
||||
import { formatTechName } from '../utils';
|
||||
import { FindingCard, type FindingAccent } from '../sections/FindingCard';
|
||||
import { SectionDivider } from '../sections/SectionDivider';
|
||||
import { CollapseBtn, CollapseBtnLeft, CollapseBtnChevron } from '../styles';
|
||||
|
||||
const DEFAULT_VISIBLE = 5;
|
||||
|
||||
/** Map tech name dimension prefix → icon. */
|
||||
function techIcon(name: string): LucideIcon {
|
||||
if (!name) return AlertCircle;
|
||||
const dim = name.split('.')[0]?.toUpperCase();
|
||||
if (dim === 'D3') return Cpu;
|
||||
if (dim === 'D5') return Layers;
|
||||
if (dim === 'D8') return Target;
|
||||
return AlertCircle;
|
||||
}
|
||||
|
||||
function techAccent(severity: number): FindingAccent {
|
||||
if (severity >= 70) return 'critical';
|
||||
if (severity >= 40) return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/** Editorial renderer used inside Verdict (Tier 4 expanded). */
|
||||
export function renderTechniquesEditorial(
|
||||
data: any,
|
||||
definitions: TechniqueDefinition[],
|
||||
isRo: boolean,
|
||||
expanded: boolean,
|
||||
onToggle: () => void,
|
||||
) {
|
||||
if (!data) return null;
|
||||
const techs = data.techniques_detected || [];
|
||||
if (techs.length === 0) return null;
|
||||
|
||||
const dims = (data.dimensions_affected || []).join(' · ');
|
||||
const total = techs.length;
|
||||
const visible = expanded ? total : Math.min(DEFAULT_VISIBLE, total);
|
||||
const shown = techs.slice(0, visible);
|
||||
const remaining = total - visible;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Tehnici de manipulare' : 'Manipulation techniques'}
|
||||
count={`${total} ${isRo ? 'detectate' : 'detected'}${dims ? ` · ${dims}` : ''}`}
|
||||
/>
|
||||
{shown.map((tech: any, i: number) => {
|
||||
const def = TechniqueDefinitionsService.findByName(definitions, tech.name || '');
|
||||
const dimName = localized(tech, 'dimension_name', 'dimension');
|
||||
const subdimName = localized(tech, 'subdimension_name', 'subdimension');
|
||||
const eyebrowParts = [
|
||||
tech.name?.split('.')[0]?.toUpperCase(),
|
||||
dimName ? (subdimName ? `${dimName} · ${subdimName}` : dimName) : null,
|
||||
tech.severity != null ? `${isRo ? 'severitate' : 'severity'} ${tech.severity}` : null,
|
||||
].filter(Boolean);
|
||||
const description = def ? (localized(def, 'description') as string | undefined) : undefined;
|
||||
return (
|
||||
<FindingCard
|
||||
key={i}
|
||||
icon={techIcon(tech.name || '')}
|
||||
accent={techAccent(tech.severity || 0)}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={formatTechName(localized(tech, 'technique_name', 'name'))}
|
||||
info={description}
|
||||
quote={tech.evidence}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<CollapseBtn onClick={onToggle}>
|
||||
<CollapseBtnLeft>
|
||||
<Plus size={16} strokeWidth={2} />
|
||||
<span>
|
||||
{isRo
|
||||
? `Vezi celelalte ${remaining} ${remaining === 1 ? 'tehnică' : 'tehnici'}`
|
||||
: `Show ${remaining} more ${remaining === 1 ? 'technique' : 'techniques'}`}
|
||||
</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={false}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
{expanded && total > DEFAULT_VISIBLE && (
|
||||
<CollapseBtn onClick={onToggle}>
|
||||
<CollapseBtnLeft>
|
||||
<ChevronDown size={16} strokeWidth={2} />
|
||||
<span>{isRo ? 'Ascunde' : 'Hide'}</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={true}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backward-compat wrapper for History.tsx. */
|
||||
export function renderTechniquesDetail(data: any, definitions: TechniqueDefinition[] = [], _t: any) {
|
||||
// Pages like History stand in for `t` but we rely on document.documentElement lang
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
// Render expanded by default in static history view (no toggle needed there)
|
||||
return (
|
||||
<RenderTechniquesStatic data={data} definitions={definitions} isRo={isRo} />
|
||||
);
|
||||
}
|
||||
|
||||
const RenderTechniquesStatic: React.FC<{ data: any; definitions: TechniqueDefinition[]; isRo: boolean }> = ({ data, definitions, isRo }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
return <>{renderTechniquesEditorial(data, definitions, isRo, expanded, () => setExpanded(o => !o))}</>;
|
||||
};
|
||||
|
||||
/** Summary chips used in compact lists (preserved for legacy callers). */
|
||||
export function renderTechniquesSummary(data: any, t: any) {
|
||||
if (!data) return null;
|
||||
const count = (data.techniques_detected || []).length;
|
||||
if (count === 0) return null;
|
||||
const score = typeof data.manipulation_score === 'number' ? data.manipulation_score : (parseFloat(data.manipulation_score) || 0);
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
{count} {t('pipeline.techniqueCount')}{score > 0 ? ` · ${t('common.score')} ${score.toFixed(0)}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
1
web/src/components/PipelineAnalysis/index.ts
Normal file
1
web/src/components/PipelineAnalysis/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { PipelineAnalysis } from './PipelineAnalysis';
|
||||
357
web/src/components/PipelineAnalysis/sections/AiResults.tsx
Normal file
357
web/src/components/PipelineAnalysis/sections/AiResults.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
Cpu, ShieldCheck, ShieldAlert, AlertOctagon, AlertTriangle, AlertCircle,
|
||||
FileText, Image as ImageIcon, Sparkles, ChevronDown, Plus,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { typography } from '../../../theme';
|
||||
import { localized } from '../../../utils/i18n-fields';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import type { AiTamperedResult, AiIndicatorDetected } from '../../../types/analysis-session';
|
||||
import { HeadlineCard } from './HeadlineCard';
|
||||
import { FindingCard, type FindingAccent } from './FindingCard';
|
||||
import { SectionDivider } from './SectionDivider';
|
||||
import {
|
||||
StatBlock, ChipsRow, Chip,
|
||||
CollapseBtn, CollapseBtnLeft, CollapseBtnChevron,
|
||||
} from '../styles';
|
||||
|
||||
const probAccent = (p: number): string =>
|
||||
p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e';
|
||||
|
||||
const probIcon = (p: number): LucideIcon => {
|
||||
if (p >= 80) return AlertOctagon;
|
||||
if (p >= 60) return AlertTriangle;
|
||||
if (p >= 40) return Cpu;
|
||||
return ShieldCheck;
|
||||
};
|
||||
|
||||
const indicatorAccent = (confidence: number): FindingAccent => {
|
||||
if (confidence >= 80) return 'critical';
|
||||
if (confidence >= 60) return 'warning';
|
||||
if (confidence >= 40) return 'info';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const verdictLabelKey = (verdict: string): string => {
|
||||
switch (verdict) {
|
||||
case 'LIKELY_AI': return 'aiTamper.verdicts.likelyAi';
|
||||
case 'POSSIBLY_AI': return 'aiTamper.verdicts.possiblyAi';
|
||||
case 'UNLIKELY_AI': return 'aiTamper.verdicts.unlikelyAi';
|
||||
case 'LIKELY_HUMAN': return 'aiTamper.verdicts.likelyHuman';
|
||||
case 'HUMAN': return 'aiTamper.verdicts.humanWritten';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const CATEGORY_LABEL_KEYS: Record<string, string> = {
|
||||
T1: 'aiTamper.categories.writingStyle',
|
||||
T2: 'aiTamper.categories.contentPatterns',
|
||||
T3: 'aiTamper.categories.structuralAnalysis',
|
||||
T4: 'aiTamper.categories.statisticalMarkers',
|
||||
T5: 'aiTamper.categories.explicitSignals',
|
||||
};
|
||||
|
||||
const DEFAULT_VISIBLE_INDICATORS = 5;
|
||||
|
||||
/** Display-friendly shape that flattens AiTamperedResult variations across modes. */
|
||||
export interface AiDisplayResult {
|
||||
verdict: string;
|
||||
ai_probability: number;
|
||||
disclosure_detected?: boolean;
|
||||
disclosure_explicit?: boolean;
|
||||
disclosure_text?: string | null;
|
||||
/** Quick mode text indicators */
|
||||
indicators_found?: string[];
|
||||
/** Deep mode text + audio/video indicators */
|
||||
indicators_detected?: AiIndicatorDetected[];
|
||||
categories_affected?: string[];
|
||||
coupling_context?: AiTamperedResult['coupling_context'];
|
||||
image_indicators?: string[];
|
||||
image_evidence?: string;
|
||||
model_used?: string;
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
/** Build AiDisplayResult from a raw AiTamperedResult + optional input meta. */
|
||||
export function toAiDisplayResult(
|
||||
r: AiTamperedResult,
|
||||
ctx: { inputType?: string; inputText?: string | null } = {},
|
||||
): AiDisplayResult {
|
||||
const out: AiDisplayResult = {
|
||||
verdict: r.verdict,
|
||||
ai_probability: r.ai_probability,
|
||||
disclosure_detected: r.disclosure_detected,
|
||||
disclosure_explicit: r.disclosure_explicit,
|
||||
disclosure_text: r.disclosure_text,
|
||||
};
|
||||
if (r.indicators_detected && r.indicators_detected.length > 0) {
|
||||
out.indicators_detected = r.indicators_detected;
|
||||
out.categories_affected = r.categories_affected;
|
||||
out.coupling_context = r.coupling_context;
|
||||
}
|
||||
if (r.image_analysis?.indicators && r.image_analysis.indicators.length > 0) {
|
||||
out.image_indicators = r.image_analysis.indicators;
|
||||
out.image_evidence = r.image_analysis.evidence;
|
||||
out.model_used = r.image_analysis.model_used;
|
||||
}
|
||||
if (ctx.inputText && (ctx.inputType === 'audio' || ctx.inputType === 'video')) {
|
||||
out.transcript = ctx.inputText;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
result: AiDisplayResult;
|
||||
isRo: boolean;
|
||||
/** When set, a "try deep analysis" CTA is shown inside the clean-content card. Used by the live standalone where the user can re-run in deep mode. History view should omit this. */
|
||||
onTryDeepScan?: () => void;
|
||||
}
|
||||
|
||||
export const AiResults: React.FC<Props> = ({ result, isRo, onTryDeepScan }) => {
|
||||
const { t } = useTranslation();
|
||||
const [indicatorsExpand, setIndicatorsExpand] = useState(false);
|
||||
|
||||
const probability = result.ai_probability || 0;
|
||||
const verdict = result.verdict || '';
|
||||
const accent = probAccent(probability);
|
||||
const Icon = probIcon(probability);
|
||||
|
||||
const verdictText = verdictLabelKey(verdict)
|
||||
? t(verdictLabelKey(verdict))
|
||||
: (verdict || '').replace(/_/g, ' ');
|
||||
|
||||
const tldr = (() => {
|
||||
const indCount = result.indicators_detected?.length
|
||||
|| result.indicators_found?.length
|
||||
|| result.image_indicators?.length
|
||||
|| 0;
|
||||
const disclosureNote = result.disclosure_detected === true
|
||||
? (isRo ? ' cu declarație AI' : ' with AI disclosure')
|
||||
: result.disclosure_detected === false
|
||||
? (isRo ? ' fără declarație AI' : ' without AI disclosure')
|
||||
: '';
|
||||
if (isRo) {
|
||||
return `Probabilitate ${probability}% de generare AI${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detectat' : 'indicatori detectați'}` : ''}.`;
|
||||
}
|
||||
return `${probability}% likelihood of AI generation${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detected' : 'indicators detected'}` : ''}.`;
|
||||
})();
|
||||
|
||||
const totalIndicators = result.indicators_detected?.length ?? 0;
|
||||
const visibleCount = indicatorsExpand ? totalIndicators : Math.min(DEFAULT_VISIBLE_INDICATORS, totalIndicators);
|
||||
const visibleIndicators = result.indicators_detected?.slice(0, visibleCount) ?? [];
|
||||
const remainingIndicators = totalIndicators - visibleCount;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={probability}
|
||||
scoreLabel="% AI"
|
||||
eyebrow={isRo ? 'Detectare AI' : 'AI detection'}
|
||||
icon={Icon}
|
||||
category={verdictText}
|
||||
descriptor={
|
||||
<>
|
||||
{result.coupling_context?.for_verdict?.confidence_level && (
|
||||
<>
|
||||
<span>{isRo ? 'încredere' : 'confidence'} {enumLabel(result.coupling_context.for_verdict.confidence_level).toLowerCase()}</span>
|
||||
{result.disclosure_detected !== undefined && <span className="sep">·</span>}
|
||||
</>
|
||||
)}
|
||||
{result.disclosure_detected === true && (
|
||||
<span>{isRo ? 'cu declarație AI' : 'AI disclosure'}</span>
|
||||
)}
|
||||
{result.disclosure_detected === false && (
|
||||
<span>{isRo ? 'fără declarație AI' : 'no AI disclosure'}</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
tldr={tldr}
|
||||
/>
|
||||
|
||||
{(result.coupling_context?.for_verdict || (result.categories_affected && result.categories_affected.length > 0) || result.model_used) && (
|
||||
<StatBlock>
|
||||
<ChipsRow>
|
||||
{result.coupling_context?.for_verdict?.undisclosed_ai && (
|
||||
<Chip variant="warning">{t('aiTamper.undisclosedAi')}</Chip>
|
||||
)}
|
||||
{result.coupling_context?.for_verdict?.needs_manual_review && (
|
||||
<Chip variant="warning">{t('aiTamper.needsManualReview')}</Chip>
|
||||
)}
|
||||
{result.categories_affected?.map(cat => (
|
||||
<Chip key={cat} variant="violet">
|
||||
{cat} · {CATEGORY_LABEL_KEYS[cat] ? t(CATEGORY_LABEL_KEYS[cat]) : cat}
|
||||
</Chip>
|
||||
))}
|
||||
{result.model_used && (
|
||||
<Chip variant="neutral">{result.model_used}</Chip>
|
||||
)}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
)}
|
||||
|
||||
{result.disclosure_detected !== undefined && (
|
||||
<FindingCard
|
||||
icon={result.disclosure_detected ? ShieldCheck : ShieldAlert}
|
||||
accent={result.disclosure_detected ? 'success' : 'neutral'}
|
||||
eyebrow={isRo ? 'Declarație AI' : 'AI disclosure'}
|
||||
headline={
|
||||
result.disclosure_detected
|
||||
? (result.disclosure_explicit ? t('aiTamper.explicitDisclosure') : t('aiTamper.implicitDisclosure'))
|
||||
: t('aiTamper.noDisclosureDetected')
|
||||
}
|
||||
quote={result.disclosure_text || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result.image_evidence && (
|
||||
<FindingCard
|
||||
icon={ImageIcon}
|
||||
accent="info"
|
||||
eyebrow={isRo ? 'Analiză imagine' : 'Image analysis'}
|
||||
headline={t('aiTamper.analysisSummary')}
|
||||
quote={result.image_evidence}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result.transcript && (
|
||||
<FindingCard
|
||||
icon={FileText}
|
||||
accent="info"
|
||||
eyebrow={t('aiTamper.transcript')}
|
||||
headline={isRo ? 'Conținut transcris' : 'Transcribed content'}
|
||||
quote={result.transcript.length > 320
|
||||
? result.transcript.slice(0, 320) + '…'
|
||||
: result.transcript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result.indicators_found && result.indicators_found.length > 0 && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Semnale detectate' : 'Signals detected'}
|
||||
count={result.indicators_found.length}
|
||||
/>
|
||||
{result.indicators_found.map((ind, i) => (
|
||||
<FindingCard
|
||||
key={i}
|
||||
icon={AlertCircle}
|
||||
accent="info"
|
||||
headline={ind}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{result.image_indicators && result.image_indicators.length > 0 && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={t('aiTamper.imageIndicators')}
|
||||
count={result.image_indicators.length}
|
||||
/>
|
||||
{result.image_indicators.map((ind, i) => (
|
||||
<FindingCard
|
||||
key={i}
|
||||
icon={Sparkles}
|
||||
accent="warning"
|
||||
headline={ind}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{totalIndicators > 0 && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Indicatori detectați' : 'Indicators detected'}
|
||||
count={`${totalIndicators} ${isRo ? 'detectați' : 'detected'}${result.categories_affected?.length ? ` · ${result.categories_affected.join(' · ')}` : ''}`}
|
||||
/>
|
||||
{visibleIndicators.map(ind => {
|
||||
const catLabel = CATEGORY_LABEL_KEYS[ind.category]
|
||||
? t(CATEGORY_LABEL_KEYS[ind.category])
|
||||
: ind.category;
|
||||
const eyebrowParts = [
|
||||
ind.category,
|
||||
catLabel,
|
||||
`${isRo ? 'încredere' : 'confidence'} ${ind.confidence}%`,
|
||||
];
|
||||
return (
|
||||
<FindingCard
|
||||
key={ind.id}
|
||||
icon={AlertCircle}
|
||||
accent={indicatorAccent(ind.confidence)}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={localized(ind, 'indicator_name', 'name')}
|
||||
quote={ind.evidence}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{remainingIndicators > 0 && (
|
||||
<CollapseBtn onClick={() => setIndicatorsExpand(true)}>
|
||||
<CollapseBtnLeft>
|
||||
<Plus size={16} strokeWidth={2} />
|
||||
<span>
|
||||
{isRo
|
||||
? `Vezi celelalți ${remainingIndicators} ${remainingIndicators === 1 ? 'indicator' : 'indicatori'}`
|
||||
: `Show ${remainingIndicators} more ${remainingIndicators === 1 ? 'indicator' : 'indicators'}`}
|
||||
</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={false}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
{indicatorsExpand && totalIndicators > DEFAULT_VISIBLE_INDICATORS && (
|
||||
<CollapseBtn onClick={() => setIndicatorsExpand(false)}>
|
||||
<CollapseBtnLeft>
|
||||
<ChevronDown size={16} strokeWidth={2} />
|
||||
<span>{isRo ? 'Ascunde' : 'Hide'}</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={true}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(result.indicators_found?.length === 0
|
||||
&& !result.indicators_detected
|
||||
&& !result.image_indicators?.length) && (
|
||||
<FindingCard
|
||||
icon={ShieldCheck}
|
||||
accent="success"
|
||||
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
|
||||
headline={t('aiTamper.noSignalsQuick')}
|
||||
source={
|
||||
onTryDeepScan ? (
|
||||
<DeepScanHint onClick={onTryDeepScan}>
|
||||
{t('aiTamper.tryDeepAnalysis')}
|
||||
</DeepScanHint>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DeepScanHint = styled.button`
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: 12px;
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--accent-text);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
`;
|
||||
112
web/src/components/PipelineAnalysis/sections/ClaimsResults.tsx
Normal file
112
web/src/components/PipelineAnalysis/sections/ClaimsResults.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
CheckSquare, XCircle, AlertTriangle, HelpCircle, Search, Timer,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ClaimsResult } from '../../../types/analysis-session';
|
||||
import { HeadlineCard } from './HeadlineCard';
|
||||
import { renderClaimsEditorial } from '../details/claims';
|
||||
import { StatBlock, ChipsRow, Chip } from '../styles';
|
||||
|
||||
const credAccent = (s: number): string =>
|
||||
s >= 60 ? '#22c55e' : s >= 40 ? '#eab308' : s >= 20 ? '#f97316' : '#ef4444';
|
||||
|
||||
const credIcon = (s: number): LucideIcon => {
|
||||
if (s >= 60) return CheckSquare;
|
||||
if (s >= 40) return HelpCircle;
|
||||
if (s >= 20) return AlertTriangle;
|
||||
return XCircle;
|
||||
};
|
||||
|
||||
const buildTldr = (r: ClaimsResult, percent: number, isRo: boolean): string => {
|
||||
const total = r.total_claims || r.claims_verified.length || 0;
|
||||
if (total === 0) {
|
||||
return isRo
|
||||
? 'Nicio afirmație verificabilă în acest conținut.'
|
||||
: 'No verifiable claims found in this content.';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (r.verified_true > 0) parts.push(`${r.verified_true} ${isRo ? 'adevărate' : 'true'}`);
|
||||
if (r.verified_false > 0) parts.push(`${r.verified_false} ${isRo ? 'false' : 'false'}`);
|
||||
if (r.unverified > 0) parts.push(`${r.unverified} ${isRo ? 'neverificate' : 'unverified'}`);
|
||||
if (r.opinions > 0) parts.push(`${r.opinions} ${isRo ? 'opinii' : 'opinions'}`);
|
||||
return isRo
|
||||
? `${total} ${total === 1 ? 'afirmație' : 'afirmații'} analizate (${parts.join(', ')}). Scor de credibilitate: ${percent}%.`
|
||||
: `${total} ${total === 1 ? 'claim' : 'claims'} analyzed (${parts.join(', ')}). Credibility score: ${percent}%.`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
result: ClaimsResult;
|
||||
isRo: boolean;
|
||||
}
|
||||
|
||||
export const ClaimsResults: React.FC<Props> = ({ result, isRo }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const rawCred = parseFloat(String(result.credibility_score)) || 0;
|
||||
const credPercent = Math.round(rawCred > 1 ? rawCred : rawCred * 100);
|
||||
const accent = credAccent(credPercent);
|
||||
const Icon = credIcon(credPercent);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={credPercent}
|
||||
scoreLabel={isRo ? '% CREDIBIL' : '% CREDIBLE'}
|
||||
eyebrow={isRo ? 'Verificarea afirmațiilor' : 'Claim verification'}
|
||||
icon={Icon}
|
||||
category={result.interpretation || (isRo ? 'Analiză afirmații' : 'Claim analysis')}
|
||||
descriptor={
|
||||
<span>
|
||||
{result.total_claims} {isRo
|
||||
? (result.total_claims === 1 ? 'afirmație' : 'afirmații')
|
||||
: (result.total_claims === 1 ? 'claim' : 'claims')}
|
||||
</span>
|
||||
}
|
||||
tldr={buildTldr(result, credPercent, isRo)}
|
||||
/>
|
||||
|
||||
{result.total_claims > 0 && (
|
||||
<StatBlock>
|
||||
<ChipsRow>
|
||||
{result.verified_true > 0 && (
|
||||
<Chip variant="success">
|
||||
{result.verified_true} {isRo ? 'adevărate' : 'verified true'}
|
||||
</Chip>
|
||||
)}
|
||||
{result.verified_false > 0 && (
|
||||
<Chip variant="critical">
|
||||
{result.verified_false} {isRo ? 'false' : 'verified false'}
|
||||
</Chip>
|
||||
)}
|
||||
{result.unverified > 0 && (
|
||||
<Chip variant="neutral">
|
||||
{result.unverified} {isRo ? 'neverificate' : 'unverified'}
|
||||
</Chip>
|
||||
)}
|
||||
{result.opinions > 0 && (
|
||||
<Chip variant="info">
|
||||
{result.opinions} {isRo ? 'opinii' : 'opinions'}
|
||||
</Chip>
|
||||
)}
|
||||
{result.web_searches_made > 0 && (
|
||||
<Chip variant="violet">
|
||||
<Search size={12} strokeWidth={2} />
|
||||
{result.web_searches_made} {isRo ? 'căutări web' : 'web searches'}
|
||||
</Chip>
|
||||
)}
|
||||
{result.total_duration_ms > 0 && (
|
||||
<Chip variant="neutral">
|
||||
<Timer size={12} strokeWidth={2} />
|
||||
{(result.total_duration_ms / 1000).toFixed(1)}s
|
||||
</Chip>
|
||||
)}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
)}
|
||||
|
||||
{renderClaimsEditorial(result, isRo, expanded, () => setExpanded(o => !o))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TechniqueDefinition } from '../../../services/technique-definitions.service';
|
||||
import type { ComponentStatus } from '../types';
|
||||
import { COMPONENT_LABEL_KEYS, COMPONENT_ORDER, STATUS_ICON } from '../utils';
|
||||
import {
|
||||
ComponentsGrid, ComponentCard, ComponentHeader, ComponentLeft, ComponentRight,
|
||||
StatusIcon, ComponentName, DurationBadge, MiniSpinner, FailedBadge, ExpandArrow,
|
||||
ComponentSummary, ComponentDetail,
|
||||
} from '../styles';
|
||||
import { renderTechniquesSummary, renderTechniquesDetail } from '../details/techniques';
|
||||
import { renderAiSummary, renderAiDetail } from '../details/ai';
|
||||
import { renderClaimsSummary, renderClaimsDetail } from '../details/claims';
|
||||
import { renderDomainSummary, renderDomainDetail } from '../details/domain';
|
||||
import { renderSourceSummary, renderSourceDetail } from '../details/source';
|
||||
|
||||
interface Props {
|
||||
components: Record<string, ComponentStatus>;
|
||||
fullResult: Record<string, any> | null;
|
||||
expanded: Set<string>;
|
||||
onToggle: (name: string) => void;
|
||||
techniqueDefinitions: TechniqueDefinition[];
|
||||
}
|
||||
|
||||
export const ComponentList: React.FC<Props> = ({ components, fullResult, expanded, onToggle, techniqueDefinitions }) => {
|
||||
const { t } = useTranslation();
|
||||
const active = COMPONENT_ORDER.filter(c => components[c] && components[c].status !== 'skipped');
|
||||
if (active.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ComponentsGrid>
|
||||
{active.map(name => {
|
||||
const comp = components[name];
|
||||
const isComplete = comp.status === 'completed';
|
||||
const isFailed = comp.status === 'failed';
|
||||
const isExpanded = expanded.has(name);
|
||||
const result = fullResult?.[name] || (comp as any).result;
|
||||
const hasResult = isComplete && !!result;
|
||||
|
||||
return (
|
||||
<ComponentCard
|
||||
key={name}
|
||||
status={comp.status}
|
||||
onClick={() => hasResult && onToggle(name)}
|
||||
clickable={!!hasResult}
|
||||
>
|
||||
<ComponentHeader>
|
||||
<ComponentLeft>
|
||||
<StatusIcon status={comp.status}>{STATUS_ICON[comp.status]}</StatusIcon>
|
||||
<ComponentName>{COMPONENT_LABEL_KEYS[name] ? t(COMPONENT_LABEL_KEYS[name]) : name}</ComponentName>
|
||||
</ComponentLeft>
|
||||
<ComponentRight>
|
||||
{comp.duration_ms != null && (
|
||||
<DurationBadge>{(comp.duration_ms / 1000).toFixed(1)}s</DurationBadge>
|
||||
)}
|
||||
{comp.status === 'running' && <MiniSpinner />}
|
||||
{isFailed && <FailedBadge>{t('common.failed')}</FailedBadge>}
|
||||
{hasResult && <ExpandArrow expanded={isExpanded} />}
|
||||
</ComponentRight>
|
||||
</ComponentHeader>
|
||||
|
||||
{isComplete && !isExpanded && (
|
||||
<ComponentSummary>
|
||||
{name === 'techniques' && renderTechniquesSummary(result, t)}
|
||||
{name === 'ai_tampered' && renderAiSummary(result, t)}
|
||||
{name === 'claims' && renderClaimsSummary(result, t)}
|
||||
{name === 'domain' && renderDomainSummary(result)}
|
||||
{name === 'source_assessment' && renderSourceSummary(result)}
|
||||
</ComponentSummary>
|
||||
)}
|
||||
|
||||
{isExpanded && hasResult && (
|
||||
<ComponentDetail onClick={e => e.stopPropagation()}>
|
||||
{name === 'techniques' && renderTechniquesDetail(result, techniqueDefinitions, t)}
|
||||
{name === 'ai_tampered' && renderAiDetail(result, t)}
|
||||
{name === 'claims' && renderClaimsDetail(result, t)}
|
||||
{name === 'domain' && renderDomainDetail(result)}
|
||||
{name === 'source_assessment' && renderSourceDetail(result)}
|
||||
</ComponentDetail>
|
||||
)}
|
||||
</ComponentCard>
|
||||
);
|
||||
})}
|
||||
</ComponentsGrid>
|
||||
);
|
||||
};
|
||||
80
web/src/components/PipelineAnalysis/sections/FindingCard.tsx
Normal file
80
web/src/components/PipelineAnalysis/sections/FindingCard.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import React from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
FindingCardBox, FindingIconBox, FindingBody,
|
||||
FindingEyebrow, FindingHeadline, FindingQuote, FindingSourceRow,
|
||||
} from '../styles';
|
||||
|
||||
export type FindingAccent = 'critical' | 'warning' | 'info' | 'success' | 'neutral' | 'violet';
|
||||
|
||||
const ACCENT_HEX: Record<FindingAccent, string> = {
|
||||
critical: '#ef4444',
|
||||
warning: '#f97316',
|
||||
info: '#3b82f6',
|
||||
success: '#22c55e',
|
||||
neutral: '#94a3b8',
|
||||
violet: '#7fd0d4',
|
||||
};
|
||||
|
||||
const HeadlineRow = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const InfoBubble = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-text);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
&:hover { background: var(--accent-subtle); }
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
icon: LucideIcon;
|
||||
accent?: FindingAccent;
|
||||
dominant?: boolean;
|
||||
iconLg?: boolean;
|
||||
eyebrow?: React.ReactNode;
|
||||
headline: React.ReactNode;
|
||||
/** Optional explanation shown via a small ⓘ next to the headline (native browser tooltip). */
|
||||
info?: string;
|
||||
quote?: React.ReactNode;
|
||||
source?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FindingCard: React.FC<Props> = ({
|
||||
icon: Icon, accent = 'critical', dominant, iconLg,
|
||||
eyebrow, headline, info, quote, source,
|
||||
}) => (
|
||||
<FindingCardBox dominant={dominant}>
|
||||
<FindingIconBox accent={ACCENT_HEX[accent]} lg={iconLg || dominant}>
|
||||
<Icon size={iconLg || dominant ? 20 : 18} strokeWidth={2} />
|
||||
</FindingIconBox>
|
||||
<FindingBody>
|
||||
{eyebrow && <FindingEyebrow>{eyebrow}</FindingEyebrow>}
|
||||
<FindingHeadline dominant={dominant}>
|
||||
<HeadlineRow>
|
||||
{headline}
|
||||
{info && (
|
||||
<InfoBubble title={info} aria-label={info}>
|
||||
<Info size={11} strokeWidth={2.5} />
|
||||
</InfoBubble>
|
||||
)}
|
||||
</HeadlineRow>
|
||||
</FindingHeadline>
|
||||
{quote && <FindingQuote>{quote}</FindingQuote>}
|
||||
{source && <FindingSourceRow>{source}</FindingSourceRow>}
|
||||
</FindingBody>
|
||||
</FindingCardBox>
|
||||
);
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
HeadlineCard as HeadlineCardBox,
|
||||
GaugeCol, Gauge, GaugeTrack, GaugeFill, GaugeCenter, GaugeScore, GaugeOf,
|
||||
HeadlineContent, HeadlineEyebrow, CategoryRow, CategoryIconBox, CategoryName, CategoryDesc,
|
||||
TldrText,
|
||||
} from '../styles';
|
||||
|
||||
interface Props {
|
||||
accent: string;
|
||||
score: number;
|
||||
/** Optional secondary line for gauge center, e.g. "/ 100 RISC". */
|
||||
scoreLabel?: string;
|
||||
eyebrow: string;
|
||||
icon: LucideIcon;
|
||||
category: string;
|
||||
/** Inline descriptor next to category (already composed with separators). */
|
||||
descriptor?: React.ReactNode;
|
||||
/** Optional TL;DR paragraph (Merriweather italic). */
|
||||
tldr?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Animated count-up — runs once on mount & on score change. */
|
||||
function useCountUp(target: number, duration = 900, delay = 1100): number {
|
||||
const [val, setVal] = useState(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
let start: number | null = null;
|
||||
const tick = (ts: number) => {
|
||||
if (start == null) start = ts;
|
||||
const t = Math.min(1, (ts - start) / duration);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
setVal(Math.round(target * eased));
|
||||
if (t < 1) rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
const timer = setTimeout(() => { rafRef.current = requestAnimationFrame(tick); }, delay);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [target, duration, delay]);
|
||||
return val;
|
||||
}
|
||||
|
||||
export const HeadlineCard: React.FC<Props> = ({
|
||||
accent, score, scoreLabel, eyebrow, icon: Icon, category, descriptor, tldr,
|
||||
}) => {
|
||||
const animated = useCountUp(score);
|
||||
void animated; // gauge fills via CSS keyframe; static value used; reserved for future
|
||||
return (
|
||||
<HeadlineCardBox accent={accent}>
|
||||
<GaugeCol>
|
||||
<Gauge accent={accent}>
|
||||
<svg viewBox="0 0 168 168">
|
||||
<GaugeTrack cx={84} cy={84} r={80} />
|
||||
<GaugeFill cx={84} cy={84} r={80} scorePercent={score} />
|
||||
</svg>
|
||||
<GaugeCenter>
|
||||
<GaugeScore>{score}</GaugeScore>
|
||||
{scoreLabel && <GaugeOf>{scoreLabel}</GaugeOf>}
|
||||
</GaugeCenter>
|
||||
</Gauge>
|
||||
</GaugeCol>
|
||||
<HeadlineContent>
|
||||
<HeadlineEyebrow>{eyebrow}</HeadlineEyebrow>
|
||||
<CategoryRow>
|
||||
<CategoryIconBox accent={accent}>
|
||||
<Icon size={18} strokeWidth={2.2} />
|
||||
</CategoryIconBox>
|
||||
<CategoryName>{category}</CategoryName>
|
||||
{descriptor && <CategoryDesc>{descriptor}</CategoryDesc>}
|
||||
</CategoryRow>
|
||||
{tldr && <TldrText>{tldr}</TldrText>}
|
||||
</HeadlineContent>
|
||||
</HeadlineCardBox>
|
||||
);
|
||||
};
|
||||
139
web/src/components/PipelineAnalysis/sections/InputForm.tsx
Normal file
139
web/src/components/PipelineAnalysis/sections/InputForm.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { validateText } from '../../../utils/text-validation';
|
||||
import { validateUrl } from '../../../utils/url-validation';
|
||||
import { validateFile } from '../../../utils/file-validation';
|
||||
import { ConsentNotice, ConsentSlot, useMediaConsent } from '../../ConsentNotice';
|
||||
import type { InputType } from '../types';
|
||||
import { INPUT_TYPES } from '../utils';
|
||||
import {
|
||||
TypeSelector, TypeButton,
|
||||
InputArea, TextInput, UrlInputWrapper, UrlInput, UrlHint,
|
||||
FileDropZone, FileSelected, FileName, FileSize, RemoveFileBtn,
|
||||
DropPlaceholder, DropLabel, DropHint, FileErrorMsg,
|
||||
InputFooter, CharCount, AnalyzeBtn, Spinner,
|
||||
} from '../styles';
|
||||
|
||||
interface Props {
|
||||
inputType: InputType;
|
||||
onTypeChange: (t: InputType) => void;
|
||||
inputText: string;
|
||||
onTextChange: (s: string) => void;
|
||||
inputUrl: string;
|
||||
onUrlChange: (s: string) => void;
|
||||
selectedFile: File | null;
|
||||
fileError: string | null;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onRemoveFile: () => void;
|
||||
isRunning: boolean;
|
||||
statusMsg?: string;
|
||||
onAnalyze: () => void;
|
||||
}
|
||||
|
||||
export const InputForm: React.FC<Props> = ({
|
||||
inputType, onTypeChange, inputText, onTextChange, inputUrl, onUrlChange,
|
||||
selectedFile, fileError, fileInputRef, onFileSelect, onRemoveFile,
|
||||
isRunning, statusMsg, onAnalyze,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const textValidation = useMemo(() => validateText(inputText), [inputText]);
|
||||
const urlValidation = useMemo(() => validateUrl(inputUrl), [inputUrl]);
|
||||
const [mediaConsent, setMediaConsent] = useMediaConsent();
|
||||
const isMediaInput = inputType !== 'text' && inputType !== 'url';
|
||||
|
||||
const canAnalyze = inputType === 'text'
|
||||
? textValidation.valid
|
||||
: inputType === 'url'
|
||||
? urlValidation.valid
|
||||
: selectedFile !== null && mediaConsent;
|
||||
|
||||
const charCountStyle =
|
||||
inputType === 'text'
|
||||
? { color: textValidation.level === 'error' ? '#ef4444' : textValidation.level === 'warning' ? '#eab308' : '#22c55e' }
|
||||
: inputType === 'url' && urlValidation.error
|
||||
? { color: '#ef4444' }
|
||||
: inputType === 'url' && urlValidation.valid
|
||||
? { color: '#22c55e' }
|
||||
: undefined;
|
||||
|
||||
const charCountText = inputType === 'text'
|
||||
? (textValidation.error || textValidation.warning || `${inputText.trim().length} characters`)
|
||||
: inputType === 'url'
|
||||
? (urlValidation.error || (inputUrl.trim() ? t('common.validUrl') : t('common.enterUrl')))
|
||||
: selectedFile ? t('common.ready') : t('common.noFileSelected');
|
||||
|
||||
return (
|
||||
<>
|
||||
<TypeSelector>
|
||||
{INPUT_TYPES.map(({ key, labelKey }) => (
|
||||
<TypeButton key={key} active={inputType === key} onClick={() => onTypeChange(key)}>
|
||||
{t(labelKey)}
|
||||
</TypeButton>
|
||||
))}
|
||||
</TypeSelector>
|
||||
|
||||
<InputArea>
|
||||
{inputType === 'text' ? (
|
||||
<TextInput
|
||||
placeholder={t('pipeline.textPlaceholder')}
|
||||
value={inputText}
|
||||
onChange={e => onTextChange(e.target.value)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
) : inputType === 'url' ? (
|
||||
<UrlInputWrapper>
|
||||
<UrlInput
|
||||
type="url"
|
||||
placeholder={t('pipeline.urlPlaceholder')}
|
||||
value={inputUrl}
|
||||
onChange={e => onUrlChange(e.target.value)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<UrlHint>{t('pipeline.urlHint')}</UrlHint>
|
||||
</UrlInputWrapper>
|
||||
) : (
|
||||
<>
|
||||
<FileDropZone>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={INPUT_TYPES.find(t => t.key === inputType)?.accept}
|
||||
onChange={onFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{selectedFile ? (
|
||||
<FileSelected>
|
||||
<FileName>{selectedFile.name}</FileName>
|
||||
<FileSize>{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</FileSize>
|
||||
<RemoveFileBtn onClick={onRemoveFile}>Remove</RemoveFileBtn>
|
||||
</FileSelected>
|
||||
) : (
|
||||
<DropPlaceholder onClick={() => fileInputRef.current?.click()}>
|
||||
<DropLabel>Click to select {inputType} file</DropLabel>
|
||||
<DropHint>
|
||||
{inputType === 'image' && t('pipeline.imageHint')}
|
||||
{inputType === 'audio' && t('pipeline.audioHint')}
|
||||
{inputType === 'video' && t('pipeline.videoHint')}
|
||||
</DropHint>
|
||||
</DropPlaceholder>
|
||||
)}
|
||||
{fileError && <FileErrorMsg>{fileError}</FileErrorMsg>}
|
||||
</FileDropZone>
|
||||
{isMediaInput && (
|
||||
<ConsentSlot>
|
||||
<ConsentNotice consented={mediaConsent} onChange={setMediaConsent} />
|
||||
</ConsentSlot>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<InputFooter>
|
||||
<CharCount style={charCountStyle}>{charCountText}</CharCount>
|
||||
<AnalyzeBtn onClick={onAnalyze} disabled={isRunning || !canAnalyze}>
|
||||
{isRunning ? (<><Spinner />{statusMsg || t('common.analyzing')}</>) : t('pipeline.runFullAnalysis')}
|
||||
</AnalyzeBtn>
|
||||
</InputFooter>
|
||||
</InputArea>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from '@emotion/styled';
|
||||
import { spacing } from '../../../theme';
|
||||
|
||||
interface Props {
|
||||
inputType: string;
|
||||
text: string | null | undefined;
|
||||
}
|
||||
|
||||
/** Split raw input into clean paragraphs. Single \n collapses to space; \n\n+ = new paragraph. */
|
||||
function toParagraphs(raw: string): string[] {
|
||||
return raw.split(/\n{2,}/).map(p => p.replace(/\s*\n\s*/g, ' ').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export const InputPreview: React.FC<Props> = ({ inputType, text }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!text) return null;
|
||||
const paragraphs = toParagraphs(text);
|
||||
return (
|
||||
<Block>
|
||||
<Label>{t('history.inputLabel', { type: inputType })}</Label>
|
||||
<Body>
|
||||
{paragraphs.map((p, i) => <p key={i}>{p}</p>)}
|
||||
</Body>
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
const Block = styled.div`
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
margin-bottom: ${spacing.md}px;
|
||||
`;
|
||||
const Label = styled.div`
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
const Body = styled.div`
|
||||
font-size: 13px; color: var(--fg-secondary); line-height: 1.6;
|
||||
word-break: break-word; overflow-wrap: break-word;
|
||||
max-height: 240px; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
& p { margin: 0; }
|
||||
`;
|
||||
210
web/src/components/PipelineAnalysis/sections/LoadingState.tsx
Normal file
210
web/src/components/PipelineAnalysis/sections/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ComponentStatus } from '../types';
|
||||
import { COMPONENT_LABEL_KEYS, COMPONENT_ORDER } from '../utils';
|
||||
import {
|
||||
ProgressSection, ProgressBar, ProgressFill, ProgressText,
|
||||
LoadingShell, LoadingHeadline, LoadingPulse,
|
||||
LoadingGrid, LoadingTile, LoadingTileTop, LoadingTileLeft,
|
||||
LoadingTileIcon, LoadingTileName, LoadingTileDuration,
|
||||
LoadingTileMicro, LoadingMiniSpinner,
|
||||
LoadingFindings, LoadingFindingsLabel, LoadingFindingsRow, LoadingBullet,
|
||||
} from '../styles';
|
||||
|
||||
interface Props {
|
||||
progress: number;
|
||||
components: Record<string, ComponentStatus>;
|
||||
fullResult: Record<string, any> | null;
|
||||
startedAt: number | null;
|
||||
statusMsg?: string;
|
||||
}
|
||||
|
||||
const ICON: Record<string, string> = {
|
||||
pending: '○',
|
||||
running: '◎',
|
||||
completed: '✓',
|
||||
failed: '✗',
|
||||
skipped: '—',
|
||||
};
|
||||
|
||||
/** Bilingual blurbs per component & state. */
|
||||
const RUNNING_BLURB: Record<string, { ro: string; en: string }> = {
|
||||
techniques: { ro: 'caut tehnici de manipulare…', en: 'scanning rhetoric techniques…' },
|
||||
ai_tampered: { ro: 'evaluez semnalele AI…', en: 'analyzing AI signals…' },
|
||||
claims: { ro: 'verific afirmațiile cu surse web…', en: 'verifying claims with web sources…' },
|
||||
source_assessment: { ro: 'evaluez credibilitatea sursei…', en: 'checking source credibility…' },
|
||||
};
|
||||
|
||||
const PENDING_BLURB: Record<string, { ro: string; en: string }> = {
|
||||
techniques: { ro: 'așteaptă să caute tehnici', en: 'will scan techniques' },
|
||||
ai_tampered: { ro: 'așteaptă să detecteze AI', en: 'will detect AI' },
|
||||
claims: { ro: 'așteaptă verificarea web', en: 'will verify on the web' },
|
||||
source_assessment: { ro: 'așteaptă evaluarea sursei', en: 'will assess source' },
|
||||
};
|
||||
|
||||
function fmtMs(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
/** Build a component-specific micro-stat text from its result (when completed). */
|
||||
function buildDoneMicro(name: string, result: any): string | null {
|
||||
if (!result) return null;
|
||||
switch (name) {
|
||||
case 'techniques': {
|
||||
const count = result.techniques_detected?.length || 0;
|
||||
if (count === 0) return 'no techniques detected';
|
||||
const score = typeof result.manipulation_score === 'number'
|
||||
? result.manipulation_score
|
||||
: parseFloat(result.manipulation_score) || 0;
|
||||
return `${count} detected · score ${score.toFixed(0)}`;
|
||||
}
|
||||
case 'ai_tampered': {
|
||||
const prob = typeof result.ai_probability === 'number'
|
||||
? result.ai_probability
|
||||
: parseFloat(result.ai_probability) || 0;
|
||||
return `${prob.toFixed(0)}% AI probability`;
|
||||
}
|
||||
case 'claims': {
|
||||
const total = result.total_claims || 0;
|
||||
const f = result.verified_false || 0;
|
||||
const t = result.verified_true || 0;
|
||||
if (total === 0) return 'no claims found';
|
||||
return `${total} claims · ${t} true · ${f} false`;
|
||||
}
|
||||
case 'source_assessment': {
|
||||
const trust = result.trust_score;
|
||||
if (trust == null) return 'no source data';
|
||||
return `trust ${trust}%`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the running list of "Detectate până acum" bullets from progressive results. */
|
||||
function buildLiveFindings(result: Record<string, any> | null, isRo: boolean): { color: string; text: string }[] {
|
||||
if (!result) return [];
|
||||
const out: { color: string; text: string }[] = [];
|
||||
|
||||
if (result.techniques?.techniques_detected?.length) {
|
||||
const n = result.techniques.techniques_detected.length;
|
||||
out.push({
|
||||
color: '#ef4444',
|
||||
text: isRo
|
||||
? `${n} ${n === 1 ? 'tehnică de manipulare detectată' : 'tehnici de manipulare detectate'}`
|
||||
: `${n} manipulation ${n === 1 ? 'technique' : 'techniques'} detected`,
|
||||
});
|
||||
}
|
||||
if (result.ai_tampered?.ai_probability != null) {
|
||||
const p = result.ai_tampered.ai_probability;
|
||||
out.push({
|
||||
color: p >= 60 ? '#f97316' : '#22c55e',
|
||||
text: isRo ? `Probabilitate AI: ${p.toFixed(0)}%` : `AI probability: ${p.toFixed(0)}%`,
|
||||
});
|
||||
}
|
||||
if (result.claims?.total_claims != null) {
|
||||
const total = result.claims.total_claims;
|
||||
const f = result.claims.verified_false || 0;
|
||||
if (total > 0) {
|
||||
out.push({
|
||||
color: f > 0 ? '#ef4444' : '#3b82f6',
|
||||
text: isRo
|
||||
? `${total} ${total === 1 ? 'afirmație verificată' : 'afirmații verificate'}${f > 0 ? `, ${f} ${f === 1 ? 'falsă' : 'false'}` : ''}`
|
||||
: `${total} claims verified${f > 0 ? `, ${f} false` : ''}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (result.source_assessment?.trust_score != null) {
|
||||
const t = result.source_assessment.trust_score;
|
||||
out.push({
|
||||
color: t >= 70 ? '#22c55e' : t >= 40 ? '#f97316' : '#ef4444',
|
||||
text: isRo ? `Încredere sursă: ${t}%` : `Source trust: ${t}%`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const LoadingState: React.FC<Props> = ({ progress, components, fullResult, startedAt, statusMsg }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedAt) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 500);
|
||||
return () => clearInterval(id);
|
||||
}, [startedAt]);
|
||||
|
||||
const elapsed = startedAt ? now - startedAt : 0;
|
||||
const findings = buildLiveFindings(fullResult, isRo);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressSection>
|
||||
<ProgressBar><ProgressFill width={progress} /></ProgressBar>
|
||||
<ProgressText>
|
||||
<span>{statusMsg || (isRo ? 'Analiză în curs…' : 'Analyzing…')}</span>
|
||||
<span>{Math.round(progress)}% · {fmtMs(elapsed)}</span>
|
||||
</ProgressText>
|
||||
</ProgressSection>
|
||||
|
||||
<LoadingShell>
|
||||
<LoadingHeadline>
|
||||
<LoadingPulse />
|
||||
{isRo ? 'DIDI analizează textul tău' : 'DIDI is analyzing your input'}
|
||||
</LoadingHeadline>
|
||||
|
||||
<LoadingGrid>
|
||||
{COMPONENT_ORDER.map(name => {
|
||||
const comp = components[name];
|
||||
const status = comp?.status || 'pending';
|
||||
const result = fullResult?.[name];
|
||||
const doneMicro = status === 'completed' ? buildDoneMicro(name, result) : null;
|
||||
const micro = status === 'completed'
|
||||
? (doneMicro || (isRo ? 'finalizat' : 'done'))
|
||||
: status === 'running'
|
||||
? (RUNNING_BLURB[name]?.[isRo ? 'ro' : 'en'] || (isRo ? 'în lucru…' : 'working…'))
|
||||
: status === 'failed'
|
||||
? (isRo ? 'eșuat' : 'failed')
|
||||
: status === 'skipped'
|
||||
? (isRo ? 'omis' : 'skipped')
|
||||
: (PENDING_BLURB[name]?.[isRo ? 'ro' : 'en'] || (isRo ? 'în coadă' : 'queued'));
|
||||
|
||||
return (
|
||||
<LoadingTile key={name} status={status}>
|
||||
<LoadingTileTop>
|
||||
<LoadingTileLeft>
|
||||
<LoadingTileIcon status={status}>{ICON[status]}</LoadingTileIcon>
|
||||
<LoadingTileName>{t(COMPONENT_LABEL_KEYS[name] || name)}</LoadingTileName>
|
||||
</LoadingTileLeft>
|
||||
{comp?.duration_ms != null && status === 'completed' && (
|
||||
<LoadingTileDuration>{(comp.duration_ms / 1000).toFixed(1)}s</LoadingTileDuration>
|
||||
)}
|
||||
</LoadingTileTop>
|
||||
<LoadingTileMicro status={status}>
|
||||
{status === 'running' && <LoadingMiniSpinner />}
|
||||
{micro}
|
||||
</LoadingTileMicro>
|
||||
</LoadingTile>
|
||||
);
|
||||
})}
|
||||
</LoadingGrid>
|
||||
|
||||
{findings.length > 0 && (
|
||||
<LoadingFindings>
|
||||
<LoadingFindingsLabel>{isRo ? 'Detectate până acum' : 'Detected so far'}</LoadingFindingsLabel>
|
||||
{findings.map((f, i) => (
|
||||
<LoadingFindingsRow key={i}>
|
||||
<LoadingBullet color={f.color} />
|
||||
<span>{f.text}</span>
|
||||
</LoadingFindingsRow>
|
||||
))}
|
||||
</LoadingFindings>
|
||||
)}
|
||||
</LoadingShell>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
SectionDividerRow, SectionDividerLabel, SectionDividerLine, SectionDividerCount,
|
||||
} from '../styles';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
count?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SectionDivider: React.FC<Props> = ({ label, count }) => (
|
||||
<SectionDividerRow>
|
||||
<SectionDividerLabel>{label}</SectionDividerLabel>
|
||||
<SectionDividerLine />
|
||||
{count != null && <SectionDividerCount>{count}</SectionDividerCount>}
|
||||
</SectionDividerRow>
|
||||
);
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
ShieldCheck, ShieldAlert, AlertTriangle, AlertOctagon,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import type { SourceAssessmentResult } from '../../../types/analysis-session';
|
||||
import { HeadlineCard } from './HeadlineCard';
|
||||
import { renderSourceEditorial } from '../details/source';
|
||||
|
||||
const trustAccent = (s: number): string =>
|
||||
s >= 70 ? '#22c55e' : s >= 40 ? '#eab308' : s >= 20 ? '#f97316' : '#ef4444';
|
||||
|
||||
const trustIcon = (s: number): LucideIcon => {
|
||||
if (s >= 70) return ShieldCheck;
|
||||
if (s >= 40) return ShieldAlert;
|
||||
if (s >= 20) return AlertTriangle;
|
||||
return AlertOctagon;
|
||||
};
|
||||
|
||||
const buildTldr = (r: SourceAssessmentResult, isRo: boolean): string => {
|
||||
const verdictTxt = enumLabel(r.verdict || '').replace(/_/g, ' ').toLowerCase();
|
||||
const riskTxt = enumLabel(r.risk_level || '').toLowerCase();
|
||||
if (isRo) {
|
||||
return `Sursă ${verdictTxt} cu scor de încredere ${r.trust_score}/100${riskTxt ? `, risc ${riskTxt}` : ''}.`;
|
||||
}
|
||||
return `${verdictTxt.charAt(0).toUpperCase() + verdictTxt.slice(1)} source with trust score ${r.trust_score}/100${riskTxt ? ` · ${riskTxt} risk` : ''}.`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
result: SourceAssessmentResult;
|
||||
isRo: boolean;
|
||||
}
|
||||
|
||||
export const SourceResults: React.FC<Props> = ({ result: r, isRo }) => {
|
||||
const accent = trustAccent(r.trust_score);
|
||||
const Icon = trustIcon(r.trust_score);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={r.trust_score}
|
||||
scoreLabel={isRo ? '/ 100 ÎNCREDERE' : '/ 100 TRUST'}
|
||||
eyebrow={isRo ? 'Evaluarea sursei' : 'Source assessment'}
|
||||
icon={Icon}
|
||||
category={enumLabel(r.verdict || '').replace(/_/g, ' ')}
|
||||
descriptor={
|
||||
<>
|
||||
{r.risk_level && (
|
||||
<span>{isRo ? 'Risc' : 'Risk'} {enumLabel(r.risk_level).toLowerCase()}</span>
|
||||
)}
|
||||
{r.publication?.name && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>{r.publication.name}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
tldr={buildTldr(r, isRo)}
|
||||
/>
|
||||
|
||||
{renderSourceEditorial(r, isRo)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import React, { useState } from 'react';
|
||||
import { AlertOctagon, AlertTriangle, Info, ShieldCheck } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { TechniquesResult } from '../../../types/analysis-session';
|
||||
import type { TechniqueDefinition } from '../../../services/technique-definitions.service';
|
||||
import { HeadlineCard } from './HeadlineCard';
|
||||
import { FindingCard } from './FindingCard';
|
||||
import { renderTechniquesEditorial } from '../details/techniques';
|
||||
import { StatBlock, ChipsRow, Chip } from '../styles';
|
||||
|
||||
const scoreAccent = (s: number): string =>
|
||||
s >= 70 ? '#ef4444' : s >= 40 ? '#f97316' : s >= 20 ? '#eab308' : '#22c55e';
|
||||
|
||||
const scoreCategory = (s: number, isRo: boolean): string => {
|
||||
if (s >= 70) return isRo ? 'Critic' : 'Critical';
|
||||
if (s >= 40) return isRo ? 'Ridicat' : 'High';
|
||||
if (s >= 20) return isRo ? 'Mediu' : 'Medium';
|
||||
return isRo ? 'Scăzut' : 'Low';
|
||||
};
|
||||
|
||||
const scoreIcon = (s: number): LucideIcon => {
|
||||
if (s >= 70) return AlertOctagon;
|
||||
if (s >= 40) return AlertTriangle;
|
||||
if (s >= 20) return Info;
|
||||
return ShieldCheck;
|
||||
};
|
||||
|
||||
const buildTldr = (r: TechniquesResult, isRo: boolean): string => {
|
||||
const techCount = r.techniques_detected.length;
|
||||
const dimCount = r.dimensions_affected.length;
|
||||
const dims = r.dimensions_affected.join(' · ');
|
||||
if (techCount === 0) {
|
||||
return isRo
|
||||
? 'Nicio tehnică de manipulare detectată în acest conținut.'
|
||||
: 'No manipulation techniques detected in this content.';
|
||||
}
|
||||
return isRo
|
||||
? `${techCount} ${techCount === 1 ? 'tehnică' : 'tehnici'} de manipulare detectate în ${dimCount} ${dimCount === 1 ? 'dimensiune' : 'dimensiuni'}${dims ? ` (${dims})` : ''}.`
|
||||
: `${techCount} manipulation ${techCount === 1 ? 'technique' : 'techniques'} detected across ${dimCount} ${dimCount === 1 ? 'dimension' : 'dimensions'}${dims ? ` (${dims})` : ''}.`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
result: TechniquesResult;
|
||||
isRo: boolean;
|
||||
techDefs: TechniqueDefinition[];
|
||||
}
|
||||
|
||||
/** Editorial result section for Manipulation Techniques — shared between live standalone and history detail. */
|
||||
export const TechniquesResults: React.FC<Props> = ({ result, isRo, techDefs }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const score = result.manipulation_score ?? 0;
|
||||
const accent = scoreAccent(score);
|
||||
const Icon = scoreIcon(score);
|
||||
const techCount = result.techniques_detected.length;
|
||||
const dimCount = result.dimensions_affected.length;
|
||||
const warningFlags = result.coupling_context?.for_claims?.warning_flags ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={score}
|
||||
scoreLabel={isRo ? '/ 100 SCOR' : '/ 100 SCORE'}
|
||||
eyebrow={isRo ? 'Tehnici de manipulare' : 'Manipulation techniques'}
|
||||
icon={Icon}
|
||||
category={scoreCategory(score, isRo)}
|
||||
descriptor={
|
||||
<>
|
||||
<span>{techCount} {isRo ? (techCount === 1 ? 'tehnică' : 'tehnici') : (techCount === 1 ? 'technique' : 'techniques')}</span>
|
||||
{dimCount > 0 && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>{dimCount} {isRo ? (dimCount === 1 ? 'dimensiune' : 'dimensiuni') : (dimCount === 1 ? 'dimension' : 'dimensions')}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
tldr={buildTldr(result, isRo)}
|
||||
/>
|
||||
|
||||
{(score > 0 || dimCount > 0) && (
|
||||
<StatBlock>
|
||||
<ChipsRow>
|
||||
{result.dimensions_affected.map(dim => (
|
||||
<Chip key={dim} variant="info">{dim}</Chip>
|
||||
))}
|
||||
{techCount > 0 && (
|
||||
<Chip variant="violet">
|
||||
{techCount} {isRo ? 'tehnici detectate' : 'techniques detected'}
|
||||
</Chip>
|
||||
)}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
)}
|
||||
|
||||
{techCount > 0 && renderTechniquesEditorial(
|
||||
result,
|
||||
techDefs,
|
||||
isRo,
|
||||
expanded,
|
||||
() => setExpanded(o => !o),
|
||||
)}
|
||||
|
||||
{techCount === 0 && (
|
||||
<FindingCard
|
||||
icon={ShieldCheck}
|
||||
accent="success"
|
||||
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
|
||||
headline={isRo ? 'Nicio tehnică de manipulare detectată' : 'No manipulation techniques detected'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{warningFlags.length > 0 && (
|
||||
<FindingCard
|
||||
icon={AlertTriangle}
|
||||
accent="warning"
|
||||
eyebrow={isRo ? 'Semnale adiționale' : 'Additional signals'}
|
||||
headline={isRo ? 'Indicatori de manipulare detectați la screening' : 'Manipulation signals detected at screening'}
|
||||
quote={warningFlags.map(f => f.replace(/_/g, ' ')).join(' · ')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
453
web/src/components/PipelineAnalysis/sections/Verdict.tsx
Normal file
453
web/src/components/PipelineAnalysis/sections/Verdict.tsx
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertOctagon, AlertTriangle, Info, ShieldCheck,
|
||||
OctagonX, AlertCircle, BarChart3, Layers, ExternalLink,
|
||||
Zap, UserX, Quote, Users, ShieldQuestion,
|
||||
Cpu, ShieldAlert, XCircle, CheckCircle2, HelpCircle,
|
||||
TrendingUp, Timer, Plus, ChevronDown, Tag, BarChart2,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type {
|
||||
VerdictResult, VerdictSummary, KeyFinding,
|
||||
} from '../../../types/analysis-session';
|
||||
import type { TechniqueDefinition } from '../../../services/technique-definitions.service';
|
||||
import { localizedExplanation } from '../../../utils/i18n-fields';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import type { ComponentStatus } from '../types';
|
||||
import { RISK_COLORS, getViralityColor, safeHostname } from '../utils';
|
||||
import {
|
||||
VerdictSection,
|
||||
ActionCallout, ActionIconBox, ActionBody, ActionEyebrow, ActionHeadline, ActionText,
|
||||
CollapseBtn, CollapseBtnLeft, CollapseBtnChevron,
|
||||
StatBlock, ScoresBlock, ScoreLine, ScoreLineName, ScoreLineBar, ScoreLineFill, ScoreLineNum, ScoreLineSkipped,
|
||||
ChipsRow, Chip,
|
||||
LegacyExplanation,
|
||||
} from '../styles';
|
||||
import { HeadlineCard } from './HeadlineCard';
|
||||
import { FindingCard, type FindingAccent } from './FindingCard';
|
||||
import { SectionDivider } from './SectionDivider';
|
||||
import { renderTechniquesEditorial } from '../details/techniques';
|
||||
import { renderAiEditorial } from '../details/ai';
|
||||
import { renderClaimsEditorial } from '../details/claims';
|
||||
import { renderSourceEditorial } from '../details/source';
|
||||
|
||||
interface Props {
|
||||
verdict: VerdictResult;
|
||||
totalDuration: number | null;
|
||||
components: Record<string, ComponentStatus>;
|
||||
fullResult: Record<string, any> | null;
|
||||
techniqueDefinitions: TechniqueDefinition[];
|
||||
}
|
||||
|
||||
const SEVERITY_RANK: Record<string, number> = { critical: 0, warning: 1, info: 2 };
|
||||
|
||||
/** Backend occasionally leaks debug strings (e.g. `ai_probability=85/100, verdict="LIKELY_AI"`,
|
||||
* `Claims: [SKIPPED]`, `data_quality=full`) into evidence_ref.quote. Hide those — keep real quotes. */
|
||||
function cleanQuote(q: string | null | undefined): string | undefined {
|
||||
if (!q) return undefined;
|
||||
if (/\[SKIPPED\]/i.test(q)) return undefined;
|
||||
if (/^\s*\w+\s*=\s*[\d"']/.test(q)) return undefined;
|
||||
if (/data_quality\s*=/i.test(q)) return undefined;
|
||||
return q;
|
||||
}
|
||||
|
||||
/** Map finding severity → icon + accent. */
|
||||
function findingIcon(f: KeyFinding): { icon: LucideIcon; accent: FindingAccent } {
|
||||
const sev = f.severity;
|
||||
const type = f.type;
|
||||
// Type-based mapping (richer than just severity)
|
||||
const TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
false_claim: BarChart3,
|
||||
fabricated_quote: Quote,
|
||||
manipulation: AlertCircle,
|
||||
urgency: Zap,
|
||||
imposter_content: UserX,
|
||||
conspiracy: ShieldQuestion,
|
||||
demographic: Users,
|
||||
};
|
||||
const accent: FindingAccent = sev === 'critical' ? 'critical' : sev === 'warning' ? 'warning' : 'info';
|
||||
return { icon: TYPE_ICONS[type as string] || AlertCircle, accent };
|
||||
}
|
||||
|
||||
/** Action callout eyebrow text by severity + locale. */
|
||||
function actionEyebrow(severity: string, isRo: boolean): string {
|
||||
if (severity === 'critical') return isRo ? 'NU DISTRIBUI' : 'DO NOT SHARE';
|
||||
if (severity === 'warning') return isRo ? 'CITEȘTE CRITIC' : 'READ CRITICALLY';
|
||||
return isRo ? 'DE REȚINUT' : 'NOTE';
|
||||
}
|
||||
|
||||
/** Category icon by risk_category_color. */
|
||||
function categoryIcon(color: string): LucideIcon {
|
||||
if (color === 'red' || color === 'darkred') return AlertOctagon;
|
||||
if (color === 'orange') return AlertTriangle;
|
||||
if (color === 'yellow') return Info;
|
||||
if (color === 'green' || color === 'lightgreen') return ShieldCheck;
|
||||
return Info;
|
||||
}
|
||||
|
||||
/** Animated count-up — re-runs when target changes. */
|
||||
function useCountUp(target: number, duration = 900, delay = 200): number {
|
||||
const [val, setVal] = useState(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
let start: number | null = null;
|
||||
const begin = (ts: number) => { start = ts; tick(ts); };
|
||||
const tick = (ts: number) => {
|
||||
if (start == null) start = ts;
|
||||
const t = Math.min(1, (ts - start) / duration);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
setVal(Math.round(target * eased));
|
||||
if (t < 1) rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
const timer = setTimeout(() => { rafRef.current = requestAnimationFrame(begin); }, delay);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [target, duration, delay]);
|
||||
return val;
|
||||
}
|
||||
|
||||
export const Verdict: React.FC<Props> = ({
|
||||
verdict, totalDuration, components, fullResult, techniqueDefinitions,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
|
||||
const [secondaryOpen, setSecondaryOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [techExpand, setTechExpand] = useState(false);
|
||||
const [claimsExpand, setClaimsExpand] = useState(false);
|
||||
|
||||
const summary = (verdict.context_summary as { verdict_summary?: VerdictSummary } | undefined)?.verdict_summary;
|
||||
const accent = RISK_COLORS[verdict.risk_category_color] || '#94a3b8';
|
||||
|
||||
const animatedScore = useCountUp(verdict.risk_score, 900, 1100);
|
||||
void animatedScore; // gauge fills via CSS keyframe; we use it visually for text
|
||||
|
||||
const sortedFindings = useMemo<KeyFinding[]>(() => {
|
||||
if (!summary?.key_findings) return [];
|
||||
return [...summary.key_findings].sort(
|
||||
(a, b) => (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3),
|
||||
);
|
||||
}, [summary]);
|
||||
|
||||
const dominant = sortedFindings[0];
|
||||
const rest = sortedFindings.slice(1);
|
||||
|
||||
const CatIcon = categoryIcon(verdict.risk_category_color);
|
||||
|
||||
// Tier 4 chips data
|
||||
const techniquesCount = verdict.context_summary?.techniques_detected ?? 0;
|
||||
const falseClaims = verdict.context_summary?.claims_false ?? 0;
|
||||
const elapsedSec = totalDuration ? `${(totalDuration / 1000).toFixed(1)}s` : null;
|
||||
const viralityScore = verdict.virality_score;
|
||||
const viralityLevel = verdict.virality_level;
|
||||
|
||||
const scoreEntries: { name: string; value: number | null }[] = [
|
||||
{ name: isRo ? 'Manipulare' : 'Manipulation', value: verdict.score_manipulation ?? null },
|
||||
{ name: 'Claims', value: verdict.score_claims ?? null },
|
||||
{ name: isRo ? 'AI generation' : 'AI generation', value: verdict.score_ai ?? null },
|
||||
{ name: isRo ? 'Sursă' : 'Source', value: verdict.score_source ?? null },
|
||||
];
|
||||
|
||||
const hasComponentData = !!(fullResult?.techniques || fullResult?.ai_tampered ||
|
||||
fullResult?.claims || fullResult?.source_assessment);
|
||||
|
||||
return (
|
||||
<VerdictSection>
|
||||
{summary ? (
|
||||
<>
|
||||
{/* ─── TIER 1 — HEADLINE cu gauge ─── */}
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={verdict.risk_score}
|
||||
scoreLabel={`/ 100 ${isRo ? 'RISC' : 'RISK'}`}
|
||||
eyebrow={isRo ? 'Risc analizat' : 'Risk analyzed'}
|
||||
icon={CatIcon}
|
||||
category={enumLabel(verdict.risk_category || '').replace(/_/g, ' ')}
|
||||
descriptor={
|
||||
<>
|
||||
{verdict.risk_level && (
|
||||
<>
|
||||
<span>{enumLabel(verdict.risk_level)}</span>
|
||||
<span className="sep">·</span>
|
||||
</>
|
||||
)}
|
||||
{verdict.confidence != null && (
|
||||
<span>{isRo ? 'Certitudine' : 'Confidence'} {verdict.confidence}%</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
tldr={isRo ? summary.tl_dr_ro : summary.tl_dr_en}
|
||||
/>
|
||||
|
||||
{/* ─── TIER 2 — ACTION CALLOUT ─── */}
|
||||
{(summary.what_to_do_ro || summary.what_to_do_en) && (
|
||||
<ActionCallout accent={accent}>
|
||||
<ActionIconBox accent={accent}>
|
||||
<OctagonX size={26} strokeWidth={2} />
|
||||
</ActionIconBox>
|
||||
<ActionBody>
|
||||
<ActionEyebrow accent={accent}>
|
||||
{actionEyebrow(dominant?.severity || 'info', isRo)}
|
||||
</ActionEyebrow>
|
||||
<ActionHeadline>
|
||||
{isRo
|
||||
? (dominant?.severity === 'critical' ? 'Nu distribui acest articol' : 'Citește cu atenție')
|
||||
: (dominant?.severity === 'critical' ? 'Do not share this article' : 'Read carefully')}
|
||||
</ActionHeadline>
|
||||
<ActionText>{isRo ? summary.what_to_do_ro : summary.what_to_do_en}</ActionText>
|
||||
</ActionBody>
|
||||
</ActionCallout>
|
||||
)}
|
||||
|
||||
{/* ─── TIER 3 — DOMINANT FINDING ─── */}
|
||||
{dominant && (
|
||||
<>
|
||||
<SectionDivider label={isRo ? 'Cea mai gravă problemă' : 'Top issue'} />
|
||||
{(() => {
|
||||
const { icon, accent: a } = findingIcon(dominant);
|
||||
return (
|
||||
<FindingCard
|
||||
dominant
|
||||
icon={icon}
|
||||
accent={a}
|
||||
headline={isRo ? dominant.ro : dominant.en}
|
||||
quote={cleanQuote(dominant.evidence_ref?.quote)}
|
||||
source={dominant.evidence_ref?.source_url ? (
|
||||
<>
|
||||
<ExternalLink size={13} strokeWidth={2} />
|
||||
<span>{isRo ? 'Verificat de' : 'Verified by'}</span>
|
||||
<a
|
||||
href={dominant.evidence_ref.source_url}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent-text)', textDecoration: 'none', fontWeight: 500 }}
|
||||
>
|
||||
{safeHostname(dominant.evidence_ref.source_url)}
|
||||
</a>
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* secondary findings collapse */}
|
||||
{rest.length > 0 && (
|
||||
<>
|
||||
<CollapseBtn onClick={() => setSecondaryOpen(o => !o)}>
|
||||
<CollapseBtnLeft>
|
||||
<Layers size={16} strokeWidth={2} />
|
||||
<span>
|
||||
{secondaryOpen
|
||||
? (isRo ? 'Ascunde celelalte semne' : 'Hide other signals')
|
||||
: (isRo
|
||||
? `Vezi ${rest.length} ${rest.length === 1 ? 'alt semn de manipulare' : 'alte semne de manipulare'}`
|
||||
: `Show ${rest.length} more ${rest.length === 1 ? 'manipulation signal' : 'manipulation signals'}`)}
|
||||
</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={secondaryOpen}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
|
||||
{secondaryOpen && (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Alte semne de manipulare' : 'Other manipulation signals'}
|
||||
count={rest.length}
|
||||
/>
|
||||
{rest.map((f, idx) => {
|
||||
const { icon, accent: a } = findingIcon(f);
|
||||
return (
|
||||
<FindingCard
|
||||
key={idx}
|
||||
icon={icon}
|
||||
accent={a}
|
||||
headline={isRo ? f.ro : f.en}
|
||||
quote={cleanQuote(f.evidence_ref?.quote)}
|
||||
source={f.evidence_ref?.source_url ? (
|
||||
<>
|
||||
<ExternalLink size={13} strokeWidth={2} />
|
||||
<a
|
||||
href={f.evidence_ref.source_url}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent-text)', textDecoration: 'none', fontWeight: 500 }}
|
||||
>
|
||||
{safeHostname(f.evidence_ref.source_url)}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tag size={13} strokeWidth={2} />
|
||||
<span>{f.type.replace(/_/g, ' ')}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── TIER 4 — DETALII TEHNICE (collapse) ─── */}
|
||||
<CollapseBtn onClick={() => setDetailsOpen(o => !o)}>
|
||||
<CollapseBtnLeft>
|
||||
<BarChart3 size={16} strokeWidth={2} />
|
||||
<span>{isRo ? 'Detalii tehnice' : 'Technical details'}</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={detailsOpen}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
|
||||
{detailsOpen && (
|
||||
<>
|
||||
{/* Stat block — score bars + chips */}
|
||||
<StatBlock>
|
||||
<ScoresBlock>
|
||||
{scoreEntries.map(({ name, value }, i) => (
|
||||
<ScoreLine key={name}>
|
||||
<ScoreLineName>{name}</ScoreLineName>
|
||||
<ScoreLineBar>
|
||||
{value != null && (
|
||||
<ScoreLineFill
|
||||
width={value}
|
||||
color={value >= 70 ? '#ef4444' : value >= 40 ? '#f97316' : '#22c55e'}
|
||||
delay={200 + i * 80}
|
||||
/>
|
||||
)}
|
||||
</ScoreLineBar>
|
||||
{value != null
|
||||
? <ScoreLineNum>{value.toFixed(0)}</ScoreLineNum>
|
||||
: <ScoreLineSkipped>—</ScoreLineSkipped>}
|
||||
</ScoreLine>
|
||||
))}
|
||||
</ScoresBlock>
|
||||
<ChipsRow>
|
||||
{viralityScore != null && (
|
||||
<Chip variant={viralityScore >= 50 ? 'critical' : viralityScore >= 25 ? 'warning' : 'success'}>
|
||||
<TrendingUp size={12} strokeWidth={2} />
|
||||
Virality {viralityScore}{viralityLevel ? ` ${enumLabel(viralityLevel)}` : ''}
|
||||
</Chip>
|
||||
)}
|
||||
{techniquesCount > 0 && (
|
||||
<Chip variant="violet">
|
||||
<Layers size={12} strokeWidth={2} />
|
||||
{techniquesCount} {isRo ? 'tehnici detectate' : 'techniques detected'}
|
||||
</Chip>
|
||||
)}
|
||||
{falseClaims > 0 && (
|
||||
<Chip variant="critical">
|
||||
<XCircle size={12} strokeWidth={2} />
|
||||
{falseClaims} {isRo ? 'afirmații false' : 'false claims'}
|
||||
</Chip>
|
||||
)}
|
||||
{elapsedSec && (
|
||||
<Chip variant="neutral">
|
||||
<Timer size={12} strokeWidth={2} />
|
||||
{elapsedSec} {isRo ? 'analiză' : 'analysis'}
|
||||
</Chip>
|
||||
)}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
|
||||
{/* AI Detection */}
|
||||
{fullResult?.ai_tampered && renderAiEditorial(fullResult.ai_tampered, isRo)}
|
||||
|
||||
{/* Source Assessment */}
|
||||
{fullResult?.source_assessment && renderSourceEditorial(fullResult.source_assessment, isRo)}
|
||||
|
||||
{/* Techniques */}
|
||||
{fullResult?.techniques && renderTechniquesEditorial(fullResult.techniques, techniqueDefinitions, isRo, techExpand, () => setTechExpand(o => !o))}
|
||||
|
||||
{/* Claims */}
|
||||
{fullResult?.claims && renderClaimsEditorial(fullResult.claims, isRo, claimsExpand, () => setClaimsExpand(o => !o))}
|
||||
|
||||
{/* If we have components but no detail data */}
|
||||
{!hasComponentData && (
|
||||
<FindingCard
|
||||
icon={Info}
|
||||
accent="neutral"
|
||||
headline={isRo ? 'Detalii pe componente indisponibile' : 'No component data available'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// ═══ LEGACY FALLBACK ═══
|
||||
<>
|
||||
<HeadlineCard
|
||||
accent={accent}
|
||||
score={verdict.risk_score}
|
||||
scoreLabel={`/ 100 ${isRo ? 'RISC' : 'RISK'}`}
|
||||
eyebrow={isRo ? 'Risc analizat' : 'Risk analyzed'}
|
||||
icon={CatIcon}
|
||||
category={enumLabel(verdict.risk_category || '').replace(/_/g, ' ')}
|
||||
descriptor={verdict.confidence != null
|
||||
? <span>{isRo ? 'Certitudine' : 'Confidence'} {verdict.confidence}%</span>
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
{(verdict.explanation_en || verdict.explanation_ro) && (
|
||||
<LegacyExplanation>{localizedExplanation(verdict)}</LegacyExplanation>
|
||||
)}
|
||||
|
||||
<CollapseBtn onClick={() => setDetailsOpen(o => !o)}>
|
||||
<CollapseBtnLeft>
|
||||
<BarChart3 size={16} strokeWidth={2} />
|
||||
<span>{isRo ? 'Detalii tehnice' : 'Technical details'}</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={detailsOpen}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
|
||||
{detailsOpen && (
|
||||
<>
|
||||
<StatBlock>
|
||||
<ScoresBlock>
|
||||
{scoreEntries.map(({ name, value }, i) => (
|
||||
<ScoreLine key={name}>
|
||||
<ScoreLineName>{name}</ScoreLineName>
|
||||
<ScoreLineBar>
|
||||
{value != null && (
|
||||
<ScoreLineFill
|
||||
width={value}
|
||||
color={value >= 70 ? '#ef4444' : value >= 40 ? '#f97316' : '#22c55e'}
|
||||
delay={200 + i * 80}
|
||||
/>
|
||||
)}
|
||||
</ScoreLineBar>
|
||||
{value != null
|
||||
? <ScoreLineNum>{value.toFixed(0)}</ScoreLineNum>
|
||||
: <ScoreLineSkipped>—</ScoreLineSkipped>}
|
||||
</ScoreLine>
|
||||
))}
|
||||
</ScoresBlock>
|
||||
<ChipsRow>
|
||||
{viralityScore != null && (
|
||||
<Chip variant={viralityScore >= 50 ? 'critical' : 'warning'}>
|
||||
<TrendingUp size={12} strokeWidth={2} />
|
||||
Virality {viralityScore}
|
||||
</Chip>
|
||||
)}
|
||||
{elapsedSec && (
|
||||
<Chip variant="neutral">
|
||||
<Timer size={12} strokeWidth={2} />
|
||||
{elapsedSec}
|
||||
</Chip>
|
||||
)}
|
||||
</ChipsRow>
|
||||
</StatBlock>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</VerdictSection>
|
||||
);
|
||||
};
|
||||
564
web/src/components/PipelineAnalysis/styles.ts
Normal file
564
web/src/components/PipelineAnalysis/styles.ts
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { typography, spacing } from '../../theme';
|
||||
|
||||
/* =============================================================================
|
||||
LAYOUT + TOP HEADER
|
||||
============================================================================= */
|
||||
export const Container = styled.div`
|
||||
width: 100%; max-width: 1800px; margin: 0 auto;
|
||||
padding: 0;
|
||||
@media (max-width: 768px) { padding: 0; }
|
||||
`;
|
||||
export const Header = styled.div`margin-bottom: ${spacing.xl}px;`;
|
||||
export const Title = styled.h1`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: clamp(1.375rem, 1.1rem + 1vw, 1.75rem);
|
||||
font-weight: ${typography.fontWeight.bold}; color: var(--fg-primary);
|
||||
margin: 0 0 ${spacing.xs}px 0;
|
||||
`;
|
||||
export const Subtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted); margin: 0;
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
INPUT FORM
|
||||
============================================================================= */
|
||||
export const TypeSelector = styled.div`
|
||||
display: flex; gap: 4px; padding: 4px;
|
||||
background: var(--bg-surface); border-radius: 12px;
|
||||
margin-bottom: ${spacing.lg}px; border: 1px solid var(--border-subtle);
|
||||
@media (max-width: 480px) { flex-direction: column; }
|
||||
`;
|
||||
export const TypeButton = styled.button<{ active?: boolean }>`
|
||||
flex: 1; padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer;
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${p => p.active ? typography.fontWeight.semibold : typography.fontWeight.medium};
|
||||
transition: all 0.2s;
|
||||
background: ${p => p.active ? 'var(--accent-subtle)' : 'transparent'};
|
||||
color: ${p => p.active ? 'var(--accent-text)' : 'var(--fg-secondary)'};
|
||||
${p => p.active && 'box-shadow: 0 0 0 1px var(--accent-border);'}
|
||||
`;
|
||||
export const InputArea = styled.div`
|
||||
background: var(--bg-surface); border: 1px solid var(--border-default);
|
||||
border-radius: 14px; overflow: hidden; margin-bottom: ${spacing.lg}px;
|
||||
&:focus-within { border-color: var(--accent); }
|
||||
`;
|
||||
export const TextInput = styled.textarea`
|
||||
width: 100%; min-height: 140px; padding: ${spacing.lg}px;
|
||||
display: block; background: transparent;
|
||||
border: none; font-family: ${typography.fontFamily.primary}; font-size: 0.9375rem;
|
||||
color: var(--fg-primary); resize: vertical; box-sizing: border-box; line-height: 1.6;
|
||||
&:focus { outline: none; } &::placeholder { color: var(--fg-muted); }
|
||||
`;
|
||||
export const FileDropZone = styled.div`padding: ${spacing.lg}px; min-height: 140px; display: flex; align-items: center; justify-content: center;`;
|
||||
export const DropPlaceholder = styled.div`
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px;
|
||||
border: 2px dashed var(--border-default); border-radius: 12px; cursor: pointer; width: 100%;
|
||||
transition: all 0.2s;
|
||||
&:hover { border-color: var(--accent); background: var(--accent-subtle); }
|
||||
`;
|
||||
export const DropLabel = styled.span`font-size: 14px; color: var(--fg-secondary);`;
|
||||
export const DropHint = styled.span`font-size: 12px; color: var(--fg-subtle);`;
|
||||
export const FileErrorMsg = styled.div`font-size: 13px; color: #ef4444; margin-top: 8px; text-align: center;`;
|
||||
export const FileSelected = styled.div`
|
||||
display: flex; align-items: center; gap: ${spacing.md}px; padding: 14px 20px;
|
||||
background: var(--accent-subtle); border: 1px solid var(--accent-border); border-radius: 10px; width: 100%;
|
||||
`;
|
||||
export const FileName = styled.span`font-size: 14px; font-weight: 500; color: var(--fg-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`;
|
||||
export const FileSize = styled.span`font-size: 12px; color: var(--fg-muted);`;
|
||||
export const RemoveFileBtn = styled.button`
|
||||
padding: 4px 10px; border: 1px solid rgba(239,68,68,0.3); border-radius: 6px;
|
||||
background: transparent; color: #f87171; font-size: 11px; font-weight: 600; cursor: pointer;
|
||||
&:hover { background: rgba(239,68,68,0.1); }
|
||||
[data-theme="light"] & { border-color: rgba(220,38,38,0.2); color: #dc2626; }
|
||||
`;
|
||||
export const UrlInputWrapper = styled.div`padding: ${spacing.lg}px; display: flex; flex-direction: column; gap: 8px;`;
|
||||
export const UrlInput = styled.input`
|
||||
width: 100%; padding: 14px ${spacing.lg}px; background: transparent;
|
||||
border: none; font-family: ${typography.fontFamily.primary}; font-size: 0.9375rem;
|
||||
color: var(--fg-primary); box-sizing: border-box;
|
||||
&:focus { outline: none; } &::placeholder { color: var(--fg-muted); }
|
||||
`;
|
||||
export const UrlHint = styled.div`
|
||||
font-size: 11px; color: var(--fg-subtle); padding: 0 ${spacing.lg}px;
|
||||
`;
|
||||
export const InputFooter = styled.div`
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: ${spacing.sm}px ${spacing.lg}px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column; gap: 8px; padding: ${spacing.md}px;
|
||||
& > button { width: 100%; justify-content: center; }
|
||||
}
|
||||
`;
|
||||
export const CharCount = styled.span`font-size: 12px; color: var(--fg-subtle);`;
|
||||
|
||||
const spin = keyframes`from { transform: rotate(0deg); } to { transform: rotate(360deg); }`;
|
||||
export { spin };
|
||||
export const Spinner = styled.span`
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
||||
border-radius: 50%; animation: ${spin} 0.7s linear infinite;
|
||||
`;
|
||||
export const AnalyzeBtn = styled.button<{ disabled?: boolean }>`
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 24px;
|
||||
background: ${p => p.disabled ? 'var(--accent-subtle)' : 'var(--accent)'};
|
||||
color: ${p => p.disabled ? 'var(--fg-disabled)' : 'var(--fg-on-accent)'};
|
||||
border: none; border-radius: 8px; font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: ${p => p.disabled ? 'not-allowed' : 'pointer'}; transition: all 0.2s;
|
||||
&:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
BANNERS (error / skip / warning)
|
||||
============================================================================= */
|
||||
export const ErrorBox = styled.div`
|
||||
display: flex; align-items: center; gap: 10px; padding: 14px ${spacing.lg}px;
|
||||
background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2);
|
||||
border-radius: 10px; color: #f87171; font-size: ${typography.fontSize.sm}; margin-bottom: ${spacing.lg}px;
|
||||
[data-theme="light"] & { background: #fef2f2; border-color: #fecaca; color: #dc2626; }
|
||||
`;
|
||||
export const ErrorIcon = styled.span`
|
||||
display: flex; align-items: center; justify-content: center; width: 20px; height: 20px;
|
||||
border-radius: 50%; background: rgba(239,68,68,0.2); font-size: 11px; font-weight: 700;
|
||||
`;
|
||||
export const SkipBox = styled.div`
|
||||
display: flex; flex-direction: column; gap: 10px; padding: 16px ${spacing.lg}px;
|
||||
background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.25);
|
||||
border-radius: 10px; margin-bottom: ${spacing.lg}px;
|
||||
[data-theme="light"] & { background: #fffbeb; border-color: #fde68a; }
|
||||
`;
|
||||
export const SkipText = styled.div`
|
||||
color: #eab308; font-size: ${typography.fontSize.sm}; line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
[data-theme="light"] & { color: #b45309; }
|
||||
`;
|
||||
export const SkipAction = styled.button`
|
||||
align-self: flex-start; padding: 8px 16px; background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border); border-radius: 8px; color: var(--accent-text);
|
||||
font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
&:hover { border-color: var(--accent); }
|
||||
`;
|
||||
export const WarningBox = styled.div`
|
||||
padding: 12px ${spacing.lg}px;
|
||||
background: rgba(234, 179, 8, 0.08); border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
border-radius: 10px; color: #eab308; font-size: ${typography.fontSize.sm};
|
||||
margin-bottom: ${spacing.lg}px; display: flex; flex-direction: column; gap: 4px;
|
||||
[data-theme="light"] & { background: #fefce8; border-color: #fde68a; color: #a16207; }
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
PROGRESS BAR
|
||||
============================================================================= */
|
||||
export const ProgressSection = styled.div`margin-bottom: ${spacing.lg}px;`;
|
||||
export const ProgressBar = styled.div`
|
||||
height: 6px; background: var(--bg-active); border-radius: 3px; overflow: hidden;
|
||||
`;
|
||||
export const ProgressFill = styled.div<{ width: number }>`
|
||||
height: 100%; width: ${p => p.width}%; background: var(--accent);
|
||||
border-radius: 3px; transition: width 0.5s ease;
|
||||
`;
|
||||
export const ProgressText = styled.div`
|
||||
font-size: 11px; color: var(--fg-muted); margin-top: 6px;
|
||||
display: flex; justify-content: space-between;
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
LOADING STATE — grid 2x2 cu micro-stats live
|
||||
============================================================================= */
|
||||
export const LoadingShell = styled.div`
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
padding: 22px 24px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
`;
|
||||
export const LoadingHeadline = styled.div`
|
||||
font-size: 13px; font-weight: 400; color: var(--fg-secondary);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
letter-spacing: -0.005em;
|
||||
`;
|
||||
export const LoadingPulse = styled.span`
|
||||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: ${keyframes`
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.8); }
|
||||
`} 1.4s ease-in-out infinite;
|
||||
`;
|
||||
export const LoadingGrid = styled.div`
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||
`;
|
||||
export const LoadingTile = styled.div<{ status: string }>`
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: ${p => p.status === 'pending' ? 0.55 : 1};
|
||||
`;
|
||||
export const LoadingTileTop = styled.div`
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
`;
|
||||
export const LoadingTileLeft = styled.div`display: flex; align-items: center; gap: 8px;`;
|
||||
export const LoadingTileIcon = styled.span<{ status: string }>`
|
||||
font-size: 12px; line-height: 1; flex-shrink: 0;
|
||||
color: ${p =>
|
||||
p.status === 'completed' ? '#22c55e' :
|
||||
p.status === 'running' ? 'var(--accent)' :
|
||||
p.status === 'failed' ? '#ef4444' :
|
||||
'var(--fg-subtle)'};
|
||||
`;
|
||||
export const LoadingTileName = styled.span`
|
||||
font-size: 12.5px; font-weight: 500; color: var(--fg-primary);
|
||||
letter-spacing: -0.005em;
|
||||
`;
|
||||
export const LoadingTileDuration = styled.span`
|
||||
font-size: 10px; font-weight: 400;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
export const LoadingTileMicro = styled.div<{ status: string }>`
|
||||
font-size: 11.5px; line-height: 1.4; padding-left: 20px;
|
||||
color: ${p =>
|
||||
p.status === 'completed' ? 'var(--fg-secondary)' :
|
||||
p.status === 'running' ? 'var(--accent-text)' :
|
||||
'var(--fg-subtle)'};
|
||||
letter-spacing: -0.005em;
|
||||
`;
|
||||
export const LoadingMiniSpinner = styled.span`
|
||||
display: inline-block; width: 9px; height: 9px;
|
||||
border: 1.5px solid var(--accent-border); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: ${spin} 0.7s linear infinite;
|
||||
margin-right: 4px; vertical-align: -1px;
|
||||
`;
|
||||
export const LoadingFindings = styled.div`
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
`;
|
||||
export const LoadingFindingsLabel = styled.div`
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--fg-muted); font-weight: 500;
|
||||
`;
|
||||
export const LoadingFindingsRow = styled.div`
|
||||
font-size: 12.5px; color: var(--fg-secondary); display: flex; align-items: center; gap: 8px;
|
||||
letter-spacing: -0.005em;
|
||||
`;
|
||||
export const LoadingBullet = styled.span<{ color: string }>`
|
||||
display: inline-block; width: 5px; height: 5px; border-radius: 50%;
|
||||
background: ${p => p.color}; flex-shrink: 0;
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
VERDICT — EDITORIAL PATTERN (v3 redesign)
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
============================================================================= */
|
||||
|
||||
export const VerdictSection = styled.div`
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
|
||||
/* stagger animation pe încărcare */
|
||||
& > * { animation: vstagger 380ms cubic-bezier(0.2,0.8,0.2,1) backwards; }
|
||||
& > *:nth-of-type(1) { animation-delay: 0ms; }
|
||||
& > *:nth-of-type(2) { animation-delay: 160ms; }
|
||||
& > *:nth-of-type(3) { animation-delay: 240ms; }
|
||||
& > *:nth-of-type(4) { animation-delay: 320ms; }
|
||||
& > *:nth-of-type(5) { animation-delay: 400ms; }
|
||||
& > *:nth-of-type(6) { animation-delay: 480ms; }
|
||||
& > *:nth-of-type(7) { animation-delay: 560ms; }
|
||||
& > *:nth-of-type(8) { animation-delay: 640ms; }
|
||||
& > *:nth-of-type(9) { animation-delay: 720ms; }
|
||||
& > *:nth-of-type(n+10) { animation-delay: 800ms; }
|
||||
|
||||
@keyframes vstagger {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`;
|
||||
|
||||
/* ─── Tier 1 — HEADLINE cu gauge ring ─── */
|
||||
export const HeadlineCard = styled.div<{ accent: string }>`
|
||||
display: flex; align-items: stretch; gap: 28px;
|
||||
padding: 32px 36px;
|
||||
background: linear-gradient(180deg, ${p => p.accent}0a, var(--bg-surface));
|
||||
border: 1px solid ${p => p.accent}1f;
|
||||
border-radius: 20px;
|
||||
position: relative; overflow: hidden;
|
||||
&::before {
|
||||
content: ''; position: absolute;
|
||||
top: -120px; right: -120px; width: 320px; height: 320px;
|
||||
background: radial-gradient(circle, ${p => p.accent}1f, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
flex-direction: column; gap: 20px; padding: 24px 22px; align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
export const GaugeCol = styled.div`
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 168px;
|
||||
@media (max-width: 720px) { width: auto; align-self: flex-start; }
|
||||
`;
|
||||
export const Gauge = styled.div<{ accent: string }>`
|
||||
width: 168px; height: 168px; position: relative; color: ${p => p.accent};
|
||||
svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
@media (max-width: 720px) { width: 132px; height: 132px; }
|
||||
`;
|
||||
const gaugeFill = keyframes`
|
||||
to { stroke-dashoffset: var(--gauge-target, 0); }
|
||||
`;
|
||||
export const GaugeTrack = styled.circle`
|
||||
fill: none; stroke: currentColor; stroke-width: 6; opacity: 0.12;
|
||||
`;
|
||||
export const GaugeFill = styled.circle<{ scorePercent: number }>`
|
||||
fill: none; stroke: currentColor; stroke-width: 6; stroke-linecap: round;
|
||||
stroke-dasharray: 502.65;
|
||||
--gauge-target: ${p => 502.65 * (1 - Math.min(1, Math.max(0, p.scorePercent / 100)))};
|
||||
stroke-dashoffset: 502.65;
|
||||
animation: ${gaugeFill} 1100ms cubic-bezier(0.2,0.8,0.2,1) 200ms forwards;
|
||||
`;
|
||||
export const GaugeCenter = styled.div`
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
animation: ${keyframes`from { opacity: 0; transform: scale(0.85); } to { opacity: 1; transform: scale(1); }`}
|
||||
600ms cubic-bezier(0.2,0.8,0.2,1) 1100ms backwards;
|
||||
`;
|
||||
export const GaugeScore = styled.div`
|
||||
font-size: 3.5rem; font-weight: 600; line-height: 1; letter-spacing: -0.04em; color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
@media (max-width: 720px) { font-size: 2.75rem; }
|
||||
`;
|
||||
export const GaugeOf = styled.div`
|
||||
margin-top: 6px; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--fg-subtle); font-weight: 500;
|
||||
`;
|
||||
|
||||
export const HeadlineContent = styled.div`
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 16px; padding-top: 6px;
|
||||
`;
|
||||
export const HeadlineEyebrow = styled.div`
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
&::before { content: ''; width: 18px; height: 1px; background: var(--border-strong); }
|
||||
`;
|
||||
export const CategoryRow = styled.div`
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
`;
|
||||
export const CategoryIconBox = styled.span<{ accent: string }>`
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 32px; border-radius: 10px;
|
||||
background: ${p => p.accent}26; color: ${p => p.accent};
|
||||
`;
|
||||
export const CategoryName = styled.span`
|
||||
font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; color: var(--fg-primary);
|
||||
@media (max-width: 720px) { font-size: 1.25rem; }
|
||||
`;
|
||||
export const CategoryDesc = styled.span`
|
||||
font-size: 13px; color: var(--fg-secondary); display: flex; align-items: center; gap: 6px;
|
||||
& .sep { opacity: 0.4; }
|
||||
`;
|
||||
export const TldrText = styled.p`
|
||||
font-family: 'Merriweather', Georgia, serif; font-style: italic;
|
||||
font-size: 1.125rem; line-height: 1.55; font-weight: 400;
|
||||
color: var(--fg-primary); max-width: 60ch; margin: 0;
|
||||
@media (max-width: 720px) { font-size: 1rem; font-style: normal; }
|
||||
`;
|
||||
|
||||
/* ─── Tier 2 — ACTION CALLOUT ─── */
|
||||
export const ActionCallout = styled.div<{ accent: string }>`
|
||||
display: flex; gap: 20px; align-items: flex-start;
|
||||
padding: 22px 26px;
|
||||
background: linear-gradient(135deg, ${p => p.accent}29, ${p => p.accent}0f);
|
||||
border: 1px solid ${p => p.accent}47;
|
||||
border-radius: 16px;
|
||||
[data-theme="light"] & {
|
||||
background: linear-gradient(135deg, ${p => p.accent}1a, ${p => p.accent}0a);
|
||||
}
|
||||
`;
|
||||
export const ActionIconBox = styled.span<{ accent: string }>`
|
||||
flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 44px; height: 44px; border-radius: 14px;
|
||||
background: ${p => p.accent}38; color: ${p => p.accent};
|
||||
`;
|
||||
export const ActionBody = styled.div`flex: 1; display: flex; flex-direction: column; gap: 4px;`;
|
||||
export const ActionEyebrow = styled.div<{ accent: string }>`
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: ${p => p.accent};
|
||||
`;
|
||||
export const ActionHeadline = styled.div`
|
||||
font-size: 1.25rem; font-weight: 600; color: var(--fg-primary); letter-spacing: -0.02em; line-height: 1.25;
|
||||
@media (max-width: 720px) { font-size: 1.125rem; }
|
||||
`;
|
||||
export const ActionText = styled.div`
|
||||
margin-top: 6px; font-size: 0.90625rem; line-height: 1.5;
|
||||
color: var(--fg-secondary); max-width: 60ch;
|
||||
`;
|
||||
|
||||
/* ─── SECTION DIVIDER ─── */
|
||||
export const SectionDividerRow = styled.div`
|
||||
display: flex; align-items: center; gap: 14px; padding: 14px 4px 6px;
|
||||
`;
|
||||
export const SectionDividerLabel = styled.span`
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
export const SectionDividerLine = styled.span`
|
||||
flex: 1; height: 1px; background: var(--border-subtle);
|
||||
`;
|
||||
export const SectionDividerCount = styled.span`
|
||||
font-size: 11px; color: var(--fg-subtle); font-weight: 500;
|
||||
`;
|
||||
|
||||
/* ─── FINDING CARD universal ─── */
|
||||
export const FindingCardBox = styled.div<{ dominant?: boolean }>`
|
||||
display: flex; gap: 16px;
|
||||
padding: ${p => p.dominant ? '22px 24px' : '20px 22px'};
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid ${p => p.dominant ? 'rgba(239,68,68,0.2)' : 'var(--border-subtle)'};
|
||||
border-radius: 16px;
|
||||
`;
|
||||
export const FindingIconBox = styled.span<{ accent: string; lg?: boolean }>`
|
||||
flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||
width: ${p => p.lg ? '40px' : '36px'};
|
||||
height: ${p => p.lg ? '40px' : '36px'};
|
||||
border-radius: ${p => p.lg ? '12px' : '11px'};
|
||||
background: ${p => p.accent}1a; color: ${p => p.accent};
|
||||
`;
|
||||
export const FindingBody = styled.div`flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px;`;
|
||||
export const FindingEyebrow = styled.div`
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
export const FindingHeadline = styled.div<{ dominant?: boolean }>`
|
||||
font-size: ${p => p.dominant ? '1.03125rem' : '0.96875rem'};
|
||||
line-height: 1.5; font-weight: 500;
|
||||
color: var(--fg-primary); letter-spacing: -0.01em;
|
||||
overflow-wrap: anywhere; word-break: break-word;
|
||||
`;
|
||||
export const FindingQuote = styled.p`
|
||||
font-family: 'Merriweather', Georgia, serif; font-style: italic;
|
||||
font-size: 0.875rem; line-height: 1.55; color: var(--fg-secondary);
|
||||
padding-left: 14px; border-left: 2px solid var(--border-default);
|
||||
max-width: 60ch; margin: 0;
|
||||
overflow-wrap: anywhere; word-break: break-word;
|
||||
`;
|
||||
export const FindingSourceRow = styled.div`
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
font-size: 12.5px; color: var(--fg-muted); letter-spacing: -0.005em;
|
||||
min-width: 0; max-width: 100%;
|
||||
& .sep { color: var(--fg-subtle); }
|
||||
& svg { flex-shrink: 0; }
|
||||
`;
|
||||
export const FindingSourceLink = styled.a`
|
||||
color: var(--accent-text); text-decoration: none; font-weight: 500;
|
||||
overflow-wrap: anywhere; word-break: break-all; max-width: 100%;
|
||||
&:hover { text-decoration: underline; }
|
||||
`;
|
||||
export const StancePill = styled.span<{ stance: string }>`
|
||||
font-size: 9.5px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0;
|
||||
background: ${p => p.stance === 'contradicts' ? 'rgba(239,68,68,0.12)' :
|
||||
p.stance === 'supports' ? 'rgba(34,197,94,0.12)' :
|
||||
'rgba(148,163,184,0.12)'};
|
||||
color: ${p => p.stance === 'contradicts' ? '#ef4444' :
|
||||
p.stance === 'supports' ? '#22c55e' :
|
||||
'#94a3b8'};
|
||||
`;
|
||||
|
||||
/* ─── COLLAPSE button ─── */
|
||||
export const CollapseBtn = styled.button`
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
width: 100%; padding: 14px 20px; cursor: pointer;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px; color: var(--fg-secondary);
|
||||
font-family: inherit; font-size: 13px; font-weight: 500;
|
||||
text-align: left; letter-spacing: -0.005em;
|
||||
transition: all 0.18s ease;
|
||||
&:hover { background: var(--bg-hover); color: var(--fg-primary); border-color: var(--border-default); }
|
||||
`;
|
||||
export const CollapseBtnLeft = styled.span`
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
& svg { opacity: 0.7; }
|
||||
`;
|
||||
export const CollapseBtnChevron = styled.span<{ expanded: boolean }>`
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease;
|
||||
transform: rotate(${p => p.expanded ? '180deg' : '0deg'});
|
||||
& svg { opacity: 0.6; }
|
||||
`;
|
||||
|
||||
/* ─── STAT BLOCK (score bars + chips) ─── */
|
||||
export const StatBlock = styled.div`
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
padding: 22px 24px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
`;
|
||||
export const ScoresBlock = styled.div`display: flex; flex-direction: column; gap: 10px;`;
|
||||
export const ScoreLine = styled.div`display: flex; align-items: center; gap: 14px;`;
|
||||
export const ScoreLineName = styled.span`
|
||||
font-size: 13px; color: var(--fg-secondary); width: 110px; font-weight: 400;
|
||||
letter-spacing: -0.005em;
|
||||
@media (max-width: 600px) { width: 90px; }
|
||||
`;
|
||||
export const ScoreLineBar = styled.div`
|
||||
flex: 1; height: 4px; background: var(--bg-active); border-radius: 2px; overflow: hidden;
|
||||
`;
|
||||
const barFill = keyframes`from { width: 0; }`;
|
||||
export const ScoreLineFill = styled.div<{ width: number; color: string; delay?: number }>`
|
||||
height: 100%; width: ${p => Math.min(p.width, 100)}%; background: ${p => p.color};
|
||||
border-radius: 2px;
|
||||
animation: ${barFill} 900ms cubic-bezier(0.2,0.8,0.2,1) backwards;
|
||||
animation-delay: ${p => p.delay ?? 0}ms;
|
||||
`;
|
||||
export const ScoreLineNum = styled.span`
|
||||
font-size: 13px; font-weight: 500; color: var(--fg-primary);
|
||||
width: 40px; text-align: right; letter-spacing: -0.01em;
|
||||
`;
|
||||
export const ScoreLineSkipped = styled.span`
|
||||
font-size: 13px; color: var(--fg-subtle); width: 40px; text-align: right;
|
||||
`;
|
||||
|
||||
export const ChipsRow = styled.div`display: flex; gap: 6px; flex-wrap: wrap;`;
|
||||
export const Chip = styled.span<{ variant?: 'critical' | 'warning' | 'success' | 'info' | 'violet' | 'neutral' }>`
|
||||
font-size: 12px; font-weight: 400; padding: 5px 12px; border-radius: 999px;
|
||||
display: inline-flex; align-items: center; gap: 6px; letter-spacing: -0.005em;
|
||||
background: ${p => {
|
||||
switch (p.variant) {
|
||||
case 'critical': return 'rgba(239,68,68,0.12)';
|
||||
case 'warning': return 'rgba(249,115,22,0.12)';
|
||||
case 'success': return 'rgba(34,197,94,0.12)';
|
||||
case 'info': return 'rgba(59,130,246,0.12)';
|
||||
case 'violet': return 'var(--accent-subtle)';
|
||||
case 'neutral': return 'rgba(148,163,184,0.12)';
|
||||
default: return 'var(--accent-subtle)';
|
||||
}
|
||||
}};
|
||||
color: ${p => {
|
||||
switch (p.variant) {
|
||||
case 'critical': return '#ef4444';
|
||||
case 'warning': return '#f97316';
|
||||
case 'success': return '#22c55e';
|
||||
case 'info': return '#3b82f6';
|
||||
case 'violet': return 'var(--accent-text)';
|
||||
case 'neutral': return '#94a3b8';
|
||||
default: return 'var(--accent-text)';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
/* ─── LEGACY fallback (sesiuni vechi fără verdict_summary) ─── */
|
||||
export const LegacyExplanation = styled.div`
|
||||
font-family: 'Merriweather', Georgia, serif;
|
||||
font-size: 0.875rem; line-height: 1.6; color: var(--fg-secondary);
|
||||
padding: 16px 20px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle); border-radius: 16px;
|
||||
`;
|
||||
6
web/src/components/PipelineAnalysis/types.ts
Normal file
6
web/src/components/PipelineAnalysis/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export type InputType = 'text' | 'image' | 'audio' | 'video' | 'url';
|
||||
|
||||
export interface ComponentStatus {
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
duration_ms?: number;
|
||||
}
|
||||
83
web/src/components/PipelineAnalysis/utils.ts
Normal file
83
web/src/components/PipelineAnalysis/utils.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { InputType } from './types';
|
||||
|
||||
export const INPUT_TYPES: { key: InputType; labelKey: string; accept?: string }[] = [
|
||||
{ key: 'text', labelKey: 'common.text' },
|
||||
{ key: 'url', labelKey: 'common.url' },
|
||||
{ key: 'image', labelKey: 'common.image', accept: 'image/jpeg,image/png,image/webp,image/gif' },
|
||||
{ key: 'audio', labelKey: 'common.audio', accept: 'audio/mpeg,audio/wav,audio/ogg,audio/mp4' },
|
||||
{ key: 'video', labelKey: 'common.video', accept: 'video/mp4,video/webm,video/ogg' },
|
||||
];
|
||||
|
||||
export const COMPONENT_LABEL_KEYS: Record<string, string> = {
|
||||
techniques: 'pipeline.componentNames.techniques',
|
||||
ai_tampered: 'pipeline.componentNames.aiTamper',
|
||||
claims: 'pipeline.componentNames.claims',
|
||||
domain: 'pipeline.componentNames.domain',
|
||||
source_assessment: 'pipeline.componentNames.source',
|
||||
verdict: 'pipeline.componentNames.verdict',
|
||||
};
|
||||
|
||||
export const COMPONENT_ORDER = ['ai_tampered', 'source_assessment', 'techniques', 'claims'];
|
||||
|
||||
export const RISK_COLORS: Record<string, string> = {
|
||||
green: '#22c55e',
|
||||
lightgreen: '#84cc16',
|
||||
yellow: '#eab308',
|
||||
orange: '#f97316',
|
||||
red: '#ef4444',
|
||||
darkred: '#dc2626',
|
||||
};
|
||||
|
||||
export const STATUS_ICON: Record<string, string> = {
|
||||
pending: '○',
|
||||
running: '◎',
|
||||
completed: '●',
|
||||
failed: '✗',
|
||||
skipped: '—',
|
||||
};
|
||||
|
||||
export const SEVERITY_BG: Record<string, string> = {
|
||||
critical: 'rgba(239,68,68,0.10)',
|
||||
warning: 'rgba(249,115,22,0.10)',
|
||||
info: 'rgba(59,130,246,0.08)',
|
||||
};
|
||||
export const SEVERITY_BORDER: Record<string, string> = {
|
||||
critical: '#ef4444',
|
||||
warning: '#f97316',
|
||||
info: '#3b82f6',
|
||||
};
|
||||
export const SEVERITY_BG_LIGHT: Record<string, string> = {
|
||||
critical: 'rgba(239,68,68,0.06)',
|
||||
warning: 'rgba(249,115,22,0.06)',
|
||||
info: 'rgba(59,130,246,0.05)',
|
||||
};
|
||||
|
||||
export const formatTechName = (name: string) => {
|
||||
const part = name.includes('.') ? name.split('.').pop()! : name;
|
||||
return part.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
export const formatFactorName = (factor: string) =>
|
||||
factor.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
export const getViralityColor = (score: number) =>
|
||||
score >= 75 ? '#dc2626' : score >= 50 ? '#ef4444' : score >= 25 ? '#f97316' : '#22c55e';
|
||||
|
||||
export const getSeverityColor = (s: number) =>
|
||||
s >= 70 ? '#ef4444' : s >= 40 ? '#f97316' : '#eab308';
|
||||
|
||||
export const getProbColor = (p: number) =>
|
||||
p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e';
|
||||
|
||||
export const getStanceColor = (s: string) =>
|
||||
s === 'SUPPORTS' ? '#22c55e' : s === 'CONTRADICTS' ? '#ef4444' : '#94a3b8';
|
||||
|
||||
export const getStatusColor = (s: string) => {
|
||||
if (s === 'verified_true' || s === 'VT') return '#22c55e';
|
||||
if (s === 'verified_false' || s === 'VF_STATUS') return '#ef4444';
|
||||
return '#94a3b8';
|
||||
};
|
||||
|
||||
export const safeHostname = (url: string): string => {
|
||||
try { return new URL(url).hostname; } catch { return url; }
|
||||
};
|
||||
120
web/src/components/ProtectedRoute.tsx
Normal file
120
web/src/components/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const { isAuthenticated, isLoading, sessionExpired, reconnect } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'var(--bg-canvas)',
|
||||
gap: '16px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
border: '3px solid var(--accent-border)',
|
||||
borderTopColor: 'var(--accent)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
<span style={{
|
||||
color: 'var(--fg-muted)',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Session expired — show overlay on top of current content so user doesn't lose context
|
||||
if (sessionExpired) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--bg-overlay)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10000,
|
||||
gap: '16px',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '12px',
|
||||
padding: '32px 40px',
|
||||
textAlign: 'center',
|
||||
maxWidth: '400px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-primary)',
|
||||
marginBottom: '8px',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}>
|
||||
{t('auth.sessionExpired')}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--fg-secondary)',
|
||||
marginBottom: '24px',
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
}}>
|
||||
{t('auth.sessionExpiredDesc')}
|
||||
</div>
|
||||
<button
|
||||
onClick={reconnect}
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--fg-on-accent)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 28px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseOver={e => (e.currentTarget.style.background = 'var(--accent-hover)')}
|
||||
onMouseOut={e => (e.currentTarget.style.background = 'var(--accent)')}
|
||||
>
|
||||
{t('auth.reconnect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login if not authenticated (genuine — user never logged in)
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
1115
web/src/components/Reports/AnalysisReportPDF.tsx
Normal file
1115
web/src/components/Reports/AnalysisReportPDF.tsx
Normal file
File diff suppressed because it is too large
Load diff
90
web/src/components/Reports/DownloadPdfButton.tsx
Normal file
90
web/src/components/Reports/DownloadPdfButton.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Reusable "Download PDF" button. Wraps PDFDownloadLink from @react-pdf/renderer
|
||||
// and styles it to match the DIDI palette. Renders inline (caller positions it).
|
||||
import React from 'react';
|
||||
import { PDFDownloadLink } from '@react-pdf/renderer';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download } from 'lucide-react';
|
||||
import { AnalysisReportPDF } from './AnalysisReportPDF';
|
||||
import type { AnalysisSession } from '../../types/analysis-session';
|
||||
import type { TechniqueDefinition } from '../../services/technique-definitions.service';
|
||||
|
||||
interface Props {
|
||||
session: AnalysisSession | null | undefined;
|
||||
/** Optional technique definitions for inline descriptions. */
|
||||
techDefs?: TechniqueDefinition[];
|
||||
/** Compact mode for use in lists (smaller, icon-only on narrow). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const Btn = styled.button<{ compact?: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: ${p => p.compact ? '6px 10px' : '9px 14px'};
|
||||
font-size: ${p => p.compact ? '11px' : '13px'};
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: filter 0.15s ease, transform 0.1s ease;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { filter: brightness(1.1); }
|
||||
&:active { transform: translateY(1px); }
|
||||
&:disabled { opacity: 0.6; cursor: wait; filter: grayscale(0.3); }
|
||||
`;
|
||||
|
||||
const Disabled = styled.span<{ compact?: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-text);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 8px;
|
||||
padding: ${p => p.compact ? '6px 10px' : '9px 14px'};
|
||||
font-size: ${p => p.compact ? '11px' : '13px'};
|
||||
font-weight: 600;
|
||||
cursor: not-allowed;
|
||||
font-family: inherit;
|
||||
`;
|
||||
|
||||
export const DownloadPdfButton: React.FC<Props> = ({ session, techDefs, compact = false }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
|
||||
const labelDownload = isRo ? 'Descarcă PDF' : 'Download PDF';
|
||||
const labelGenerating = isRo ? 'Se generează…' : 'Generating…';
|
||||
|
||||
if (!session || !session.session_id) {
|
||||
return (
|
||||
<Disabled compact={compact}>
|
||||
<Download size={compact ? 12 : 14} />
|
||||
{labelDownload}
|
||||
</Disabled>
|
||||
);
|
||||
}
|
||||
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `didi-report-${session.session_id.substring(0, 8)}-${dateStr}.pdf`;
|
||||
|
||||
return (
|
||||
<PDFDownloadLink
|
||||
document={<AnalysisReportPDF session={session} language={isRo ? 'ro' : 'en'} techDefs={techDefs} />}
|
||||
fileName={filename}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
{({ loading, error }) => (
|
||||
<Btn compact={compact} disabled={loading} type="button">
|
||||
<Download size={compact ? 12 : 14} />
|
||||
{loading ? labelGenerating : (error ? (isRo ? 'Eroare PDF' : 'PDF error') : labelDownload)}
|
||||
</Btn>
|
||||
)}
|
||||
</PDFDownloadLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadPdfButton;
|
||||
111
web/src/components/Reports/DownloadPdfButtonLazy.tsx
Normal file
111
web/src/components/Reports/DownloadPdfButtonLazy.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Lazy variant of DownloadPdfButton — fetches the full session detail on
|
||||
// click (history list view only has light items), then triggers the PDF
|
||||
// download via @react-pdf/renderer's pdf() helper.
|
||||
import React, { useState } from 'react';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { httpClient } from '../../services/http.client';
|
||||
import { HISTORY_ENDPOINTS } from '../../services/api.constants';
|
||||
import { KeycloakService } from '../../services/keycloak.service';
|
||||
import { TechniqueDefinitionsService } from '../../services/technique-definitions.service';
|
||||
import { AnalysisReportPDF } from './AnalysisReportPDF';
|
||||
import type { AnalysisSession } from '../../types/analysis-session';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
/** When true, button shows only the icon (used in tight history rows). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const Btn = styled.button<{ compact?: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: ${p => p.compact ? '6px 10px' : '9px 14px'};
|
||||
font-size: ${p => p.compact ? '11px' : '13px'};
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: filter 0.15s ease, transform 0.1s ease;
|
||||
|
||||
&:hover { filter: brightness(1.1); }
|
||||
&:active { transform: translateY(1px); }
|
||||
&:disabled { opacity: 0.7; cursor: wait; }
|
||||
`;
|
||||
|
||||
const Spin = styled(Loader2)`
|
||||
animation: spin 1s linear infinite;
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
||||
interface HistoryDetailResponse {
|
||||
success: boolean;
|
||||
data: AnalysisSession;
|
||||
}
|
||||
|
||||
export const DownloadPdfButtonLazy: React.FC<Props> = ({ sessionId, compact = false }) => {
|
||||
const { i18n } = useTranslation();
|
||||
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const userId = KeycloakService.getUserFromToken()?.sub;
|
||||
const params = userId ? `?user_id=${userId}` : '';
|
||||
const url = `${HISTORY_ENDPOINTS.DETAIL(sessionId)}${params}`;
|
||||
|
||||
// Fetch session detail + technique definitions in parallel.
|
||||
const [res, techDefs] = await Promise.all([
|
||||
httpClient.get<HistoryDetailResponse>(url),
|
||||
TechniqueDefinitionsService.getDefinitions().catch(() => []),
|
||||
]);
|
||||
|
||||
if (!res?.success || !res.data) {
|
||||
throw new Error('No data');
|
||||
}
|
||||
|
||||
const blob = await pdf(
|
||||
<AnalysisReportPDF session={res.data} language={isRo ? 'ro' : 'en'} techDefs={techDefs} />
|
||||
).toBlob();
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
const filename = `didi-report-${sessionId.substring(0, 8)}-${dateStr}.pdf`;
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Revoke after a short delay so the download has time to start.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000);
|
||||
} catch (err: any) {
|
||||
console.error('[DownloadPdfButtonLazy] failed:', err);
|
||||
const msg = err?.message || String(err);
|
||||
alert(`${isRo ? 'Eroare la generarea PDF' : 'PDF generation failed'}: ${msg}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const labelDownload = isRo ? 'PDF' : 'PDF';
|
||||
const labelGenerating = isRo ? 'Generez…' : 'Generating…';
|
||||
|
||||
return (
|
||||
<Btn compact={compact} disabled={busy} onClick={handleClick} type="button" title={isRo ? 'Descarcă raport PDF' : 'Download PDF report'}>
|
||||
{busy ? <Spin size={compact ? 12 : 14} /> : <Download size={compact ? 12 : 14} />}
|
||||
{busy ? labelGenerating : labelDownload}
|
||||
</Btn>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadPdfButtonLazy;
|
||||
83
web/src/components/Reports/ShareReportButton.tsx
Normal file
83
web/src/components/Reports/ShareReportButton.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Buton "Partajează" pentru pagina de rezultate. Folosește navigator.share
|
||||
// (unde există) cu fallback copy-to-clipboard al unui link intern către
|
||||
// rezultat (/dashboard?section=history&session=<session_id>) + toast de confirmare.
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
/** Compact mode for use in tight rows (smaller padding/font). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const Btn = styled.button<{ compact?: boolean }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-text);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 8px;
|
||||
padding: ${p => p.compact ? '6px 10px' : '9px 14px'};
|
||||
font-size: ${p => p.compact ? '0.6875rem' : '0.8125rem'};
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover { border-color: var(--accent); color: var(--accent-hover); }
|
||||
&:active { transform: translateY(1px); }
|
||||
`;
|
||||
|
||||
/** Build the internal shareable link for an analysis result. */
|
||||
const buildShareUrl = (sessionId: string): string =>
|
||||
`${window.location.origin}/dashboard?section=history&session=${encodeURIComponent(sessionId)}`;
|
||||
|
||||
export const ShareReportButton: React.FC<Props> = ({ sessionId, compact = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
const copyToClipboard = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success(t('share.linkCopied'));
|
||||
} catch {
|
||||
toast.error(t('share.shareFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const url = buildShareUrl(sessionId);
|
||||
|
||||
if (typeof navigator.share === 'function') {
|
||||
try {
|
||||
await navigator.share({ title: t('share.shareTitle'), url });
|
||||
return;
|
||||
} catch (err) {
|
||||
// Utilizatorul a anulat dialogul nativ — nu forțăm fallback-ul
|
||||
if ((err as { name?: string })?.name === 'AbortError') return;
|
||||
// Alt eșec (ex. permisiuni) — cădem pe copy-to-clipboard
|
||||
}
|
||||
}
|
||||
await copyToClipboard(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Btn
|
||||
compact={compact}
|
||||
onClick={handleShare}
|
||||
type="button"
|
||||
aria-label={t('share.share')}
|
||||
title={t('share.share')}
|
||||
>
|
||||
<Share2 size={compact ? 12 : 14} aria-hidden="true" />
|
||||
{t('share.share')}
|
||||
</Btn>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareReportButton;
|
||||
644
web/src/components/Settings/Settings.tsx
Normal file
644
web/src/components/Settings/Settings.tsx
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { typography, spacing, borderRadius, animation } from '../../theme';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useToast } from '../Toast';
|
||||
import { useUsageStats, usePlans } from '../../hooks/useSubscription';
|
||||
import { KeycloakService } from '../../services/keycloak.service';
|
||||
|
||||
import { SubscriptionCard } from './Subscription/SubscriptionCard';
|
||||
import { UsageStats } from './Subscription/UsageStats';
|
||||
import { PlanComparison } from './Subscription/PlanComparison';
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
// React Query hooks handle auth gating automatically via KeycloakService.isAuthenticated()
|
||||
const { data: usageStats, isLoading: loadingSubscription } = useUsageStats();
|
||||
const { data: plansData = [] } = usePlans();
|
||||
|
||||
// Initialize from user data
|
||||
const [username, setUsername] = useState('');
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
// UI state for plan comparison modal
|
||||
const [showPlanComparison, setShowPlanComparison] = useState(false);
|
||||
|
||||
// Load user data when component mounts or user changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setUsername(user.email?.split('@')[0] || '');
|
||||
setFirstName(user.firstName || '');
|
||||
setLastName(user.lastName || '');
|
||||
setEmail(user.email || '');
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSaveProfile = () => {
|
||||
// Save profile logic
|
||||
toast.success(t('settings.profileUpdated'));
|
||||
};
|
||||
|
||||
|
||||
const handleUpgradePlan = async (_planId: number) => {
|
||||
// TODO: Backend upgrade endpoint not yet available
|
||||
toast.info(t('settings.upgradesNotAvailable'));
|
||||
setShowPlanComparison(false);
|
||||
};
|
||||
|
||||
// Show loading or not authenticated message
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Container>
|
||||
<SectionTitle>{t('settings.title')}</SectionTitle>
|
||||
<InfoMessage>
|
||||
ℹ️ {t('settings.pleaseLogin')}
|
||||
</InfoMessage>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PageHeader>
|
||||
<SectionTitle>{t('settings.title')}</SectionTitle>
|
||||
<SectionSubtitle>{t('settings.subtitle')}</SectionSubtitle>
|
||||
|
||||
{user && (
|
||||
<UserInfoBanner>
|
||||
<UserInfoLabel>{t('settings.loggedInAs')}</UserInfoLabel>
|
||||
<UserInfoValue>{user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.email?.split('@')[0]}</UserInfoValue>
|
||||
<UserInfoSubtext>({user.email})</UserInfoSubtext>
|
||||
</UserInfoBanner>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SectionsGrid>
|
||||
{/* Profile Section */}
|
||||
<Section>
|
||||
<SectionHeader>{t('settings.profileInfo')}</SectionHeader>
|
||||
|
||||
<FormGroup>
|
||||
<Label htmlFor="username-input">{t('settings.username')}</Label>
|
||||
<Input
|
||||
id="username-input"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t('settings.enterUsername')}
|
||||
disabled
|
||||
aria-describedby="username-helper"
|
||||
/>
|
||||
<HelperText id="username-helper">{t('settings.usernameNoChange')}</HelperText>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label htmlFor="firstname-input">{t('settings.firstName')}</Label>
|
||||
<Input
|
||||
id="firstname-input"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder={t('settings.enterFirstName')}
|
||||
aria-label={t('settings.firstName')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label htmlFor="lastname-input">{t('settings.lastName')}</Label>
|
||||
<Input
|
||||
id="lastname-input"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder={t('settings.enterLastName')}
|
||||
aria-label={t('settings.lastName')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<Label htmlFor="email-input">{t('settings.email')}</Label>
|
||||
<Input
|
||||
id="email-input"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('settings.enterEmail')}
|
||||
aria-label={t('settings.email')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<SaveButton onClick={handleSaveProfile} aria-label={t('settings.saveProfile')}>
|
||||
<span aria-hidden="true">💾</span> {t('settings.saveProfile')}
|
||||
</SaveButton>
|
||||
|
||||
<SectionDivider />
|
||||
<SectionHeader>{t('settings.changePassword')}</SectionHeader>
|
||||
<PasswordDescription>
|
||||
{t('settings.changePasswordDesc')}
|
||||
</PasswordDescription>
|
||||
<SaveButton onClick={() => KeycloakService.changePassword()} aria-label={t('settings.changePassword')}>
|
||||
<span aria-hidden="true">🔒</span> {t('settings.changePassword')}
|
||||
</SaveButton>
|
||||
</Section>
|
||||
|
||||
{/* Browser Extension + Mobile App — disponibile pentru orice plan */}
|
||||
<Section>
|
||||
<SectionHeader>{t('settings.browserExtension')}</SectionHeader>
|
||||
<ExtDescription>{t('settings.browserExtensionDesc')}</ExtDescription>
|
||||
|
||||
<ExtDownloadRow>
|
||||
<ExtDownloadInfo>
|
||||
<ExtDownloadIcon aria-hidden="true">🧩</ExtDownloadIcon>
|
||||
<div>
|
||||
<ExtDownloadName>{t('settings.extDownloadName')}</ExtDownloadName>
|
||||
<ExtDownloadVersion>{t('settings.extDownloadVersion')}</ExtDownloadVersion>
|
||||
</div>
|
||||
</ExtDownloadInfo>
|
||||
<DownloadLink href="/downloads/didi-extension-latest.zip" download>
|
||||
{t('settings.extDownload')}
|
||||
</DownloadLink>
|
||||
</ExtDownloadRow>
|
||||
|
||||
<ExtSteps>
|
||||
<li>{t('settings.extInstallStep1')}</li>
|
||||
<li>{t('settings.extInstallStep2')}</li>
|
||||
<li>{t('settings.extInstallStep3')}</li>
|
||||
</ExtSteps>
|
||||
|
||||
<SectionDivider />
|
||||
<SectionHeader>{t('settings.mobileAppSection')}</SectionHeader>
|
||||
<ExtDescription>{t('settings.mobileAppDesc')}</ExtDescription>
|
||||
|
||||
<MobileAppContent>
|
||||
<MobileAppIcon>📱</MobileAppIcon>
|
||||
<MobileAppInfo>
|
||||
<MobileAppName>{t('settings.didiAndroid')}</MobileAppName>
|
||||
<MobileAppVersion>{t('settings.appVersion')}</MobileAppVersion>
|
||||
</MobileAppInfo>
|
||||
</MobileAppContent>
|
||||
|
||||
<DownloadLink href="/downloads/didi.apk" download="didi.apk">
|
||||
{t('settings.downloadApk')}
|
||||
</DownloadLink>
|
||||
|
||||
<SectionDivider />
|
||||
<SectionHeader>{t('settings.deliverySection')}</SectionHeader>
|
||||
<ExtDescription>{t('settings.deliveryDesc')}</ExtDescription>
|
||||
|
||||
<ExtDownloadRow>
|
||||
<ExtDownloadInfo>
|
||||
<ExtDownloadIcon aria-hidden="true">🐳</ExtDownloadIcon>
|
||||
<div>
|
||||
<ExtDownloadName>{t('settings.deliveryImageName')}</ExtDownloadName>
|
||||
<ExtDownloadVersion>{t('settings.deliveryImageVersion')}</ExtDownloadVersion>
|
||||
</div>
|
||||
</ExtDownloadInfo>
|
||||
<DownloadLink href="/downloads/livrare/didi-frontend-web_lot3-1.0.docker.tar.gz" download>
|
||||
{t('settings.deliveryImageDownload')}
|
||||
</DownloadLink>
|
||||
</ExtDownloadRow>
|
||||
|
||||
<DeliveryLinksRow>
|
||||
<DeliveryLink href="/downloads/livrare/SHA256SUMS.txt" download>SHA256SUMS.txt</DeliveryLink>
|
||||
<DeliveryLink href="/downloads/livrare/README_LIVRARE.md" download>README_LIVRARE.md</DeliveryLink>
|
||||
</DeliveryLinksRow>
|
||||
</Section>
|
||||
|
||||
{/* Subscription & Billing */}
|
||||
<Section>
|
||||
<SectionHeader>{t('settings.subscriptionBilling')}</SectionHeader>
|
||||
|
||||
{loadingSubscription ? (
|
||||
<LoadingMessage>{t('settings.loadingSubscription')}</LoadingMessage>
|
||||
) : usageStats ? (
|
||||
<>
|
||||
{!showPlanComparison ? (
|
||||
<>
|
||||
<SubscriptionGrid>
|
||||
<SubscriptionCard
|
||||
data={usageStats}
|
||||
onUpgrade={() => setShowPlanComparison(true)}
|
||||
/>
|
||||
<UsageStats stats={usageStats} />
|
||||
</SubscriptionGrid>
|
||||
{plansData && plansData.length > 1 && (
|
||||
<ViewPlansButton onClick={() => setShowPlanComparison(true)}>
|
||||
{t('settings.viewAllPlans')}
|
||||
</ViewPlansButton>
|
||||
)}
|
||||
</>
|
||||
) : plansData ? (
|
||||
<>
|
||||
<BackButton onClick={() => setShowPlanComparison(false)}>
|
||||
{t('settings.backToCurrentPlan')}
|
||||
</BackButton>
|
||||
<PlanComparison
|
||||
plans={plansData}
|
||||
currentPlanId={usageStats.plan.id}
|
||||
onSelectPlan={handleUpgradePlan}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<LoadingMessage>{t('settings.loadingPlans')}</LoadingMessage>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<InfoMessage>{t('settings.failedSubscription')}</InfoMessage>
|
||||
)}
|
||||
</Section>
|
||||
</SectionsGrid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// Styled Components
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
animation: fadeIn ${animation.duration.slow} ${animation.easing.easeOut};
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PageHeader = styled.div`
|
||||
max-width: 1400px;
|
||||
margin: 0 auto ${spacing['3xl']}px;
|
||||
`;
|
||||
|
||||
const SectionsGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: ${spacing['2xl']}px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
/* Make subscription section span full width */
|
||||
& > div:last-child {
|
||||
@media (min-width: 1024px) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionTitle = styled.h2`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize['3xl']};
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.md}px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 32px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionSubtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing['3xl']}px;
|
||||
`;
|
||||
|
||||
const Section = styled.div`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.lg}px;
|
||||
padding: ${spacing['2xl']}px;
|
||||
margin-bottom: ${spacing['2xl']}px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: ${spacing.md}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.h3`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xl};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
padding-bottom: ${spacing.md}px;
|
||||
border-bottom: 1px solid var(--accent-border);
|
||||
`;
|
||||
|
||||
const FormGroup = styled.div`
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: var(--fg-primary);
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: ${spacing.md}px ${spacing.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
`;
|
||||
|
||||
const SaveButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
padding: ${spacing.md}px ${spacing['2xl']}px;
|
||||
background: var(--bg-surface);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer;
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const HelperText = styled.p`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-secondary);
|
||||
margin-top: ${spacing.xs}px;
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
const InfoMessage = styled.div`
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
padding: ${spacing['2xl']}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
text-align: center;
|
||||
margin-top: ${spacing['3xl']}px;
|
||||
`;
|
||||
|
||||
const UserInfoBanner = styled.div`
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
padding: ${spacing.lg}px ${spacing.xl}px;
|
||||
margin-bottom: ${spacing['2xl']}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.md}px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
const UserInfoLabel = styled.span`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
const UserInfoValue = styled.span`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--fg-primary);
|
||||
`;
|
||||
|
||||
const UserInfoSubtext = styled.span`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
// Step 5: Uncomment subscription styled components
|
||||
const SubscriptionGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: ${spacing.lg}px;
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: ${spacing['2xl']}px;
|
||||
margin-bottom: ${spacing['2xl']}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ViewPlansButton = styled.button`
|
||||
width: 100%;
|
||||
padding: ${spacing.lg}px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer;
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`;
|
||||
|
||||
const BackButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
padding: ${spacing.md}px ${spacing.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
cursor: pointer;
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
color: var(--fg-primary);
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingMessage = styled.div`
|
||||
text-align: center;
|
||||
padding: ${spacing['3xl']}px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.lg};
|
||||
`;
|
||||
|
||||
const SectionDivider = styled.div`
|
||||
border-top: 1px solid var(--accent-border);
|
||||
margin: ${spacing['2xl']}px 0;
|
||||
`;
|
||||
|
||||
const PasswordDescription = styled.p`
|
||||
font-size: ${typography.fontSize.sm}; color: var(--fg-secondary);
|
||||
margin-bottom: ${spacing.xl}px; line-height: 1.5;
|
||||
`;
|
||||
|
||||
/* Browser Extension */
|
||||
const ExtDescription = styled.p`
|
||||
font-size: ${typography.fontSize.sm}; color: var(--fg-secondary);
|
||||
margin-bottom: ${spacing.xl}px; line-height: 1.5;
|
||||
`;
|
||||
/* Mobile App */
|
||||
const MobileAppContent = styled.div`
|
||||
display: flex; align-items: center; gap: ${spacing.lg}px;
|
||||
padding: ${spacing.lg}px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
margin-bottom: ${spacing.xl}px;
|
||||
`;
|
||||
const MobileAppIcon = styled.div`
|
||||
font-size: 40px; flex-shrink: 0;
|
||||
`;
|
||||
const MobileAppInfo = styled.div`
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
`;
|
||||
const MobileAppName = styled.div`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.lg};
|
||||
font-weight: ${typography.fontWeight.semibold}; color: var(--fg-primary);
|
||||
`;
|
||||
const MobileAppVersion = styled.div`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
const DownloadButton = styled.button`
|
||||
display: inline-flex; align-items: center; gap: ${spacing.sm}px;
|
||||
padding: ${spacing.md}px ${spacing['2xl']}px;
|
||||
background: var(--bg-surface); border: 2px solid var(--accent);
|
||||
border-radius: ${borderRadius.md}px; color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold}; cursor: pointer;
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
box-shadow: var(--shadow-md);
|
||||
text-decoration: none;
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px); background: var(--accent-subtle);
|
||||
border-color: var(--accent-hover); box-shadow: var(--shadow-md);
|
||||
}
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
/* Variantă <a> a butonului de descărcare — tipată corect pentru href/download */
|
||||
const DownloadLink = DownloadButton.withComponent('a');
|
||||
|
||||
/* Browser Extension — download row (disponibil pentru orice plan) */
|
||||
const ExtDownloadRow = styled.div`
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: ${spacing.lg}px; margin-top: ${spacing.xl}px;
|
||||
padding: ${spacing.lg}px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column; align-items: stretch;
|
||||
}
|
||||
`;
|
||||
const ExtDownloadInfo = styled.div`
|
||||
display: flex; align-items: center; gap: ${spacing.md}px;
|
||||
`;
|
||||
const ExtDownloadIcon = styled.div`
|
||||
font-size: 32px; flex-shrink: 0;
|
||||
`;
|
||||
const ExtDownloadName = styled.div`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold}; color: var(--fg-primary);
|
||||
`;
|
||||
const ExtDownloadVersion = styled.div`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
const DeliveryLinksRow = styled.div`
|
||||
display: flex; gap: ${spacing.lg}px; margin-top: ${spacing.md}px; flex-wrap: wrap;
|
||||
`;
|
||||
const DeliveryLink = styled.a`
|
||||
font-size: ${typography.fontSize.sm}; color: var(--accent-text);
|
||||
text-decoration: underline; text-underline-offset: 3px;
|
||||
&:hover { color: var(--accent); }
|
||||
`;
|
||||
const ExtSteps = styled.ol`
|
||||
margin: ${spacing.lg}px 0 ${spacing.xl}px; padding-left: ${spacing.xl}px;
|
||||
display: flex; flex-direction: column; gap: ${spacing.sm}px;
|
||||
font-size: ${typography.fontSize.sm}; color: var(--fg-secondary); line-height: 1.55;
|
||||
`;
|
||||
363
web/src/components/Settings/Subscription/PlanComparison.tsx
Normal file
363
web/src/components/Settings/Subscription/PlanComparison.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/** @jsxImportSource @emotion/react */
|
||||
import { css } from '@emotion/react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Plan } from '../../../services/subscription.service';
|
||||
|
||||
interface PlanComparisonProps {
|
||||
plans: Plan[];
|
||||
currentPlanId: number;
|
||||
onSelectPlan: (planId: number) => void;
|
||||
}
|
||||
|
||||
const containerStyles = css`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-sizing: border-box; min-width: 0;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const headerStyles = css`
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
color: var(--fg-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--fg-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const plansGridStyles = css`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const planCardStyles = (isCurrent: boolean, isPopular: boolean) => css`
|
||||
border: 2px solid ${isCurrent ? 'var(--accent)' : isPopular ? 'var(--accent-border)' : 'var(--border-default)'};
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
background: ${isCurrent ? 'var(--accent-subtle)' : 'var(--bg-surface)'};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
${!isCurrent && `
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const badgeStyles = css`
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: 24px;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
const planHeaderStyles = css`
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 22px;
|
||||
color: var(--fg-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
const priceStyles = css`
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: var(--accent-text);
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: var(--fg-secondary);
|
||||
font-weight: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
const featureListStyles = css`
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 20px 0 auto 0;
|
||||
min-height: 180px;
|
||||
`;
|
||||
|
||||
const featureItemStyles = css`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--fg-primary);
|
||||
|
||||
&::before {
|
||||
content: '✓';
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
line-height: 18px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
const buttonStyles = (isCurrent: boolean) => css`
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: ${isCurrent ? 'not-allowed' : 'pointer'};
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 20px;
|
||||
background: var(--accent-subtle);
|
||||
border: 2px solid var(--accent);
|
||||
color: var(--fg-primary);
|
||||
flex-shrink: 0;
|
||||
|
||||
${isCurrent ? `
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
` : `
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const confirmModalStyles = css`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
`;
|
||||
|
||||
const modalContentStyles = css`
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 20px;
|
||||
color: var(--fg-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 24px 0;
|
||||
color: var(--fg-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
`;
|
||||
|
||||
const modalButtonsStyles = css`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const modalButtonBaseStyles = css`
|
||||
padding: 10px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
`;
|
||||
|
||||
const cancelButtonStyles = css`
|
||||
${modalButtonBaseStyles}
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--fg-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
`;
|
||||
|
||||
const confirmButtonStyles = css`
|
||||
${modalButtonBaseStyles}
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`;
|
||||
|
||||
export const PlanComparison = ({ plans, currentPlanId, onSelectPlan }: PlanComparisonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
|
||||
const handleSelectPlan = (plan: Plan) => {
|
||||
setSelectedPlan(plan);
|
||||
setShowConfirmModal(true);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedPlan) {
|
||||
onSelectPlan(selectedPlan.id);
|
||||
setShowConfirmModal(false);
|
||||
setSelectedPlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowConfirmModal(false);
|
||||
setSelectedPlan(null);
|
||||
};
|
||||
|
||||
const formatPrice = (amount: number) => {
|
||||
if (amount === 0) return t('common.free');
|
||||
return `$${amount}`;
|
||||
};
|
||||
|
||||
const getFeatures = (plan: Plan) => {
|
||||
const features: string[] = [];
|
||||
features.push(t('subscription.creditsPerCycleFeature', { count: plan.creditsPerCycle }));
|
||||
if (plan.maxImages) features.push(t('subscription.imagesPerCycle', { count: plan.maxImages }));
|
||||
if (plan.maxVideoMinutes) features.push(t('subscription.videoPerCycle', { count: plan.maxVideoMinutes }));
|
||||
if (plan.storageLimitGb) features.push(t('subscription.storageGb', { count: plan.storageLimitGb }));
|
||||
return features;
|
||||
};
|
||||
|
||||
const isPopular = (plan: Plan) => {
|
||||
const name = plan.name.toLowerCase();
|
||||
return name.includes('pro') || name.includes('personal') || name.includes('guardian');
|
||||
};
|
||||
const isCurrent = (plan: Plan) => plan.id === currentPlanId;
|
||||
|
||||
// Sort plans by price
|
||||
const sortedPlans = [...plans].sort((a, b) => a.priceAmount - b.priceAmount);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div css={containerStyles}>
|
||||
<div css={headerStyles}>
|
||||
<h3>{t('subscription.comparePlans')}</h3>
|
||||
<p>{t('subscription.choosePlan')}</p>
|
||||
</div>
|
||||
|
||||
<div css={plansGridStyles}>
|
||||
{sortedPlans.map((plan) => (
|
||||
<div key={plan.id} css={planCardStyles(isCurrent(plan), isPopular(plan))}>
|
||||
{isPopular(plan) && !isCurrent(plan) && <div css={badgeStyles}>{t('subscription.mostPopular')}</div>}
|
||||
{isCurrent(plan) && <div css={badgeStyles}>{t('subscription.currentPlan')}</div>}
|
||||
|
||||
<div css={planHeaderStyles}>
|
||||
<h4>{plan.name}</h4>
|
||||
<div css={priceStyles}>
|
||||
{formatPrice(plan.priceAmount)}
|
||||
{plan.priceAmount > 0 && <span> {t('subscription.perMonth')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul css={featureListStyles}>
|
||||
{getFeatures(plan).map((feature, index) => (
|
||||
<li key={index} css={featureItemStyles}>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
css={buttonStyles(isCurrent(plan))}
|
||||
onClick={() => handleSelectPlan(plan)}
|
||||
disabled={isCurrent(plan)}
|
||||
>
|
||||
{isCurrent(plan) ? t('subscription.currentPlan') : plan.priceAmount === 0 ? t('subscription.downgrade') : t('subscription.upgrade')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfirmModal && selectedPlan && (
|
||||
<div css={confirmModalStyles} onClick={handleCancel}>
|
||||
<div css={modalContentStyles} onClick={(e) => e.stopPropagation()}>
|
||||
<h4>{t('subscription.confirmPlanChange')}</h4>
|
||||
<p>
|
||||
{t('subscription.confirmSwitch', { plan: selectedPlan.name, price: formatPrice(selectedPlan.priceAmount), period: selectedPlan.priceAmount > 0 ? t('subscription.perMonthText') : '' })}
|
||||
</p>
|
||||
<p>
|
||||
{selectedPlan.priceAmount > 0
|
||||
? t('subscription.upgradeImmediate')
|
||||
: t('subscription.downgradeEnd')}
|
||||
</p>
|
||||
<div css={modalButtonsStyles}>
|
||||
<button css={cancelButtonStyles} onClick={handleCancel}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button css={confirmButtonStyles} onClick={handleConfirm}>
|
||||
{t('subscription.confirmChange')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
// Adaugă următoarele în styled components:
|
||||
|
||||
// planCardStyles - după linia 85, adaugă:
|
||||
[data-theme="light"] & {
|
||||
background: ${isCurrent ? 'rgba(139, 92, 246, 0.1)' : 'rgba(139, 92, 246, 0.05)'};
|
||||
border-color: ${isCurrent ? 'rgba(139, 92, 246, 0.5)' : isPopular ? 'rgba(139, 92, 246, 0.35)' : 'rgba(139, 92, 246, 0.25)'};
|
||||
|
||||
${!isCurrent && `
|
||||
&:hover {
|
||||
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.15);
|
||||
border-color: rgba(139, 92, 246, 0.4);
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
// badgeStyles - după linia 101:
|
||||
[data-theme="light"] & {
|
||||
background: rgba(139, 92, 246, 0.9);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
// planHeaderStyles h4 - după linia 112:
|
||||
[data-theme="light"] & {
|
||||
h4 {
|
||||
color: #1F2933;
|
||||
}
|
||||
}
|
||||
|
||||
// priceStyles - după linia 125:
|
||||
[data-theme="light"] & {
|
||||
color: rgba(139, 92, 246, 1);
|
||||
span {
|
||||
color: #6B7280;
|
||||
}
|
||||
}
|
||||
|
||||
// descriptionStyles - după linia 133:
|
||||
[data-theme="light"] & {
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
// featureItemStyles - după linia 164:
|
||||
[data-theme="light"] & {
|
||||
color: rgba(31, 41, 51, 0.9);
|
||||
&::before {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
color: #1F2933;
|
||||
}
|
||||
}
|
||||
|
||||
// buttonStyles - după linia 188:
|
||||
[data-theme="light"] & {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: rgba(139, 92, 246, 0.4);
|
||||
color: #1F2933;
|
||||
|
||||
${isCurrent ? `
|
||||
opacity: 0.6;
|
||||
` : `
|
||||
&:hover {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
border-color: rgba(139, 92, 246, 0.6);
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
// modalContentStyles - după linia 226:
|
||||
[data-theme="light"] & {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.2);
|
||||
|
||||
h4 {
|
||||
color: #1F2933;
|
||||
}
|
||||
p {
|
||||
color: rgba(31, 41, 51, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
// cancelButtonStyles - după linia 254:
|
||||
[data-theme="light"] & {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border-color: rgba(139, 92, 246, 0.25);
|
||||
color: #1F2933;
|
||||
|
||||
&:hover {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border-color: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
// confirmButtonStyles - după linia 269:
|
||||
[data-theme="light"] & {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
color: #1F2933;
|
||||
|
||||
&:hover {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
border-color: rgba(139, 92, 246, 0.7);
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
}
|
||||
116
web/src/components/Settings/Subscription/SubscriptionCard.tsx
Normal file
116
web/src/components/Settings/Subscription/SubscriptionCard.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/** @jsxImportSource @emotion/react */
|
||||
import { css } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { UsageStats } from '../../../services/subscription.service';
|
||||
|
||||
interface SubscriptionCardProps {
|
||||
data: UsageStats;
|
||||
onUpgrade: () => void;
|
||||
}
|
||||
|
||||
const cardStyles = css`
|
||||
background: var(--bg-surface); border: 1px solid var(--accent-border);
|
||||
border-radius: 12px; padding: 24px; color: var(--fg-primary);
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box; min-width: 0; overflow: hidden;
|
||||
&:hover { border-color: var(--accent); background: var(--bg-hover); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const headerStyles = css`
|
||||
display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const planNameStyles = css`
|
||||
font-size: 28px; font-weight: bold; margin: 0 0 8px 0; color: var(--fg-primary);
|
||||
word-break: break-word;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 22px;
|
||||
}
|
||||
`;
|
||||
|
||||
const priceStyles = css`
|
||||
font-size: 16px; color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
const badgeStyles = css`
|
||||
background: var(--accent-subtle); border: 1px solid var(--accent-border);
|
||||
padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; color: var(--fg-primary);
|
||||
`;
|
||||
|
||||
const featureListStyles = css`list-style: none; padding: 0; margin: 20px 0;`;
|
||||
|
||||
const featureItemStyles = css`
|
||||
display: flex; align-items: center; margin-bottom: 12px; font-size: 14px; color: var(--fg-primary);
|
||||
&::before { content: '✓'; display: inline-block; width: 20px; height: 20px;
|
||||
background: var(--accent); border-radius: 50%; margin-right: 12px;
|
||||
text-align: center; line-height: 20px; font-weight: bold; color: var(--fg-on-accent); }
|
||||
`;
|
||||
|
||||
const upgradeButtonStyles = css`
|
||||
margin-top: 20px; padding: 10px 20px; border-radius: 6px; font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.3s ease;
|
||||
background: var(--accent-subtle); border: 2px solid var(--accent); color: var(--fg-primary);
|
||||
&:hover { transform: translateY(-2px); background: var(--accent-subtle);
|
||||
border-color: var(--accent-hover); box-shadow: var(--shadow-md); }
|
||||
`;
|
||||
|
||||
const periodInfoStyles = css`
|
||||
font-size: 13px; color: var(--fg-secondary); margin-top: 16px; padding-top: 16px;
|
||||
border-top: 1px solid var(--accent-border); word-break: break-word;
|
||||
`;
|
||||
|
||||
export const SubscriptionCard = ({ data, onUpgrade }: SubscriptionCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { plan, subscription, credits } = data;
|
||||
const isFree = plan.priceAmount === 0;
|
||||
|
||||
const features = [
|
||||
t('subscription.creditsPerCycleFeature', { count: plan.creditsPerCycle }),
|
||||
t('subscription.storageGb', { count: plan.storageLimitGb }),
|
||||
];
|
||||
|
||||
return (
|
||||
<div css={cardStyles}>
|
||||
<div css={headerStyles}>
|
||||
<div>
|
||||
<h2 css={planNameStyles}>{plan.name}</h2>
|
||||
{plan.priceAmount > 0 && (
|
||||
<div css={priceStyles}>${plan.priceAmount}/mo</div>
|
||||
)}
|
||||
</div>
|
||||
<div css={badgeStyles}>
|
||||
{subscription.isActive ? t('common.active') : t('common.inactive')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul css={featureListStyles}>
|
||||
{features.map((feature, i) => (
|
||||
<li key={i} css={featureItemStyles}>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isFree && (
|
||||
<button css={upgradeButtonStyles} onClick={onUpgrade}>
|
||||
{t('subscription.upgradePlan')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div css={periodInfoStyles}>
|
||||
{isFree
|
||||
? t('subscription.freePlanInfo')
|
||||
: subscription.activationDate
|
||||
? t('subscription.activeSince', { date: new Date(subscription.activationDate).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) })
|
||||
: t('subscription.activeSubscription')
|
||||
}
|
||||
{credits.remaining > 0 && ` — ${t('subscription.creditsRemaining', { count: credits.remaining })}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
132
web/src/components/Settings/Subscription/UsageStats.tsx
Normal file
132
web/src/components/Settings/Subscription/UsageStats.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/** @jsxImportSource @emotion/react */
|
||||
import { css } from '@emotion/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SubscriptionService } from '../../../services/subscription.service';
|
||||
import type { UsageStats as UsageStatsType } from '../../../services/subscription.service';
|
||||
|
||||
interface UsageStatsProps {
|
||||
stats: UsageStatsType;
|
||||
}
|
||||
|
||||
const containerStyles = css`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box; min-width: 0; overflow: hidden;
|
||||
&:hover { border-color: var(--accent); background: var(--bg-hover); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const headerStyles = css`
|
||||
margin-bottom: 24px;
|
||||
h3 { margin: 0 0 8px 0; font-size: 20px; color: var(--fg-primary); font-weight: 600; }
|
||||
p { margin: 0; color: var(--fg-secondary); font-size: 14px; }
|
||||
`;
|
||||
|
||||
const usageItemStyles = css`margin-bottom: 24px; &:last-child { margin-bottom: 0; }`;
|
||||
|
||||
const usageHeaderStyles = css`
|
||||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const usageLabelStyles = css`
|
||||
font-size: 14px; font-weight: 600; color: var(--fg-primary);
|
||||
`;
|
||||
|
||||
const usageValueStyles = css`
|
||||
font-size: 14px; color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
const progressBarContainerStyles = css`
|
||||
width: 100%; height: 8px; background: var(--accent-subtle);
|
||||
border-radius: 4px; overflow: hidden;
|
||||
`;
|
||||
|
||||
const progressBarFillStyles = (percentage: number, color: string) => css`
|
||||
height: 100%; width: ${percentage}%; background: ${color};
|
||||
transition: width 0.3s ease; border-radius: 4px;
|
||||
`;
|
||||
|
||||
const warningStyles = css`
|
||||
margin-top: 8px; padding: 8px 12px;
|
||||
background: rgba(255, 193, 7, 0.1); border-left: 3px solid rgba(255, 193, 7, 0.6);
|
||||
border-radius: 4px; font-size: 13px; color: rgba(255, 193, 7, 0.9);
|
||||
[data-theme="light"] & { background: rgba(217, 119, 6, 0.1); border-left-color: rgba(217, 119, 6, 0.6); color: #d97706; }
|
||||
`;
|
||||
|
||||
const dangerStyles = css`
|
||||
margin-top: 8px; padding: 8px 12px;
|
||||
background: rgba(230, 57, 70, 0.1); border-left: 3px solid rgba(230, 57, 70, 0.6);
|
||||
border-radius: 4px; font-size: 13px; color: rgba(255, 92, 92, 0.9);
|
||||
[data-theme="light"] & { background: rgba(220, 38, 38, 0.1); border-left-color: rgba(220, 38, 38, 0.6); color: #dc2626; }
|
||||
`;
|
||||
|
||||
const upgradeButtonStyles = css`
|
||||
margin-top: 20px; width: 100%; padding: 12px;
|
||||
background: var(--accent-subtle); border: 2px solid var(--accent);
|
||||
color: var(--fg-primary); border-radius: 8px; font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; transition: all 0.3s ease;
|
||||
&:hover { transform: translateY(-2px); background: var(--accent-subtle);
|
||||
border-color: var(--accent-hover); box-shadow: var(--shadow-md); }
|
||||
`;
|
||||
|
||||
export const UsageStats = ({ stats }: UsageStatsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { credits, plan } = stats;
|
||||
// Guard against backend inconsistency (remaining > total)
|
||||
const effectiveTotal = Math.max(credits.total, credits.remaining);
|
||||
const usagePercent = effectiveTotal > 0
|
||||
? Math.round(((effectiveTotal - credits.remaining) / effectiveTotal) * 100)
|
||||
: 0;
|
||||
const color = SubscriptionService.getUsageColor(usagePercent);
|
||||
|
||||
const getAlert = () => {
|
||||
if (credits.remaining === 0) {
|
||||
return { type: 'danger' as const, message: t('subscription.creditsExhausted') };
|
||||
}
|
||||
if (usagePercent >= 90) {
|
||||
return { type: 'danger' as const, message: t('subscription.creditsLowDanger', { percent: usagePercent, remaining: credits.remaining }) };
|
||||
}
|
||||
if (usagePercent >= 75) {
|
||||
return { type: 'warning' as const, message: t('subscription.creditsLowWarning', { percent: usagePercent, remaining: credits.remaining, total: effectiveTotal }) };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const alert = getAlert();
|
||||
|
||||
return (
|
||||
<div css={containerStyles}>
|
||||
<div css={headerStyles}>
|
||||
<h3>{t('subscription.usageStats')}</h3>
|
||||
<p>{t('subscription.creditsPerCycle', { name: plan.name, credits: plan.creditsPerCycle })}</p>
|
||||
</div>
|
||||
|
||||
<div css={usageItemStyles}>
|
||||
<div css={usageHeaderStyles}>
|
||||
<span css={usageLabelStyles}>{t('subscription.credits')}</span>
|
||||
<span css={usageValueStyles}>{credits.remaining} / {effectiveTotal}</span>
|
||||
</div>
|
||||
<div css={progressBarContainerStyles}>
|
||||
<div css={progressBarFillStyles(Math.min(usagePercent, 100), color)} />
|
||||
</div>
|
||||
{alert && (
|
||||
<div css={alert.type === 'danger' ? dangerStyles : warningStyles}>
|
||||
{alert.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{usagePercent >= 75 && (
|
||||
<button css={upgradeButtonStyles} onClick={() => { window.location.hash = '#upgrade'; }}>
|
||||
{t('subscription.upgradeForMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
3
web/src/components/Settings/Subscription/index.ts
Normal file
3
web/src/components/Settings/Subscription/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { SubscriptionCard } from './SubscriptionCard';
|
||||
export { UsageStats } from './UsageStats';
|
||||
export { PlanComparison } from './PlanComparison';
|
||||
268
web/src/components/Sidebar/Sidebar.tsx
Normal file
268
web/src/components/Sidebar/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import React, { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { typography, spacing, media } from '../../theme';
|
||||
|
||||
export type SidebarSection = 'pipeline' | 'agent' | 'didi' | 'source' | 'claim' | 'aitamper' | 'history' | 'settings';
|
||||
|
||||
interface SidebarProps {
|
||||
activeSection: SidebarSection;
|
||||
onSectionChange: (section: SidebarSection) => void;
|
||||
onLogout: () => void;
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
onLogout,
|
||||
isOpen,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [skillsExpanded, setSkillsExpanded] = useState(true);
|
||||
|
||||
const handleSkillsClick = () => {
|
||||
setSkillsExpanded(!skillsExpanded);
|
||||
};
|
||||
|
||||
const isSkillSection = (section: SidebarSection) =>
|
||||
section === 'didi' || section === 'source' || section === 'claim' || section === 'aitamper';
|
||||
|
||||
return (
|
||||
<SidebarContainer isOpen={isOpen}>
|
||||
<SidebarContent>
|
||||
{/* Full Analysis (Pipeline) */}
|
||||
<MenuSection>
|
||||
<MenuItem
|
||||
active={activeSection === 'pipeline'}
|
||||
onClick={() => onSectionChange('pipeline')}
|
||||
>
|
||||
<MenuLabel>{t('sidebar.fullAnalysis')}</MenuLabel>
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
|
||||
{/* DIDI Skills Section with Submenu */}
|
||||
<MenuSection>
|
||||
<MenuItem
|
||||
active={isSkillSection(activeSection)}
|
||||
onClick={handleSkillsClick}
|
||||
aria-expanded={skillsExpanded}
|
||||
aria-controls="skills-submenu"
|
||||
>
|
||||
<MenuLabel>{t('sidebar.didiSkills')}</MenuLabel>
|
||||
<ExpandIcon expanded={skillsExpanded}>
|
||||
{skillsExpanded ? '▼' : '▶'}
|
||||
</ExpandIcon>
|
||||
</MenuItem>
|
||||
|
||||
{skillsExpanded && (
|
||||
<Submenu id="skills-submenu" role="menu">
|
||||
<SubmenuItem
|
||||
active={activeSection === 'didi'}
|
||||
onClick={() => onSectionChange('didi')}
|
||||
role="menuitem"
|
||||
>
|
||||
<SubmenuLabel>{t('sidebar.manipulationTechniques')}</SubmenuLabel>
|
||||
</SubmenuItem>
|
||||
|
||||
<SubmenuItem
|
||||
active={activeSection === 'source'}
|
||||
onClick={() => onSectionChange('source')}
|
||||
role="menuitem"
|
||||
>
|
||||
<SubmenuLabel>{t('sidebar.sourceAssessment')}</SubmenuLabel>
|
||||
</SubmenuItem>
|
||||
|
||||
<SubmenuItem
|
||||
active={activeSection === 'claim'}
|
||||
onClick={() => onSectionChange('claim')}
|
||||
role="menuitem"
|
||||
>
|
||||
<SubmenuLabel>{t('sidebar.claimAnalysis')}</SubmenuLabel>
|
||||
</SubmenuItem>
|
||||
|
||||
<SubmenuItem
|
||||
active={activeSection === 'aitamper'}
|
||||
onClick={() => onSectionChange('aitamper')}
|
||||
role="menuitem"
|
||||
>
|
||||
<SubmenuLabel>{t('sidebar.aiTamper')}</SubmenuLabel>
|
||||
</SubmenuItem>
|
||||
</Submenu>
|
||||
)}
|
||||
</MenuSection>
|
||||
|
||||
{/* History Section */}
|
||||
<MenuSection>
|
||||
<MenuItem
|
||||
active={activeSection === 'history'}
|
||||
onClick={() => onSectionChange('history')}
|
||||
>
|
||||
<MenuLabel>{t('sidebar.history')}</MenuLabel>
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
|
||||
{/* Settings Section */}
|
||||
<MenuSection>
|
||||
<MenuItem
|
||||
active={activeSection === 'settings'}
|
||||
onClick={() => onSectionChange('settings')}
|
||||
>
|
||||
<MenuLabel>{t('sidebar.settings')}</MenuLabel>
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
|
||||
{/* Logout Button - Bottom */}
|
||||
<LogoutSection>
|
||||
<LogoutButton onClick={onLogout}>
|
||||
<MenuLabel>{t('sidebar.logout')}</MenuLabel>
|
||||
</LogoutButton>
|
||||
</LogoutSection>
|
||||
</SidebarContent>
|
||||
</SidebarContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// Styled Components
|
||||
const SidebarContainer = styled.aside<{ isOpen?: boolean }>`
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 80px;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
z-index: 8;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
${media.maxTablet} {
|
||||
top: 70px;
|
||||
z-index: 9;
|
||||
transform: translateX(${p => p.isOpen ? '0' : '-100%'});
|
||||
box-shadow: ${p => p.isOpen ? 'var(--shadow-lg)' : 'none'};
|
||||
}
|
||||
`;
|
||||
|
||||
const SidebarContent = styled.nav`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${spacing.xl}px ${spacing.md}px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
&::-webkit-scrollbar { width: 6px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 3px; }
|
||||
&::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
|
||||
`;
|
||||
|
||||
const MenuSection = styled.div`
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
`;
|
||||
|
||||
const MenuItem = styled.button<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.md}px;
|
||||
width: 100%;
|
||||
padding: ${spacing.sm}px ${spacing.md}px;
|
||||
min-height: 44px;
|
||||
background: ${props => props.active ? 'var(--accent-subtle)' : 'transparent'};
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
box-shadow: ${props => props.active ? 'inset 2px 0 0 0 var(--accent)' : 'none'};
|
||||
color: ${props => props.active ? 'var(--accent-text)' : 'var(--fg-secondary)'};
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${props => props.active ? typography.fontWeight.semibold : typography.fontWeight.medium};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.active ? 'var(--accent-subtle)' : 'var(--bg-hover)'};
|
||||
color: ${props => props.active ? 'var(--accent-text)' : 'var(--fg-primary)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const MenuLabel = styled.span`flex: 1;`;
|
||||
|
||||
const ExpandIcon = styled.span<{ expanded: boolean }>`
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
transition: transform 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Submenu = styled.div`
|
||||
margin-top: ${spacing.xs}px;
|
||||
margin-left: ${spacing.md}px;
|
||||
`;
|
||||
|
||||
const SubmenuItem = styled.button<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
width: 100%;
|
||||
padding: 12px ${spacing.md}px;
|
||||
min-height: 44px;
|
||||
background: ${props => props.active ? 'var(--accent-subtle)' : 'transparent'};
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${props => props.active ? 'var(--accent-text)' : 'var(--fg-secondary)'};
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${props => props.active ? typography.fontWeight.medium : typography.fontWeight.normal};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
text-align: left;
|
||||
margin-bottom: ${spacing.xs}px;
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.active ? 'var(--accent-subtle)' : 'var(--bg-hover)'};
|
||||
color: ${props => props.active ? 'var(--accent-text)' : 'var(--fg-primary)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const SubmenuLabel = styled.span`flex: 1;`;
|
||||
|
||||
const LogoutSection = styled.div`
|
||||
margin-top: auto;
|
||||
padding-top: ${spacing.lg}px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
`;
|
||||
|
||||
const LogoutButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.md}px;
|
||||
width: 100%;
|
||||
padding: ${spacing.sm}px ${spacing.md}px;
|
||||
min-height: 44px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 92, 92, 0.8);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
text-align: left;
|
||||
|
||||
[data-theme="light"] & {
|
||||
color: rgba(220, 38, 38, 0.8);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 92, 92, 0.08);
|
||||
color: rgba(255, 92, 92, 1);
|
||||
}
|
||||
|
||||
[data-theme="light"] &:hover {
|
||||
background: rgba(220, 38, 38, 0.06);
|
||||
color: rgba(220, 38, 38, 1);
|
||||
}
|
||||
`;
|
||||
2
web/src/components/Sidebar/index.ts
Normal file
2
web/src/components/Sidebar/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Sidebar } from './Sidebar';
|
||||
export type { SidebarSection } from './Sidebar';
|
||||
239
web/src/components/ThemeToggle/ThemeToggle.tsx
Normal file
239
web/src/components/ThemeToggle/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { spacing, borderRadius, animation, sizing } from '../../theme';
|
||||
|
||||
// SVG Icons (inline for no external dependencies)
|
||||
const SunIcon: React.FC<{ size?: number }> = ({ size = 20 }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MoonIcon: React.FC<{ size?: number }> = ({ size = 20 }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SystemIcon: React.FC<{ size?: number }> = ({ size = 20 }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
type ThemeMode = 'dark' | 'light' | 'system';
|
||||
|
||||
const themeOptionDefs: { value: ThemeMode; labelKey: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'dark', labelKey: 'theme.dark', icon: <MoonIcon size={16} /> },
|
||||
{ value: 'light', labelKey: 'theme.light', icon: <SunIcon size={16} /> },
|
||||
{ value: 'system', labelKey: 'theme.system', icon: <SystemIcon size={16} /> },
|
||||
];
|
||||
|
||||
export const ThemeToggle: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Determine which icon to show based on theme state
|
||||
const getCurrentIcon = () => {
|
||||
if (theme === 'system') {
|
||||
return <SystemIcon />;
|
||||
}
|
||||
return resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />;
|
||||
};
|
||||
|
||||
const handleSelect = (value: ThemeMode) => {
|
||||
setTheme(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleContainer ref={containerRef}>
|
||||
<ToggleButton
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={t('theme.toggleMenu')}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
title={t('theme.currentTheme', { theme: theme === 'system' ? t('theme.system') : resolvedTheme })}
|
||||
>
|
||||
{getCurrentIcon()}
|
||||
</ToggleButton>
|
||||
|
||||
{isOpen && (
|
||||
<Dropdown role="listbox" aria-label={t('theme.selectTheme')}>
|
||||
{themeOptionDefs.map((option) => (
|
||||
<DropdownItem
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
role="option"
|
||||
aria-selected={theme === option.value}
|
||||
isActive={theme === option.value}
|
||||
>
|
||||
<IconWrapper>{option.icon}</IconWrapper>
|
||||
<span>{t(option.labelKey)}</span>
|
||||
</DropdownItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
)}
|
||||
</ToggleContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ToggleContainer = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
const ToggleButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${sizing.button.lg}px;
|
||||
height: ${sizing.button.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: ${borderRadius.md}px;
|
||||
cursor: pointer;
|
||||
color: var(--fg-primary);
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-subtle);
|
||||
border-color: var(--accent-border);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(0, 145, 152, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Dropdown = styled.div`
|
||||
position: absolute;
|
||||
top: calc(100% + ${spacing.sm}px);
|
||||
right: 0;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: ${borderRadius.sm}px;
|
||||
padding: ${spacing.xs}px;
|
||||
min-width: 140px;
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-lg);
|
||||
`;
|
||||
|
||||
const DropdownItem = styled.button<{ isActive: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.sm}px;
|
||||
width: 100%;
|
||||
padding: ${spacing.sm}px ${spacing.md}px;
|
||||
background: ${props => props.isActive ? 'var(--accent-subtle)' : 'transparent'};
|
||||
border: none;
|
||||
border-radius: ${borderRadius.sm}px;
|
||||
cursor: pointer;
|
||||
color: ${props => props.isActive ? 'var(--accent-text)' : 'var(--fg-primary)'};
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
transition: background ${animation.duration.fast} ${animation.easing.default};
|
||||
|
||||
&:hover {
|
||||
background: ${props => props.isActive ? 'var(--accent-subtle)' : 'var(--bg-hover)'};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: ${sizing.icon.sm}px;
|
||||
height: ${sizing.icon.sm}px;
|
||||
`;
|
||||
|
||||
1
web/src/components/ThemeToggle/index.ts
Normal file
1
web/src/components/ThemeToggle/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ThemeToggle } from './ThemeToggle';
|
||||
163
web/src/components/Toast/Toast.tsx
Normal file
163
web/src/components/Toast/Toast.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors, typography, spacing, borderRadius, animation, sizing } from '../../theme';
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
duration?: number;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
const Toast: React.FC<ToastProps> = ({ id, message, variant, duration = 5000, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose(id);
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [id, duration, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose(id);
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (variant) {
|
||||
case 'success':
|
||||
return '✅';
|
||||
case 'error':
|
||||
return '❌';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
case 'info':
|
||||
return 'ℹ️';
|
||||
default:
|
||||
return 'ℹ️';
|
||||
}
|
||||
};
|
||||
|
||||
// WCAG 4.1.3: erorile se anunță asertiv (role="alert"), restul politicos (role="status")
|
||||
const isError = variant === 'error';
|
||||
|
||||
return (
|
||||
<ToastContainer
|
||||
variant={variant}
|
||||
role={isError ? 'alert' : 'status'}
|
||||
aria-live={isError ? 'assertive' : 'polite'}
|
||||
>
|
||||
<ToastIcon aria-hidden="true">{getIcon()}</ToastIcon>
|
||||
<ToastMessage>{message}</ToastMessage>
|
||||
<CloseButton onClick={handleClose} aria-label={t('common.close')}>✕</CloseButton>
|
||||
</ToastContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
|
||||
// Styled Components
|
||||
const ToastContainer = styled.div<{ variant: ToastVariant }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${spacing.md}px;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
padding: ${spacing.md}px ${spacing.lg}px;
|
||||
background: ${({ variant }) => {
|
||||
switch (variant) {
|
||||
case 'success':
|
||||
return `rgba(40, 167, 69, 0.15)`;
|
||||
case 'error':
|
||||
return `rgba(230, 57, 70, 0.15)`;
|
||||
case 'warning':
|
||||
return `rgba(255, 140, 66, 0.15)`;
|
||||
case 'info':
|
||||
return `var(--accent-subtle)`;
|
||||
default:
|
||||
return `var(--bg-elevated)`;
|
||||
}
|
||||
}};
|
||||
border: 1px solid ${({ variant }) => {
|
||||
switch (variant) {
|
||||
case 'success':
|
||||
return colors.truthGreen;
|
||||
case 'error':
|
||||
return colors.cautionRed;
|
||||
case 'warning':
|
||||
return colors.insightOrange;
|
||||
case 'info':
|
||||
return 'var(--accent-border)';
|
||||
default:
|
||||
return 'var(--border-strong)';
|
||||
}
|
||||
}};
|
||||
border-radius: ${borderRadius.md}px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideInRight ${animation.duration.normal} ${animation.easing.easeOut}, fadeOut ${animation.duration.normal} ${animation.easing.easeIn} ${props => (props.variant === 'success' || props.variant === 'info') ? '4.7s' : '4.7s'} forwards;
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ToastIcon = styled.span`
|
||||
font-size: ${typography.fontSize.xl};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const ToastMessage = styled.p`
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: var(--fg-primary);
|
||||
line-height: ${typography.lineHeight.normal};
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg-primary);
|
||||
font-size: ${typography.fontSize.lg};
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: ${sizing.icon.md}px;
|
||||
height: ${sizing.icon.md}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: ${borderRadius.xs}px;
|
||||
transition: all ${animation.duration.fast} ${animation.easing.default};
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--bg-active);
|
||||
}
|
||||
`;
|
||||
104
web/src/components/Toast/ToastContext.tsx
Normal file
104
web/src/components/Toast/ToastContext.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import React, { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import Toast from './Toast';
|
||||
import type { ToastVariant } from './Toast';
|
||||
|
||||
interface ToastItem {
|
||||
id: string;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const addToast = useCallback((message: string, variant: ToastVariant) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const newToast: ToastItem = { id, message, variant };
|
||||
|
||||
setToasts((prevToasts) => [...prevToasts, newToast]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const success = useCallback((message: string) => {
|
||||
addToast(message, 'success');
|
||||
}, [addToast]);
|
||||
|
||||
const error = useCallback((message: string) => {
|
||||
addToast(message, 'error');
|
||||
}, [addToast]);
|
||||
|
||||
const warning = useCallback((message: string) => {
|
||||
addToast(message, 'warning');
|
||||
}, [addToast]);
|
||||
|
||||
const info = useCallback((message: string) => {
|
||||
addToast(message, 'info');
|
||||
}, [addToast]);
|
||||
|
||||
// Memoize context value to prevent infinite re-renders in consumers
|
||||
// that have `toast` in useEffect dependency arrays
|
||||
const value = useMemo<ToastContextValue>(() => ({
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
}), [success, error, warning, info]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<ToastContainer>
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
id={toast.id}
|
||||
message={toast.message}
|
||||
variant={toast.variant}
|
||||
onClose={removeToast}
|
||||
/>
|
||||
))}
|
||||
</ToastContainer>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = (): ToastContextValue => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Styled Components
|
||||
const ToastContainer = styled.div`
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
3
web/src/components/Toast/index.ts
Normal file
3
web/src/components/Toast/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Toast } from './Toast';
|
||||
export type { ToastProps, ToastVariant } from './Toast';
|
||||
export { ToastProvider, useToast } from './ToastContext';
|
||||
378
web/src/components/UserProfile/UserProfileWidget.tsx
Normal file
378
web/src/components/UserProfile/UserProfileWidget.tsx
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { typography, spacing, borderRadius, animation, media } from '../../theme';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useUsageStats } from '../../hooks/useSubscription';
|
||||
|
||||
interface UserProfileWidgetProps {
|
||||
onNavigateToSettings?: () => void;
|
||||
}
|
||||
|
||||
export const UserProfileWidget: React.FC<UserProfileWidgetProps> = ({ onNavigateToSettings }) => {
|
||||
const { t } = useTranslation();
|
||||
const { user, credits, isAuthenticated } = useAuth();
|
||||
const { data: usageStats, isLoading } = useUsageStats();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getInitials = () => {
|
||||
if (user.firstName && user.lastName) return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
|
||||
if (user.firstName) return user.firstName.substring(0, 2).toUpperCase();
|
||||
return user.email?.substring(0, 2).toUpperCase() || 'U';
|
||||
};
|
||||
|
||||
const getDisplayName = () => {
|
||||
if (user.firstName && user.lastName) return `${user.firstName} ${user.lastName}`;
|
||||
if (user.firstName) return user.firstName;
|
||||
return user.email?.split('@')[0] || 'User';
|
||||
};
|
||||
|
||||
const getPlanColor = () => {
|
||||
if (!usageStats?.plan?.name) return '#7fd0d4';
|
||||
const p = usageStats.plan.name.toLowerCase();
|
||||
if (p.includes('enterprise') || p.includes('professional')) return 'rgba(127, 208, 212, 1)';
|
||||
if (p.includes('personal') || p.includes('guardian')) return 'rgba(31, 182, 189, 1)';
|
||||
return '#7fd0d4';
|
||||
};
|
||||
|
||||
const creditsData = usageStats?.credits;
|
||||
const creditsRemaining = creditsData?.remaining ?? credits;
|
||||
|
||||
const creditsColor = creditsRemaining > 10
|
||||
? 'rgba(31, 182, 189, 0.8)'
|
||||
: creditsRemaining >= 5
|
||||
? 'rgba(255, 165, 0, 0.8)'
|
||||
: 'rgba(230, 57, 70, 0.8)';
|
||||
|
||||
return (
|
||||
<Container ref={containerRef}>
|
||||
<ProfileTrigger onClick={() => setIsDropdownOpen(!isDropdownOpen)}>
|
||||
<Avatar>{getInitials()}</Avatar>
|
||||
<TriggerName>{getDisplayName()}</TriggerName>
|
||||
<CreditsBadge>{t('profile.creditsLabel', { count: creditsRemaining })}</CreditsBadge>
|
||||
<DropdownArrow>▾</DropdownArrow>
|
||||
{usageStats?.plan?.name && (
|
||||
<PlanBadge color={getPlanColor()}>{usageStats.plan.name}</PlanBadge>
|
||||
)}
|
||||
</ProfileTrigger>
|
||||
|
||||
<Dropdown isOpen={isDropdownOpen}>
|
||||
{/* Top: user info */}
|
||||
<Header>
|
||||
<AvatarLg>{getInitials()}</AvatarLg>
|
||||
<UserInfo>
|
||||
<Name>{getDisplayName()}</Name>
|
||||
<Email>{user.email}</Email>
|
||||
<PlanRow>
|
||||
<PlanLabel color={getPlanColor()}>{usageStats?.plan?.name || t('profile.planFree')}</PlanLabel>
|
||||
<PlanPrice>{usageStats?.plan?.priceAmount ? t('profile.pricePerMonth', { price: `$${usageStats.plan.priceAmount}` }) : t('profile.planFree')}</PlanPrice>
|
||||
{usageStats?.subscription && (
|
||||
<Status active={usageStats.subscription.isActive}>
|
||||
{usageStats.subscription.isActive ? t('common.active') : t('common.inactive')}
|
||||
</Status>
|
||||
)}
|
||||
</PlanRow>
|
||||
</UserInfo>
|
||||
</Header>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Middle: 2-column grid — Credits left, Limits right */}
|
||||
<TwoCol>
|
||||
<Col>
|
||||
<ColTitle>{t('profile.creditsTitle')}</ColTitle>
|
||||
{isLoading ? (
|
||||
<Muted>{t('common.loading')}</Muted>
|
||||
) : (
|
||||
<>
|
||||
<CreditsNum>
|
||||
<Big style={{ color: creditsColor }}>{creditsRemaining}</Big>
|
||||
<Small>{t('profile.remaining')}</Small>
|
||||
</CreditsNum>
|
||||
{creditsRemaining === 0 && (
|
||||
<Warn>{t('profile.noCreditsLeft')}</Warn>
|
||||
)}
|
||||
{creditsRemaining > 0 && creditsRemaining < 5 && (
|
||||
<Warn>{t('profile.creditsRunningLow')}</Warn>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<ColSep />
|
||||
|
||||
<Col>
|
||||
<ColTitle>{t('profile.planLimits')}</ColTitle>
|
||||
{usageStats?.plan ? (
|
||||
<Limits>
|
||||
<Lim><LimIcon>🔄</LimIcon><LimVal>{usageStats.plan.creditsPerCycle}</LimVal><LimLbl>/cycle</LimLbl></Lim>
|
||||
<Lim><LimIcon>🖼️</LimIcon><LimVal>{usageStats.plan.maxImages}</LimVal><LimLbl>img</LimLbl></Lim>
|
||||
<Lim><LimIcon>🎬</LimIcon><LimVal>{usageStats.plan.maxVideoMinutes}m</LimVal><LimLbl>video</LimLbl></Lim>
|
||||
<Lim><LimIcon>💾</LimIcon><LimVal>{usageStats.plan.storageLimitGb}GB</LimVal><LimLbl>storage</LimLbl></Lim>
|
||||
</Limits>
|
||||
) : (
|
||||
<Muted>--</Muted>
|
||||
)}
|
||||
</Col>
|
||||
</TwoCol>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Bottom: costs + settings */}
|
||||
<Footer>
|
||||
<Costs>
|
||||
<CostTag>{t('profile.costText')} <C>1</C></CostTag>
|
||||
<CostTag>{t('profile.costUrl')} <C>1</C></CostTag>
|
||||
<CostTag>{t('profile.costImage')} <C>2</C></CostTag>
|
||||
<CostTag>{t('profile.costAudio')} <C>3</C></CostTag>
|
||||
<CostTag>{t('profile.costVideo')} <C>5</C></CostTag>
|
||||
</Costs>
|
||||
<SettingsBtn onClick={onNavigateToSettings}>⚙️ {t('profile.settingsButton')}</SettingsBtn>
|
||||
</Footer>
|
||||
</Dropdown>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────────────
|
||||
|
||||
const Container = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ProfileTrigger = styled.div`
|
||||
display: flex; align-items: center; gap: ${spacing.sm}px;
|
||||
max-width: 480px; width: auto;
|
||||
padding: ${spacing.xs}px ${spacing.md}px;
|
||||
background: var(--bg-surface); border: 1px solid var(--accent-border);
|
||||
border-radius: ${borderRadius.md}px; cursor: pointer;
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
&:hover { background: var(--accent-subtle); border-color: var(--accent); }
|
||||
`;
|
||||
|
||||
const Avatar = styled.div`
|
||||
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.bold}; color: #fff;
|
||||
`;
|
||||
|
||||
const AvatarLg = styled(Avatar)`width: 40px; height: 40px; font-size: ${typography.fontSize.base};`;
|
||||
|
||||
const TriggerName = styled.span`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium}; color: var(--fg-primary);
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
@media (max-width: 768px) { display: none; }
|
||||
`;
|
||||
|
||||
const CreditsBadge = styled.span`
|
||||
padding: 2px 8px; background: var(--accent-subtle); border: 1px solid var(--accent-border);
|
||||
border-radius: 12px; font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xs}; font-weight: ${typography.fontWeight.semibold};
|
||||
color: var(--accent-text); white-space: nowrap;
|
||||
@media (max-width: 768px) { display: none; }
|
||||
`;
|
||||
|
||||
const DropdownArrow = styled.span`
|
||||
font-size: ${typography.fontSize.xs}; color: var(--fg-primary);
|
||||
transition: transform ${animation.duration.normal} ${animation.easing.default};
|
||||
@media (max-width: 768px) { display: none; }
|
||||
`;
|
||||
|
||||
const PlanBadge = styled.span<{ color: string }>`
|
||||
padding: 2px 8px; background: ${p => p.color}20; border: 1px solid ${p => p.color}40;
|
||||
border-radius: 12px; font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xs}; font-weight: ${typography.fontWeight.semibold};
|
||||
color: ${p => p.color}; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
@media (max-width: 768px) { display: none; }
|
||||
`;
|
||||
|
||||
// ─── Dropdown ────────────────────────────────────────────────────
|
||||
|
||||
const Dropdown = styled.div<{ isOpen?: boolean }>`
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 480px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: ${borderRadius.lg}px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: ${p => p.isOpen ? 1 : 0};
|
||||
visibility: ${p => p.isOpen ? 'visible' : 'hidden'};
|
||||
transform: translateY(${p => p.isOpen ? '0' : '-8px'});
|
||||
transition: all 0.2s ease;
|
||||
z-index: 100;
|
||||
|
||||
${media.maxMobile} { width: calc(100vw - 32px); right: -16px; }
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex; align-items: center; gap: ${spacing.md}px;
|
||||
padding: ${spacing.md}px ${spacing.lg}px;
|
||||
`;
|
||||
|
||||
const UserInfo = styled.div`flex: 1; min-width: 0;`;
|
||||
|
||||
const Name = styled.div`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.base};
|
||||
font-weight: ${typography.fontWeight.semibold}; color: var(--fg-primary);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
`;
|
||||
|
||||
const Email = styled.div`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; margin-top: 1px;
|
||||
`;
|
||||
|
||||
const PlanRow = styled.div`
|
||||
display: flex; align-items: center; gap: 8px; margin-top: 4px;
|
||||
`;
|
||||
|
||||
const PlanLabel = styled.span<{ color: string }>`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.semibold}; color: ${p => p.color};
|
||||
`;
|
||||
|
||||
const PlanPrice = styled.span`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
|
||||
const Status = styled.span<{ active: boolean }>`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: 10px;
|
||||
padding: 1px 6px; border-radius: 8px;
|
||||
background: ${p => p.active ? 'rgba(31,182,189,0.12)' : 'rgba(248,113,113,0.12)'};
|
||||
color: ${p => p.active ? 'rgba(31,182,189,0.9)' : 'rgba(248,113,113,0.9)'};
|
||||
[data-theme="light"] & {
|
||||
color: ${p => p.active ? '#059669' : '#dc2626'};
|
||||
background: ${p => p.active ? 'rgba(5,150,105,0.08)' : 'rgba(220,38,38,0.08)'};
|
||||
}
|
||||
`;
|
||||
|
||||
const Sep = styled.div`
|
||||
height: 1px; background: var(--border-subtle); margin: 0 ${spacing.md}px;
|
||||
`;
|
||||
|
||||
// ─── Two columns ─────────────────────────────────────────────────
|
||||
|
||||
const TwoCol = styled.div`
|
||||
display: flex; padding: ${spacing.md}px ${spacing.lg}px; gap: 0;
|
||||
`;
|
||||
|
||||
const Col = styled.div`flex: 1; min-width: 0;`;
|
||||
|
||||
const ColSep = styled.div`
|
||||
width: 1px; background: var(--border-subtle); margin: 0 ${spacing.md}px; flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const ColTitle = styled.div`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: 10px;
|
||||
font-weight: ${typography.fontWeight.semibold}; color: var(--fg-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const CreditsNum = styled.div`
|
||||
display: flex; align-items: baseline; gap: 4px; margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const Big = styled.span`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: 24px;
|
||||
font-weight: ${typography.fontWeight.bold}; color: var(--fg-primary); line-height: 1;
|
||||
`;
|
||||
|
||||
const Small = styled.span`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-subtle);
|
||||
`;
|
||||
|
||||
|
||||
const Warn = styled.div`
|
||||
font-size: 10px; color: rgba(248,113,113,0.85); margin-top: 3px;
|
||||
[data-theme="light"] & { color: #dc2626; }
|
||||
`;
|
||||
|
||||
const Limits = styled.div`
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
|
||||
`;
|
||||
|
||||
const Lim = styled.div`
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 6px; border-radius: 6px;
|
||||
background: var(--accent-subtle);
|
||||
`;
|
||||
|
||||
const LimIcon = styled.span`font-size: 12px; flex-shrink: 0;`;
|
||||
|
||||
const LimVal = styled.span`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: 13px;
|
||||
font-weight: ${typography.fontWeight.semibold}; color: var(--fg-primary);
|
||||
`;
|
||||
|
||||
const LimLbl = styled.span`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: 9px;
|
||||
color: var(--fg-subtle); margin-left: auto;
|
||||
`;
|
||||
|
||||
// ─── Footer ──────────────────────────────────────────────────────
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px ${spacing.lg}px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
`;
|
||||
|
||||
const Costs = styled.div`
|
||||
display: flex; align-items: center; gap: 5px; flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const CostTag = styled.span`
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
padding: 2px 6px; background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border); border-radius: 8px;
|
||||
font-family: ${typography.fontFamily.primary}; font-size: 10px;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
|
||||
const C = styled.span`
|
||||
font-weight: ${typography.fontWeight.bold}; color: var(--accent-text);
|
||||
`;
|
||||
|
||||
const SettingsBtn = styled.button`
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 5px 12px; background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border); border-radius: ${borderRadius.sm}px;
|
||||
font-family: ${typography.fontFamily.primary}; font-size: 12px;
|
||||
font-weight: ${typography.fontWeight.medium}; color: var(--fg-primary); cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
&:hover { border-color: var(--accent); }
|
||||
`;
|
||||
|
||||
const Muted = styled.div`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted); padding: 4px 0;
|
||||
`;
|
||||
|
||||
export default UserProfileWidget;
|
||||
2
web/src/components/UserProfile/index.ts
Normal file
2
web/src/components/UserProfile/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { UserProfileWidget } from './UserProfileWidget';
|
||||
export { default } from './UserProfileWidget';
|
||||
248
web/src/components/__tests__/ProtectedRoute.test.tsx
Normal file
248
web/src/components/__tests__/ProtectedRoute.test.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { ProtectedRoute } from '../ProtectedRoute';
|
||||
import * as AuthContext from '../../contexts/AuthContext';
|
||||
|
||||
// Mock the useAuth hook
|
||||
vi.mock('../../contexts/AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
const TestComponent = () => <div>Protected Content</div>;
|
||||
|
||||
const renderProtectedRoute = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<div>Login Page</div>} />
|
||||
<Route
|
||||
path="/protected"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('should show loading state when authentication is being checked', () => {
|
||||
// Mock loading state
|
||||
vi.spyOn(AuthContext, 'useAuth').mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
// Navigate to protected route
|
||||
window.history.pushState({}, 'Test', '/protected');
|
||||
renderProtectedRoute();
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should redirect to login page when user is not authenticated', () => {
|
||||
// Mock unauthenticated state
|
||||
vi.spyOn(AuthContext, 'useAuth').mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
// Navigate to protected route
|
||||
window.history.pushState({}, 'Test', '/protected');
|
||||
renderProtectedRoute();
|
||||
|
||||
// Should redirect to login page (/)
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render protected content when user is authenticated', () => {
|
||||
// Mock authenticated state
|
||||
vi.spyOn(AuthContext, 'useAuth').mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
sub: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
preferred_username: 'testuser',
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
// Navigate to protected route
|
||||
window.history.pushState({}, 'Test', '/protected');
|
||||
renderProtectedRoute();
|
||||
|
||||
// Should show protected content
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Login Page')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show loading screen when not loading', () => {
|
||||
vi.spyOn(AuthContext, 'useAuth').mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
sub: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
preferred_username: 'testuser',
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
window.history.pushState({}, 'Test', '/protected');
|
||||
renderProtectedRoute();
|
||||
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle transition from loading to authenticated', () => {
|
||||
// Start with loading state
|
||||
const mockUseAuth = vi.spyOn(AuthContext, 'useAuth');
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
window.history.pushState({}, 'Test', '/protected');
|
||||
const { rerender } = renderProtectedRoute();
|
||||
|
||||
// Should show loading
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
|
||||
// Update to authenticated
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
sub: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
preferred_username: 'testuser',
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<div>Login Page</div>} />
|
||||
<Route
|
||||
path="/protected"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should now show protected content
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle transition from loading to unauthenticated (redirect)', () => {
|
||||
const mockUseAuth = vi.spyOn(AuthContext, 'useAuth');
|
||||
|
||||
// Start with loading
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
window.history.pushState({}, 'Test', '/protected');
|
||||
const { rerender } = renderProtectedRoute();
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
|
||||
// Update to unauthenticated
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<div>Login Page</div>} />
|
||||
<Route
|
||||
path="/protected"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TestComponent />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
// Should redirect to login
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children as fragment', () => {
|
||||
vi.spyOn(AuthContext, 'useAuth').mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
sub: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
preferred_username: 'testuser',
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
});
|
||||
|
||||
window.history.pushState({}, 'Test', '/protected');
|
||||
renderProtectedRoute();
|
||||
|
||||
// Check that the protected content is rendered
|
||||
const protectedContent = screen.getByText('Protected Content');
|
||||
expect(protectedContent).toBeInTheDocument();
|
||||
|
||||
// The content should be directly in the DOM, not wrapped in extra elements
|
||||
// (React fragments don't create DOM nodes)
|
||||
expect(protectedContent.parentElement?.tagName).not.toBe('PROTECTED-ROUTE');
|
||||
});
|
||||
});
|
||||
82
web/src/components/ui/Badge.tsx
Normal file
82
web/src/components/ui/Badge.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { colors, typography, spacing, borderRadius, animation } from '../../theme';
|
||||
|
||||
export type BadgeVariant =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info';
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({
|
||||
variant = 'default',
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<StyledBadge variant={variant} {...props}>
|
||||
{children}
|
||||
</StyledBadge>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledBadge = styled.span<{ variant: BadgeVariant }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: ${spacing.xs}px ${spacing.md}px;
|
||||
border-radius: ${borderRadius.md}px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
white-space: nowrap;
|
||||
transition: all ${animation.duration.fast} ${animation.easing.default};
|
||||
|
||||
${props => {
|
||||
switch (props.variant) {
|
||||
case 'primary':
|
||||
return `
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-text);
|
||||
border: 1px solid var(--accent-border);
|
||||
`;
|
||||
case 'success':
|
||||
return `
|
||||
background: ${colors.truthGreen}20;
|
||||
color: ${colors.truthGreen};
|
||||
border: 1px solid ${colors.truthGreen}40;
|
||||
`;
|
||||
case 'warning':
|
||||
return `
|
||||
background: ${colors.insightOrange}20;
|
||||
color: ${colors.insightOrange};
|
||||
border: 1px solid ${colors.insightOrange}40;
|
||||
`;
|
||||
case 'danger':
|
||||
return `
|
||||
background: ${colors.cautionRed}20;
|
||||
color: ${colors.cautionRed};
|
||||
border: 1px solid ${colors.cautionRed}40;
|
||||
`;
|
||||
case 'info':
|
||||
return `
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-text);
|
||||
border: 1px solid var(--accent-border);
|
||||
`;
|
||||
case 'default':
|
||||
default:
|
||||
return `
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
`;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
123
web/src/components/ui/Button.tsx
Normal file
123
web/src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { colors, typography, spacing, borderRadius, animation, sizing } from '../../theme';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger';
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
loading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<StyledButton
|
||||
variant={variant}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner />
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
/* Ghid Clossers: buton compact 500, radius 8, teal plin;
|
||||
hover = teal-deep + translateY(-1px) + umbră soft; focus ring teal. */
|
||||
const StyledButton = styled.button<{ variant: ButtonVariant }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${spacing.sm}px;
|
||||
padding: 9px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
white-space: nowrap;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(0, 145, 152, 0.12);
|
||||
}
|
||||
|
||||
${props => {
|
||||
switch (props.variant) {
|
||||
case 'primary':
|
||||
return `
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`;
|
||||
case 'secondary':
|
||||
return `
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--fg-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
`;
|
||||
case 'danger':
|
||||
return `
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid var(--danger-border);
|
||||
color: ${colors.cautionRed};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: ${colors.cautionRed};
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Spinner = styled.span`
|
||||
width: ${sizing.icon.xs}px;
|
||||
height: ${sizing.icon.xs}px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin ${animation.duration.spinner} ${animation.easing.linear} infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
54
web/src/components/ui/Card.tsx
Normal file
54
web/src/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { spacing, borderRadius, animation } from '../../theme';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
hoverable?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
padding = 'lg',
|
||||
hoverable = false,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<StyledCard padding={padding} hoverable={hoverable} {...props}>
|
||||
{children}
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
|
||||
/* Ghid Clossers: suprafață opacă, hairline border, radius 22, umbră soft —
|
||||
fără glassmorphism/blur. */
|
||||
const StyledCard = styled.div<{ padding: 'sm' | 'md' | 'lg' | 'xl'; hoverable: boolean }>`
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: ${borderRadius.lg}px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
|
||||
${props => {
|
||||
const paddingMap = {
|
||||
sm: spacing.sm,
|
||||
md: spacing.md,
|
||||
lg: spacing.lg,
|
||||
xl: spacing.xl,
|
||||
};
|
||||
return `padding: ${paddingMap[props.padding]}px;`;
|
||||
}}
|
||||
|
||||
${props =>
|
||||
props.hoverable &&
|
||||
`
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
91
web/src/components/ui/Input.tsx
Normal file
91
web/src/components/ui/Input.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { colors, typography, spacing, borderRadius, animation } from '../../theme';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
export const Input: React.FC<InputProps> = ({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
id,
|
||||
...props
|
||||
}) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{label && <Label htmlFor={inputId}>{label}</Label>}
|
||||
<StyledInput id={inputId} hasError={!!error} {...props} />
|
||||
{error && <ErrorText>{error}</ErrorText>}
|
||||
{helperText && !error && <HelperText>{helperText}</HelperText>}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
display: block;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${typography.fontWeight.medium};
|
||||
color: var(--fg-secondary);
|
||||
margin-bottom: ${spacing.sm}px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input<{ hasError: boolean }>`
|
||||
width: 100%;
|
||||
padding: ${spacing.md}px ${spacing.lg}px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid ${props => props.hasError ? colors.cautionRed : 'var(--border-default)'};
|
||||
border-radius: 10px;
|
||||
color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.base};
|
||||
transition: all ${animation.duration.normal} ${animation.easing.default};
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${props => props.hasError ? colors.cautionRed : 'var(--accent)'};
|
||||
background: ${props => props.hasError ? 'rgba(230, 57, 70, 0.05)' : 'var(--bg-surface)'};
|
||||
box-shadow: 0 0 0 3px ${props => props.hasError ? 'rgba(230, 57, 70, 0.1)' : 'rgba(0, 145, 152, 0.12)'};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
const ErrorText = styled.span`
|
||||
display: block;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: ${colors.cautionRed};
|
||||
margin-top: ${spacing.xs}px;
|
||||
`;
|
||||
|
||||
const HelperText = styled.span`
|
||||
display: block;
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted);
|
||||
margin-top: ${spacing.xs}px;
|
||||
font-style: italic;
|
||||
`;
|
||||
172
web/src/components/ui/Modal.tsx
Normal file
172
web/src/components/ui/Modal.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { colors, typography, spacing, borderRadius, animation, sizing } from '../../theme';
|
||||
import { lockScroll, unlockScroll } from '../../utils/scroll-lock';
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
maxWidth = '900px',
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
lockScroll();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
if (isOpen) unlockScroll();
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Overlay onClick={onClose}>
|
||||
<Content onClick={(e) => e.stopPropagation()} maxWidth={maxWidth}>
|
||||
{title && (
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<CloseButton onClick={onClose}>✕</CloseButton>
|
||||
</Header>
|
||||
)}
|
||||
{!title && <CloseButtonOnly onClick={onClose}>✕</CloseButtonOnly>}
|
||||
<Body hasHeader={!!title}>{children}</Body>
|
||||
</Content>
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
|
||||
const Overlay = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(13, 20, 36, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: ${spacing.xl}px;
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn ${animation.duration.fast} ${animation.easing.easeOut};
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled.div<{ maxWidth: string }>`
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 16px;
|
||||
max-width: ${props => props.maxWidth};
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideUp ${animation.duration.normal} ${animation.easing.easeOut};
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: ${spacing.lg}px ${spacing.xl}px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.xl};
|
||||
font-weight: ${typography.fontWeight.bold};
|
||||
color: var(--fg-primary);
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const CloseButton = styled.button`
|
||||
width: ${sizing.button.md}px;
|
||||
height: ${sizing.button.md}px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg-primary);
|
||||
font-size: ${sizing.icon.sm}px;
|
||||
cursor: pointer;
|
||||
transition: all ${animation.duration.fast};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: ${colors.cautionRed};
|
||||
color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const CloseButtonOnly = styled(CloseButton)`
|
||||
position: absolute;
|
||||
top: ${spacing.lg}px;
|
||||
right: ${spacing.lg}px;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const Body = styled.div<{ hasHeader: boolean }>`
|
||||
padding: ${spacing.xl}px;
|
||||
overflow-y: auto;
|
||||
max-height: ${props => props.hasHeader ? 'calc(90vh - 80px)' : 'calc(90vh - 32px)'};
|
||||
|
||||
/* Custom scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
width: ${sizing.scrollbar}px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: ${borderRadius.xs}px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-default);
|
||||
border-radius: ${borderRadius.xs}px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-strong);
|
||||
}
|
||||
`;
|
||||
14
web/src/components/ui/index.ts
Normal file
14
web/src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export { Button } from './Button';
|
||||
export type { ButtonProps, ButtonVariant } from './Button';
|
||||
|
||||
export { Card } from './Card';
|
||||
export type { CardProps } from './Card';
|
||||
|
||||
export { Badge } from './Badge';
|
||||
export type { BadgeProps, BadgeVariant } from './Badge';
|
||||
|
||||
export { Input } from './Input';
|
||||
export type { InputProps } from './Input';
|
||||
|
||||
export { Modal } from './Modal';
|
||||
export type { ModalProps } from './Modal';
|
||||
212
web/src/contexts/AuthContext.tsx
Normal file
212
web/src/contexts/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { KeycloakService } from '../services/keycloak.service';
|
||||
import { AuthApiService } from '../services/auth-api.service';
|
||||
import type { UserProfile } from '../services/auth-api.service';
|
||||
|
||||
interface AuthContextType {
|
||||
/** Backend user profile (from /api/auth/me) */
|
||||
user: UserProfile | null;
|
||||
/** Keycloak authentication status */
|
||||
isAuthenticated: boolean;
|
||||
/** True while Keycloak is initializing */
|
||||
isLoading: boolean;
|
||||
/** True when token refresh failed — user needs to re-login */
|
||||
sessionExpired: boolean;
|
||||
/** Remaining analysis credits */
|
||||
credits: number;
|
||||
/** Redirect to Keycloak login page */
|
||||
login: () => void;
|
||||
/** Redirect to Keycloak registration page */
|
||||
register: () => void;
|
||||
/** End Keycloak session and redirect to landing */
|
||||
logout: () => void;
|
||||
/** Re-fetch user profile and credits from backend */
|
||||
refreshProfile: () => Promise<void>;
|
||||
/** Use a credit (call after analysis) */
|
||||
useCredit: (amount?: number) => Promise<void>;
|
||||
/** Redirect to Keycloak to re-authenticate (after session expired) */
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [credits, setCredits] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
|
||||
// Initialize Keycloak on mount
|
||||
useEffect(() => {
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
// Listen for session expired events from KeycloakService (token refresh failed)
|
||||
useEffect(() => {
|
||||
const unsubscribe = KeycloakService.onSessionExpired(() => {
|
||||
console.warn('Session expired — showing reconnect prompt');
|
||||
setSessionExpired(true);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Refresh token when tab returns from background/idle — only if near expiry
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = async () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (!KeycloakService.isAuthenticated()) return;
|
||||
|
||||
// Only refresh if token expires within 60s (not forced every time)
|
||||
const ok = await KeycloakService.refreshToken(60);
|
||||
if (!ok) {
|
||||
console.warn('Token refresh failed after tab resume — session expired');
|
||||
setSessionExpired(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}, []);
|
||||
|
||||
const initAuth = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
let authenticated = await KeycloakService.init();
|
||||
|
||||
// If check-sso says not authenticated, try one token refresh
|
||||
// (handles case where SSO iframe timed out but refresh token is still valid)
|
||||
if (!authenticated && KeycloakService.instance.refreshToken) {
|
||||
authenticated = await KeycloakService.refreshToken();
|
||||
}
|
||||
|
||||
setIsAuthenticated(authenticated);
|
||||
|
||||
if (authenticated) {
|
||||
// Try to fetch profile first; only register if profile doesn't exist
|
||||
const profileOk = await fetchProfile();
|
||||
if (!profileOk) {
|
||||
await registerInBackend();
|
||||
await fetchProfile();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Auth init error (check-sso likely timed out):', error);
|
||||
|
||||
// check-sso iframe can throw on timeout / 3rd-party cookie block,
|
||||
// but a refresh token may still be valid — try it before giving up
|
||||
try {
|
||||
if (KeycloakService.instance.refreshToken) {
|
||||
const recovered = await KeycloakService.refreshToken();
|
||||
if (recovered) {
|
||||
setIsAuthenticated(true);
|
||||
await fetchProfile();
|
||||
return; // finally block still runs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// refresh also failed — fall through
|
||||
}
|
||||
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register user profile in backend after Keycloak login.
|
||||
* Uses Keycloak token claims for firstName/lastName.
|
||||
* 409 response means user already registered — that's fine.
|
||||
*/
|
||||
const registerInBackend = async () => {
|
||||
try {
|
||||
const kcUser = KeycloakService.getUserFromToken();
|
||||
if (!kcUser) return;
|
||||
|
||||
await AuthApiService.registerProfile({
|
||||
firstName: kcUser.given_name || kcUser.name || '',
|
||||
lastName: kcUser.family_name || '',
|
||||
});
|
||||
} catch (error) {
|
||||
// Non-409 errors are logged but don't block the flow
|
||||
console.warn('Backend registration:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch user profile from backend /api/auth/me
|
||||
*/
|
||||
const fetchProfile = async (): Promise<boolean> => {
|
||||
try {
|
||||
const profile = await AuthApiService.getProfile();
|
||||
setUser(profile);
|
||||
setCredits(profile.creditsRemained ?? 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const login = useCallback(() => {
|
||||
KeycloakService.login();
|
||||
}, []);
|
||||
|
||||
const register = useCallback(() => {
|
||||
KeycloakService.register();
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setSessionExpired(false);
|
||||
setCredits(0);
|
||||
KeycloakService.logout();
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
setSessionExpired(false);
|
||||
KeycloakService.login();
|
||||
}, []);
|
||||
|
||||
const refreshProfile = useCallback(async () => {
|
||||
if (KeycloakService.isAuthenticated()) {
|
||||
await fetchProfile();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const useCredit = useCallback(async (amount: number = 1) => {
|
||||
await AuthApiService.useCredit(amount);
|
||||
// Refresh credits after use
|
||||
setCredits((prev) => Math.max(0, prev - amount));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
sessionExpired,
|
||||
credits,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshProfile,
|
||||
useCredit,
|
||||
reconnect,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
167
web/src/contexts/ThemeContext.tsx
Normal file
167
web/src/contexts/ThemeContext.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { colors } from '../theme/colors';
|
||||
import { lightColors } from '../theme/lightColors';
|
||||
import { darkTokens, lightTokens, cssVarMap } from '../theme/tokens';
|
||||
import type { ThemeTokens } from '../theme/tokens';
|
||||
|
||||
type ThemeMode = 'dark' | 'light' | 'system';
|
||||
type ResolvedTheme = 'dark' | 'light';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeMode; // User preference (dark/light/system)
|
||||
resolvedTheme: ResolvedTheme; // Actual applied theme
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
toggleTheme: () => void; // Legacy support - cycles through options
|
||||
colors: typeof colors;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const THEME_STORAGE_KEY = 'didi-theme-preference';
|
||||
|
||||
// Detect system color scheme preference
|
||||
const getSystemTheme = (): ResolvedTheme => {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} catch (error) {
|
||||
console.warn('[Theme] matchMedia not supported, defaulting to dark:', error);
|
||||
return 'dark';
|
||||
}
|
||||
};
|
||||
|
||||
// Apply CSS variables to document root
|
||||
const applyCssVariables = (themeColors: typeof colors, resolvedTheme: ResolvedTheme) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Set data attribute for CSS selectors
|
||||
root.setAttribute('data-theme', resolvedTheme);
|
||||
|
||||
// Apply CSS variables
|
||||
root.style.setProperty('--color-background', resolvedTheme === 'dark' ? themeColors.darkBg : themeColors.warmGray);
|
||||
root.style.setProperty('--color-surface', resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.03)' : themeColors.white);
|
||||
root.style.setProperty('--color-surface-elevated', resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : themeColors.white);
|
||||
root.style.setProperty('--color-text-primary', resolvedTheme === 'dark' ? themeColors.softWhite : themeColors.slateCharcoal);
|
||||
root.style.setProperty('--color-text-secondary', themeColors.steelGray);
|
||||
root.style.setProperty('--color-border', resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : themeColors.borderGray);
|
||||
root.style.setProperty('--color-border-light', resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.05)' : themeColors.lightGray);
|
||||
|
||||
// Brand colors (same in both themes)
|
||||
root.style.setProperty('--color-primary', themeColors.deepTrustBlue);
|
||||
root.style.setProperty('--color-accent', themeColors.honestTeal);
|
||||
root.style.setProperty('--color-success', themeColors.truthGreen);
|
||||
root.style.setProperty('--color-warning', themeColors.insightOrange);
|
||||
root.style.setProperty('--color-error', themeColors.cautionRed);
|
||||
|
||||
// Gradients
|
||||
root.style.setProperty('--gradient-primary', themeColors.gradients.trustBloom);
|
||||
root.style.setProperty('--gradient-secondary', themeColors.gradients.progressPop);
|
||||
root.style.setProperty('--gradient-overlay', themeColors.gradients.darkOverlay);
|
||||
|
||||
// Semantic design tokens (tokens.ts) — sursa de adevăr pentru componente:
|
||||
// var(--bg-surface), var(--fg-primary), var(--border-default), var(--accent), ...
|
||||
const tokens = resolvedTheme === 'dark' ? darkTokens : lightTokens;
|
||||
(Object.keys(cssVarMap) as Array<keyof ThemeTokens>).forEach((key) => {
|
||||
root.style.setProperty(cssVarMap[key], tokens[key]);
|
||||
});
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
// Initialize theme preference from localStorage or default to 'dark'
|
||||
const [theme, setThemeState] = useState<ThemeMode>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === 'dark' || stored === 'light' || stored === 'system') {
|
||||
return stored;
|
||||
}
|
||||
if (stored !== null) {
|
||||
console.warn('[Theme] Invalid stored value, using default:', stored);
|
||||
}
|
||||
return 'light';
|
||||
} catch (error) {
|
||||
console.warn('[Theme] Failed to read preference from localStorage:', error);
|
||||
return 'light';
|
||||
}
|
||||
});
|
||||
|
||||
// Resolved theme is what's actually applied (dark or light)
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => {
|
||||
if (theme === 'system') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
return theme as ResolvedTheme;
|
||||
});
|
||||
|
||||
// Update resolved theme when preference changes
|
||||
useEffect(() => {
|
||||
if (theme === 'system') {
|
||||
setResolvedTheme(getSystemTheme());
|
||||
} else {
|
||||
setResolvedTheme(theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes when in 'system' mode
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setResolvedTheme(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
// Persist theme preference to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch (error) {
|
||||
console.error('Failed to save theme preference:', error);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Apply CSS variables whenever resolved theme changes
|
||||
useEffect(() => {
|
||||
const currentColors = resolvedTheme === 'dark' ? colors : lightColors;
|
||||
applyCssVariables(currentColors, resolvedTheme);
|
||||
}, [resolvedTheme]);
|
||||
|
||||
const setTheme = useCallback((newTheme: ThemeMode) => {
|
||||
setThemeState(newTheme);
|
||||
}, []);
|
||||
|
||||
// Legacy toggle - cycles through: dark -> light -> system -> dark
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState(prev => {
|
||||
if (prev === 'dark') return 'light';
|
||||
if (prev === 'light') return 'system';
|
||||
return 'light';
|
||||
});
|
||||
}, []);
|
||||
|
||||
const currentColors = resolvedTheme === 'dark' ? colors : lightColors;
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
colors: currentColors
|
||||
}}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
475
web/src/contexts/__tests__/ThemeContext.test.tsx
Normal file
475
web/src/contexts/__tests__/ThemeContext.test.tsx
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { ThemeProvider, useTheme } from '../ThemeContext';
|
||||
import { colors } from '../../theme/colors';
|
||||
import { lightColors } from '../../theme/lightColors';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key]; }),
|
||||
clear: vi.fn(() => { store = {}; }),
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock matchMedia
|
||||
const createMatchMediaMock = (matches: boolean) => {
|
||||
const listeners: Array<(e: MediaQueryListEvent) => void> = [];
|
||||
return {
|
||||
matches,
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, callback: (e: MediaQueryListEvent) => void) => {
|
||||
if (event === 'change') {
|
||||
listeners.push(callback);
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn((event: string, callback: (e: MediaQueryListEvent) => void) => {
|
||||
if (event === 'change') {
|
||||
const index = listeners.indexOf(callback);
|
||||
if (index > -1) listeners.splice(index, 1);
|
||||
}
|
||||
}),
|
||||
dispatchEvent: vi.fn(),
|
||||
// Helper for testing - trigger system theme change
|
||||
_triggerChange: (newMatches: boolean) => {
|
||||
listeners.forEach(listener => {
|
||||
listener({ matches: newMatches } as MediaQueryListEvent);
|
||||
});
|
||||
},
|
||||
_listeners: listeners,
|
||||
};
|
||||
};
|
||||
|
||||
let matchMediaInstance: ReturnType<typeof createMatchMediaMock>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
// Default: system prefers dark
|
||||
matchMediaInstance = createMatchMediaMock(true);
|
||||
window.matchMedia = vi.fn().mockImplementation(() => matchMediaInstance);
|
||||
// Reset document styles
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
});
|
||||
|
||||
describe('ThemeContext', () => {
|
||||
// Test component that exposes all theme context values
|
||||
const TestComponent = () => {
|
||||
const { theme, setTheme, resolvedTheme, toggleTheme, colors: themeColors } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="theme">{theme}</span>
|
||||
<span data-testid="resolved">{resolvedTheme}</span>
|
||||
<span data-testid="primary-color">{themeColors.deepTrustBlue}</span>
|
||||
<button onClick={() => setTheme('light')}>Set Light</button>
|
||||
<button onClick={() => setTheme('dark')}>Set Dark</button>
|
||||
<button onClick={() => setTheme('system')}>Set System</button>
|
||||
<button onClick={toggleTheme}>Toggle Theme</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useTheme hook', () => {
|
||||
it('should throw error when useTheme is used outside ThemeProvider', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(() => render(<TestComponent />)).toThrow('useTheme must be used within ThemeProvider');
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default behavior', () => {
|
||||
it('should provide default dark theme when no localStorage value exists', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('should provide the colors object from context', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(screen.getByTestId('primary-color').textContent).toBe(colors.deepTrustBlue);
|
||||
});
|
||||
|
||||
it('should set data-theme attribute on document root', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTheme', () => {
|
||||
it('should change theme to light when setTheme is called with light', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set Light'));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('should change theme to dark when setTheme is called with dark', () => {
|
||||
localStorageMock.getItem.mockReturnValue('light');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set Dark'));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('should change theme to system when setTheme is called with system', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set System'));
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('system');
|
||||
// System prefers dark (matchMedia returns true)
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleTheme', () => {
|
||||
it('should cycle from dark to light', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
fireEvent.click(screen.getByText('Toggle Theme'));
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('should cycle from light to system', () => {
|
||||
localStorageMock.getItem.mockReturnValue('light');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
fireEvent.click(screen.getByText('Toggle Theme'));
|
||||
expect(screen.getByTestId('theme').textContent).toBe('system');
|
||||
});
|
||||
|
||||
it('should cycle from system to dark', () => {
|
||||
localStorageMock.getItem.mockReturnValue('system');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('system');
|
||||
fireEvent.click(screen.getByText('Toggle Theme'));
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('should complete full cycle: dark -> light -> system -> dark', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
|
||||
fireEvent.click(screen.getByText('Toggle Theme'));
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
|
||||
fireEvent.click(screen.getByText('Toggle Theme'));
|
||||
expect(screen.getByTestId('theme').textContent).toBe('system');
|
||||
|
||||
fireEvent.click(screen.getByText('Toggle Theme'));
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage persistence', () => {
|
||||
it('should persist theme to localStorage when theme changes', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set Light'));
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('didi-theme-preference', 'light');
|
||||
});
|
||||
|
||||
it('should restore theme from localStorage on mount', () => {
|
||||
localStorageMock.getItem.mockReturnValue('light');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('should restore system theme from localStorage', () => {
|
||||
localStorageMock.getItem.mockReturnValue('system');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('theme').textContent).toBe('system');
|
||||
});
|
||||
|
||||
it('should handle invalid localStorage value gracefully', () => {
|
||||
localStorageMock.getItem.mockReturnValue('invalid-theme');
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Falls back to dark
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('should handle localStorage getItem errors gracefully', () => {
|
||||
localStorageMock.getItem.mockImplementation(() => {
|
||||
throw new Error('Storage access denied');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Falls back to dark
|
||||
expect(screen.getByTestId('theme').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('should handle localStorage setItem errors gracefully', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
localStorageMock.setItem.mockImplementation(() => {
|
||||
throw new Error('Storage full');
|
||||
});
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Should not throw when changing theme
|
||||
fireEvent.click(screen.getByText('Set Light'));
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
expect(consoleError).toHaveBeenCalledWith('Failed to save theme preference:', expect.any(Error));
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('system theme detection', () => {
|
||||
it('should resolve to dark when system prefers dark', () => {
|
||||
matchMediaInstance = createMatchMediaMock(true); // prefers dark
|
||||
window.matchMedia = vi.fn().mockImplementation(() => matchMediaInstance);
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set System'));
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('dark');
|
||||
});
|
||||
|
||||
it('should resolve to light when system prefers light', () => {
|
||||
matchMediaInstance = createMatchMediaMock(false); // prefers light
|
||||
window.matchMedia = vi.fn().mockImplementation(() => matchMediaInstance);
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set System'));
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('should respond to system theme changes when in system mode', () => {
|
||||
matchMediaInstance = createMatchMediaMock(true); // starts dark
|
||||
window.matchMedia = vi.fn().mockImplementation(() => matchMediaInstance);
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set System'));
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('dark');
|
||||
|
||||
// Simulate system theme change to light
|
||||
act(() => {
|
||||
matchMediaInstance._triggerChange(false);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('should not respond to system theme changes when not in system mode', () => {
|
||||
matchMediaInstance = createMatchMediaMock(true);
|
||||
window.matchMedia = vi.fn().mockImplementation(() => matchMediaInstance);
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Set to explicit light theme (not system)
|
||||
fireEvent.click(screen.getByText('Set Light'));
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('light');
|
||||
|
||||
// Simulate system theme change - should not affect resolved theme
|
||||
act(() => {
|
||||
matchMediaInstance._triggerChange(true);
|
||||
});
|
||||
|
||||
// Should still be light because we're not in system mode
|
||||
expect(screen.getByTestId('theme').textContent).toBe('light');
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('light');
|
||||
});
|
||||
|
||||
it('should add and remove event listener for system theme changes', () => {
|
||||
matchMediaInstance = createMatchMediaMock(true);
|
||||
window.matchMedia = vi.fn().mockImplementation(() => matchMediaInstance);
|
||||
|
||||
const { unmount } = render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set System'));
|
||||
|
||||
expect(matchMediaInstance.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(matchMediaInstance.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('colors object', () => {
|
||||
it('should provide dark colors when resolved theme is dark', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('dark');
|
||||
expect(screen.getByTestId('primary-color').textContent).toBe(colors.deepTrustBlue);
|
||||
});
|
||||
|
||||
it('should provide light colors when resolved theme is light', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set Light'));
|
||||
|
||||
expect(screen.getByTestId('resolved').textContent).toBe('light');
|
||||
expect(screen.getByTestId('primary-color').textContent).toBe(lightColors.deepTrustBlue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSS variables', () => {
|
||||
it('should apply CSS variables to document root for dark theme', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--color-primary')).toBe(colors.deepTrustBlue);
|
||||
expect(root.style.getPropertyValue('--color-accent')).toBe(colors.honestTeal);
|
||||
expect(root.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('should apply CSS variables to document root for light theme', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Set Light'));
|
||||
|
||||
const root = document.documentElement;
|
||||
expect(root.style.getPropertyValue('--color-primary')).toBe(lightColors.deepTrustBlue);
|
||||
expect(root.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
|
||||
it('should update CSS variables when theme changes', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
|
||||
fireEvent.click(screen.getByText('Set Light'));
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
|
||||
fireEvent.click(screen.getByText('Set Dark'));
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue