mirror of
https://github.com/SirBlobby/Hoya26.git
synced 2026-02-04 03:34:34 -05:00
Database Viewer Update
This commit is contained in:
@@ -13,5 +13,8 @@ def create_app():
|
|||||||
app.register_blueprint(gemini_bp, url_prefix='/api/gemini')
|
app.register_blueprint(gemini_bp, url_prefix='/api/gemini')
|
||||||
from .routes.reports import reports_bp
|
from .routes.reports import reports_bp
|
||||||
app.register_blueprint(reports_bp, url_prefix='/api/reports')
|
app.register_blueprint(reports_bp, url_prefix='/api/reports')
|
||||||
|
from .routes.incidents import incidents_bp
|
||||||
|
app.register_blueprint(incidents_bp, url_prefix='/api/incidents')
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,33 @@ Based on the context provided, give a final verdict:
|
|||||||
|
|
||||||
def ask(prompt):
|
def ask(prompt):
|
||||||
client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY"))
|
client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY"))
|
||||||
return client.models.generate_content(model="gemini-2.0-flash", contents=prompt).text
|
return client.models.generate_content(model="gemini-3-flash-preview", contents=prompt).text
|
||||||
|
|
||||||
|
def ask_gemini_with_rag(prompt, category=None):
|
||||||
|
"""Ask Gemini with RAG context from the vector database."""
|
||||||
|
# Get embedding for the prompt
|
||||||
|
query_embedding = get_embedding(prompt)
|
||||||
|
|
||||||
|
# Search for relevant documents
|
||||||
|
results = search_documents(query_embedding, num_results=5)
|
||||||
|
|
||||||
|
# Build context from results
|
||||||
|
context = ""
|
||||||
|
for res in results:
|
||||||
|
context += f"--- Document ---\n{res['text']}\n\n"
|
||||||
|
|
||||||
|
# Create full prompt with context
|
||||||
|
full_prompt = f"""You are a helpful sustainability assistant. Use the following context to answer the user's question.
|
||||||
|
If the context doesn't contain relevant information, you can use your general knowledge but mention that.
|
||||||
|
|
||||||
|
CONTEXT:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
USER QUESTION: {prompt}
|
||||||
|
|
||||||
|
Please provide a helpful and concise response."""
|
||||||
|
|
||||||
|
return ask(full_prompt)
|
||||||
|
|
||||||
def analyze(query, query_embedding, num_results=5, num_alternatives=3):
|
def analyze(query, query_embedding, num_results=5, num_alternatives=3):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
from . import scan_and_analyze
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
scan_and_analyze()
|
|
||||||
@@ -3,15 +3,9 @@ import os
|
|||||||
|
|
||||||
def generate_content(prompt, model_name="gemini-2.0-flash-exp"):
|
def generate_content(prompt, model_name="gemini-2.0-flash-exp"):
|
||||||
api_key = os.environ.get("GOOGLE_API_KEY")
|
api_key = os.environ.get("GOOGLE_API_KEY")
|
||||||
if not api_key:
|
|
||||||
return "Error: GOOGLE_API_KEY not found."
|
|
||||||
|
|
||||||
try:
|
|
||||||
client = genai.Client(api_key=api_key)
|
client = genai.Client(api_key=api_key)
|
||||||
response = client.models.generate_content(
|
response = client.models.generate_content(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
contents=prompt,
|
contents=prompt,
|
||||||
)
|
)
|
||||||
return response.text
|
return response.text
|
||||||
except Exception as e:
|
|
||||||
return f"Error interacting with Gemini API: {str(e)}"
|
|
||||||
|
|||||||
405
backend/src/routes/incidents.py
Normal file
405
backend/src/routes/incidents.py
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
"""
|
||||||
|
Incident Report API - Handles greenwashing report submissions
|
||||||
|
Uses structured outputs with Pydantic for reliable JSON responses
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from google import genai
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional, Literal
|
||||||
|
|
||||||
|
from src.ollama.detector import OllamaLogoDetector
|
||||||
|
from src.chroma.vector_store import search_documents, insert_documents
|
||||||
|
from src.rag.embeddings import get_embedding
|
||||||
|
from src.mongo.connection import get_mongo_client
|
||||||
|
|
||||||
|
incidents_bp = Blueprint('incidents', __name__)
|
||||||
|
|
||||||
|
# Initialize detector lazily
|
||||||
|
_detector = None
|
||||||
|
|
||||||
|
def get_detector():
|
||||||
|
global _detector
|
||||||
|
if _detector is None:
|
||||||
|
_detector = OllamaLogoDetector()
|
||||||
|
return _detector
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Pydantic Models for Structured Outputs =============
|
||||||
|
|
||||||
|
class GreenwashingAnalysis(BaseModel):
|
||||||
|
"""Structured output for greenwashing analysis"""
|
||||||
|
is_greenwashing: bool = Field(description="Whether this is a case of greenwashing")
|
||||||
|
confidence: Literal["high", "medium", "low"] = Field(description="Confidence level of the analysis")
|
||||||
|
verdict: str = Field(description="Brief one-sentence verdict")
|
||||||
|
reasoning: str = Field(description="Detailed explanation of why this is or isn't greenwashing")
|
||||||
|
severity: Literal["high", "medium", "low"] = Field(description="Severity of the greenwashing if detected")
|
||||||
|
recommendations: str = Field(description="What consumers should know about this case")
|
||||||
|
key_claims: List[str] = Field(description="List of specific environmental claims made by the company")
|
||||||
|
red_flags: List[str] = Field(description="List of red flags or concerning practices identified")
|
||||||
|
|
||||||
|
|
||||||
|
class LogoDetection(BaseModel):
|
||||||
|
"""Structured output for logo detection from Ollama"""
|
||||||
|
brand: str = Field(description="The company or brand name detected")
|
||||||
|
confidence: Literal["high", "medium", "low"] = Field(description="Confidence level of detection")
|
||||||
|
location: str = Field(description="Location in image (e.g., center, top-left)")
|
||||||
|
category: str = Field(description="Product category if identifiable")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageAnalysis(BaseModel):
|
||||||
|
"""Structured output for full image analysis"""
|
||||||
|
logos_detected: List[LogoDetection] = Field(description="List of logos/brands detected in the image")
|
||||||
|
total_count: int = Field(description="Total number of logos detected")
|
||||||
|
description: str = Field(description="Brief description of what's in the image")
|
||||||
|
environmental_claims: List[str] = Field(description="Any environmental or eco-friendly claims visible in the image")
|
||||||
|
packaging_description: str = Field(description="Description of the product packaging and design")
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Analysis Functions =============
|
||||||
|
|
||||||
|
GREENWASHING_ANALYSIS_PROMPT = """You are an expert at detecting greenwashing - misleading environmental claims by companies.
|
||||||
|
|
||||||
|
Analyze the following user-submitted report about a potential greenwashing incident:
|
||||||
|
|
||||||
|
PRODUCT/COMPANY: {product_name}
|
||||||
|
USER COMPLAINT: {user_description}
|
||||||
|
DETECTED BRAND FROM IMAGE: {detected_brand}
|
||||||
|
IMAGE DESCRIPTION: {image_description}
|
||||||
|
|
||||||
|
RELEVANT CONTEXT FROM OUR DATABASE:
|
||||||
|
{context}
|
||||||
|
|
||||||
|
Based on this information, determine if this is a valid case of greenwashing. Consider:
|
||||||
|
1. Does the company have a history of misleading environmental claims?
|
||||||
|
2. Are their eco-friendly claims vague or unsubstantiated?
|
||||||
|
3. Is there a disconnect between their marketing and actual practices?
|
||||||
|
4. Are they using green imagery or terms without substance?
|
||||||
|
|
||||||
|
Provide your analysis in the structured format requested."""
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_with_gemini(product_name: str, user_description: str, detected_brand: str,
|
||||||
|
image_description: str, context: str) -> GreenwashingAnalysis:
|
||||||
|
"""Send analysis request to Gemini with structured output"""
|
||||||
|
api_key = os.environ.get("GOOGLE_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("GOOGLE_API_KEY not set")
|
||||||
|
|
||||||
|
prompt = GREENWASHING_ANALYSIS_PROMPT.format(
|
||||||
|
product_name=product_name,
|
||||||
|
user_description=user_description,
|
||||||
|
detected_brand=detected_brand,
|
||||||
|
image_description=image_description,
|
||||||
|
context=context
|
||||||
|
)
|
||||||
|
|
||||||
|
client = genai.Client(api_key=api_key)
|
||||||
|
|
||||||
|
# Use structured output with Pydantic schema
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model="gemini-3-flash-preview",
|
||||||
|
contents=prompt,
|
||||||
|
config={
|
||||||
|
"response_mime_type": "application/json",
|
||||||
|
"response_json_schema": GreenwashingAnalysis.model_json_schema(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate and parse the response
|
||||||
|
analysis = GreenwashingAnalysis.model_validate_json(response.text)
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_image_with_ollama(image_bytes: bytes) -> ImageAnalysis:
|
||||||
|
"""Analyze image using Ollama with structured output"""
|
||||||
|
try:
|
||||||
|
import ollama
|
||||||
|
|
||||||
|
client = ollama.Client(host="https://ollama.sirblob.co")
|
||||||
|
|
||||||
|
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
||||||
|
|
||||||
|
prompt = """Analyze this image for a greenwashing detection system.
|
||||||
|
|
||||||
|
Identify:
|
||||||
|
1. All visible logos, brand names, and company names
|
||||||
|
2. Any environmental or eco-friendly claims (text, symbols, certifications)
|
||||||
|
3. Describe the packaging design and any "green" visual elements
|
||||||
|
|
||||||
|
Respond with structured JSON matching the schema provided."""
|
||||||
|
|
||||||
|
response = client.chat(
|
||||||
|
model="ministral-3:latest",
|
||||||
|
messages=[{
|
||||||
|
'role': 'user',
|
||||||
|
'content': prompt,
|
||||||
|
'images': [image_base64],
|
||||||
|
}],
|
||||||
|
format=ImageAnalysis.model_json_schema(),
|
||||||
|
options={'temperature': 0.1}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate and parse
|
||||||
|
analysis = ImageAnalysis.model_validate_json(response['message']['content'])
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ollama structured analysis failed: {e}")
|
||||||
|
# Fall back to basic detection
|
||||||
|
detector = get_detector()
|
||||||
|
result = detector.detect_from_bytes(image_bytes)
|
||||||
|
|
||||||
|
# Convert to structured format
|
||||||
|
logos = []
|
||||||
|
for logo in result.get('logos_detected', []):
|
||||||
|
logos.append(LogoDetection(
|
||||||
|
brand=logo.get('brand', 'Unknown'),
|
||||||
|
confidence=logo.get('confidence', 'low'),
|
||||||
|
location=logo.get('location', 'unknown'),
|
||||||
|
category=logo.get('category', 'unknown')
|
||||||
|
))
|
||||||
|
|
||||||
|
return ImageAnalysis(
|
||||||
|
logos_detected=logos,
|
||||||
|
total_count=result.get('total_count', 0),
|
||||||
|
description=result.get('description', 'No description available'),
|
||||||
|
environmental_claims=[],
|
||||||
|
packaging_description=""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_mongodb(incident_data: dict) -> str:
|
||||||
|
"""Save incident to MongoDB and return the ID"""
|
||||||
|
client = get_mongo_client()
|
||||||
|
db = client["ethix"]
|
||||||
|
collection = db["incidents"]
|
||||||
|
|
||||||
|
result = collection.insert_one(incident_data)
|
||||||
|
return str(result.inserted_id)
|
||||||
|
|
||||||
|
|
||||||
|
def save_to_chromadb(incident_data: dict, incident_id: str):
|
||||||
|
"""Save incident as context for the chatbot"""
|
||||||
|
analysis = incident_data['analysis']
|
||||||
|
|
||||||
|
# Create a rich text representation of the incident
|
||||||
|
red_flags = "\n".join(f"- {flag}" for flag in analysis.get('red_flags', []))
|
||||||
|
key_claims = "\n".join(f"- {claim}" for claim in analysis.get('key_claims', []))
|
||||||
|
|
||||||
|
text = f"""GREENWASHING INCIDENT REPORT #{incident_id}
|
||||||
|
Date: {incident_data['created_at']}
|
||||||
|
Company/Product: {incident_data['product_name']} ({incident_data.get('detected_brand', 'Unknown brand')})
|
||||||
|
|
||||||
|
USER REPORT: {incident_data['user_description']}
|
||||||
|
|
||||||
|
ANALYSIS VERDICT: {analysis['verdict']}
|
||||||
|
Confidence: {analysis['confidence']}
|
||||||
|
Severity: {analysis['severity']}
|
||||||
|
|
||||||
|
DETAILED REASONING:
|
||||||
|
{analysis['reasoning']}
|
||||||
|
|
||||||
|
KEY ENVIRONMENTAL CLAIMS MADE:
|
||||||
|
{key_claims}
|
||||||
|
|
||||||
|
RED FLAGS IDENTIFIED:
|
||||||
|
{red_flags}
|
||||||
|
|
||||||
|
CONSUMER RECOMMENDATIONS:
|
||||||
|
{analysis['recommendations']}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get embedding for the incident
|
||||||
|
embedding = get_embedding(text)
|
||||||
|
|
||||||
|
# Store in ChromaDB with metadata
|
||||||
|
metadata = {
|
||||||
|
"type": "incident_report",
|
||||||
|
"source": f"incident_{incident_id}",
|
||||||
|
"product_name": incident_data['product_name'],
|
||||||
|
"brand": incident_data.get('detected_brand', 'Unknown'),
|
||||||
|
"severity": analysis['severity'],
|
||||||
|
"confidence": analysis['confidence'],
|
||||||
|
"is_greenwashing": True,
|
||||||
|
"created_at": incident_data['created_at']
|
||||||
|
}
|
||||||
|
|
||||||
|
insert_documents(
|
||||||
|
texts=[text],
|
||||||
|
embeddings=[embedding],
|
||||||
|
metadata_list=[metadata]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============= API Endpoints =============
|
||||||
|
|
||||||
|
@incidents_bp.route('/submit', methods=['POST'])
|
||||||
|
def submit_incident():
|
||||||
|
"""
|
||||||
|
Submit a greenwashing incident report
|
||||||
|
|
||||||
|
Expects JSON with:
|
||||||
|
- product_name: Name of the product/company
|
||||||
|
- description: User's description of the misleading claim
|
||||||
|
- image: Base64 encoded image (optional, but recommended)
|
||||||
|
"""
|
||||||
|
data = request.json
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "No data provided"}), 400
|
||||||
|
|
||||||
|
product_name = data.get('product_name', '').strip()
|
||||||
|
user_description = data.get('description', '').strip()
|
||||||
|
image_base64 = data.get('image') # Base64 encoded image
|
||||||
|
|
||||||
|
if not product_name:
|
||||||
|
return jsonify({"error": "Product name is required"}), 400
|
||||||
|
|
||||||
|
if not user_description:
|
||||||
|
return jsonify({"error": "Description is required"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Analyze image with Ollama (structured output)
|
||||||
|
detected_brand = "Unknown"
|
||||||
|
image_description = "No image provided"
|
||||||
|
environmental_claims = []
|
||||||
|
|
||||||
|
if image_base64:
|
||||||
|
try:
|
||||||
|
# Remove data URL prefix if present
|
||||||
|
if ',' in image_base64:
|
||||||
|
image_base64 = image_base64.split(',')[1]
|
||||||
|
|
||||||
|
image_bytes = base64.b64decode(image_base64)
|
||||||
|
|
||||||
|
# Use structured image analysis
|
||||||
|
image_analysis = analyze_image_with_ollama(image_bytes)
|
||||||
|
|
||||||
|
if image_analysis.logos_detected:
|
||||||
|
detected_brand = image_analysis.logos_detected[0].brand
|
||||||
|
|
||||||
|
image_description = image_analysis.description
|
||||||
|
environmental_claims = image_analysis.environmental_claims
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Image analysis error: {e}")
|
||||||
|
# Continue without image analysis
|
||||||
|
|
||||||
|
# Step 2: Get relevant context from vector database
|
||||||
|
search_query = f"{product_name} {detected_brand} environmental claims sustainability greenwashing"
|
||||||
|
query_embedding = get_embedding(search_query)
|
||||||
|
search_results = search_documents(query_embedding, num_results=5)
|
||||||
|
|
||||||
|
context = ""
|
||||||
|
for res in search_results:
|
||||||
|
context += f"--- Document ---\n{res['text'][:500]}\n\n"
|
||||||
|
|
||||||
|
if not context:
|
||||||
|
context = "No prior information found about this company in our database."
|
||||||
|
|
||||||
|
# Add environmental claims from image to context
|
||||||
|
if environmental_claims:
|
||||||
|
context += "\n--- Claims visible in submitted image ---\n"
|
||||||
|
context += "\n".join(f"- {claim}" for claim in environmental_claims)
|
||||||
|
|
||||||
|
# Step 3: Analyze with Gemini (structured output)
|
||||||
|
analysis = analyze_with_gemini(
|
||||||
|
product_name=product_name,
|
||||||
|
user_description=user_description,
|
||||||
|
detected_brand=detected_brand,
|
||||||
|
image_description=image_description,
|
||||||
|
context=context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert Pydantic model to dict
|
||||||
|
analysis_dict = analysis.model_dump()
|
||||||
|
|
||||||
|
# Step 4: Prepare incident data
|
||||||
|
incident_data = {
|
||||||
|
"product_name": product_name,
|
||||||
|
"user_description": user_description,
|
||||||
|
"detected_brand": detected_brand,
|
||||||
|
"image_description": image_description,
|
||||||
|
"environmental_claims": environmental_claims,
|
||||||
|
"analysis": analysis_dict,
|
||||||
|
"is_greenwashing": analysis.is_greenwashing,
|
||||||
|
"created_at": datetime.utcnow().isoformat(),
|
||||||
|
"status": "confirmed" if analysis.is_greenwashing else "dismissed"
|
||||||
|
}
|
||||||
|
|
||||||
|
incident_id = None
|
||||||
|
|
||||||
|
# Step 5: If greenwashing detected, save to databases
|
||||||
|
if analysis.is_greenwashing:
|
||||||
|
# Save to MongoDB
|
||||||
|
incident_id = save_to_mongodb(incident_data)
|
||||||
|
|
||||||
|
# Save to ChromaDB for chatbot context
|
||||||
|
save_to_chromadb(incident_data, incident_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"is_greenwashing": analysis.is_greenwashing,
|
||||||
|
"incident_id": incident_id,
|
||||||
|
"analysis": analysis_dict,
|
||||||
|
"detected_brand": detected_brand,
|
||||||
|
"environmental_claims": environmental_claims
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({
|
||||||
|
"status": "error",
|
||||||
|
"message": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@incidents_bp.route('/list', methods=['GET'])
|
||||||
|
def list_incidents():
|
||||||
|
"""Get all confirmed greenwashing incidents"""
|
||||||
|
try:
|
||||||
|
client = get_mongo_client()
|
||||||
|
db = client["ethix"]
|
||||||
|
collection = db["incidents"]
|
||||||
|
|
||||||
|
# Get recent incidents with full analysis details
|
||||||
|
incidents = list(collection.find(
|
||||||
|
{"is_greenwashing": True},
|
||||||
|
{"_id": 1, "product_name": 1, "detected_brand": 1,
|
||||||
|
"user_description": 1, "analysis": 1, "created_at": 1}
|
||||||
|
).sort("created_at", -1).limit(50))
|
||||||
|
|
||||||
|
# Convert ObjectId to string
|
||||||
|
for inc in incidents:
|
||||||
|
inc["_id"] = str(inc["_id"])
|
||||||
|
|
||||||
|
return jsonify(incidents)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@incidents_bp.route('/<incident_id>', methods=['GET'])
|
||||||
|
def get_incident(incident_id):
|
||||||
|
"""Get a specific incident by ID"""
|
||||||
|
try:
|
||||||
|
from bson import ObjectId
|
||||||
|
|
||||||
|
client = get_mongo_client()
|
||||||
|
db = client["ethix"]
|
||||||
|
collection = db["incidents"]
|
||||||
|
|
||||||
|
incident = collection.find_one({"_id": ObjectId(incident_id)})
|
||||||
|
|
||||||
|
if not incident:
|
||||||
|
return jsonify({"error": "Incident not found"}), 404
|
||||||
|
|
||||||
|
incident["_id"] = str(incident["_id"])
|
||||||
|
return jsonify(incident)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
@@ -18,6 +18,11 @@ def get_reports():
|
|||||||
if not filename:
|
if not filename:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip incident reports - these are user-submitted greenwashing reports
|
||||||
|
if meta.get('type') == 'incident_report' or filename.startswith('incident_'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
if filename not in unique_reports:
|
if filename not in unique_reports:
|
||||||
# Attempt to extract info from filename
|
# Attempt to extract info from filename
|
||||||
# Common patterns:
|
# Common patterns:
|
||||||
|
|||||||
101
frontend/src/lib/components/catalogue/CatalogueHeader.svelte
Normal file
101
frontend/src/lib/components/catalogue/CatalogueHeader.svelte
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
viewMode = $bindable(),
|
||||||
|
searchQuery = $bindable(),
|
||||||
|
selectedCategory = $bindable(),
|
||||||
|
categories,
|
||||||
|
onSearchInput,
|
||||||
|
}: {
|
||||||
|
viewMode: "company" | "user";
|
||||||
|
searchQuery: string;
|
||||||
|
selectedCategory: string;
|
||||||
|
categories: string[];
|
||||||
|
onSearchInput: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-black/80 backdrop-blur-[30px] border border-white/20 rounded-[32px] p-10 mb-10 shadow-[0_32px_64px_rgba(0,0,0,0.4)]"
|
||||||
|
>
|
||||||
|
<!-- Header content -->
|
||||||
|
<div class="mb-8 px-3 text-center">
|
||||||
|
<h1 class="text-white text-[42px] font-black m-0 tracking-[-2px]">
|
||||||
|
Sustainability Database
|
||||||
|
</h1>
|
||||||
|
<p class="text-white/70 text-base mt-2 font-medium">
|
||||||
|
{#if viewMode === "company"}
|
||||||
|
Search within verified company reports and impact assessments
|
||||||
|
{:else}
|
||||||
|
Browse user-submitted greenwashing reports
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Mode Toggle Switch -->
|
||||||
|
<div class="flex items-center justify-center gap-4 my-6">
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1.5 text-sm font-semibold transition-all duration-300 {viewMode ===
|
||||||
|
'company'
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-white/40'}"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:building-2-line" width="16" />
|
||||||
|
Company
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="relative w-14 h-7 bg-white/15 border-none rounded-full cursor-pointer transition-all duration-300 p-0 hover:bg-white/20"
|
||||||
|
onclick={() =>
|
||||||
|
(viewMode = viewMode === "company" ? "user" : "company")}
|
||||||
|
aria-label="Toggle between company and user reports"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-1/2 -translate-y-1/2 w-[22px] h-[22px] bg-emerald-600 rounded-full transition-all duration-300 shadow-[0_2px_8px_rgba(34,197,94,0.4)] {viewMode ===
|
||||||
|
'user'
|
||||||
|
? 'left-[calc(100%-25px)]'
|
||||||
|
: 'left-[3px]'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1.5 text-sm font-semibold transition-all duration-300 {viewMode ===
|
||||||
|
'user'
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-white/40'}"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:user-voice-line" width="16" />
|
||||||
|
User Reports
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if viewMode === "company"}
|
||||||
|
<div class="relative max-w-[600px] mx-auto mb-8">
|
||||||
|
<div
|
||||||
|
class="absolute left-5 top-1/2 -translate-y-1/2 text-white/60 flex items-center pointer-events-none"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:search-line" width="20" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full bg-white/5 border border-white/10 rounded-full py-4 pl-[52px] pr-5 text-white text-base font-medium outline-none transition-all duration-200 focus:bg-white/10 focus:border-emerald-400 placeholder:text-white/40"
|
||||||
|
placeholder="Search for companies, topics (e.g., 'emissions')..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
oninput={onSearchInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center flex-wrap gap-3 mb-5">
|
||||||
|
{#each categories as category}
|
||||||
|
<button
|
||||||
|
class="px-6 py-2.5 rounded-full text-sm font-semibold cursor-pointer transition-all duration-200 border {selectedCategory ===
|
||||||
|
category
|
||||||
|
? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_15px_rgba(34,197,94,0.3)]'
|
||||||
|
: 'bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:text-white hover:-translate-y-0.5'}"
|
||||||
|
onclick={() => (selectedCategory = category)}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
86
frontend/src/lib/components/catalogue/CompanyModal.svelte
Normal file
86
frontend/src/lib/components/catalogue/CompanyModal.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import { fade, scale } from "svelte/transition";
|
||||||
|
|
||||||
|
interface Report {
|
||||||
|
company_name: string;
|
||||||
|
year: string | number;
|
||||||
|
sector: string;
|
||||||
|
greenwashing_score: number | string;
|
||||||
|
filename: string;
|
||||||
|
title?: string;
|
||||||
|
snippet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { report, onclose }: { report: Report; onclose: () => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/80 backdrop-blur-md z-1000 flex justify-center items-center p-5 outline-none"
|
||||||
|
onclick={onclose}
|
||||||
|
onkeydown={(e) => e.key === "Escape" && onclose()}
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-slate-900 border border-slate-700 rounded-[24px] w-full max-w-[1000px] h-[90vh] flex flex-col shadow-[0_24px_64px_rgba(0,0,0,0.5)] overflow-hidden outline-none"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
transition:scale={{ duration: 300, start: 0.95 }}
|
||||||
|
role="document"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="px-6 py-5 bg-slate-800 border-b border-slate-700 flex justify-between items-center shrink-0"
|
||||||
|
>
|
||||||
|
<div class="modal-title-group">
|
||||||
|
<h2 class="m-0 text-white text-xl font-bold">
|
||||||
|
{report.company_name}
|
||||||
|
</h2>
|
||||||
|
<span class="block mt-1 text-slate-400 text-sm"
|
||||||
|
>{report.title || report.filename}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="bg-transparent border-none text-slate-400 cursor-pointer p-1 flex transition-colors duration-200 hover:text-white"
|
||||||
|
onclick={onclose}
|
||||||
|
>
|
||||||
|
<Icon icon="ri:close-line" width="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 relative overflow-hidden bg-black flex flex-col">
|
||||||
|
{#if report.filename
|
||||||
|
.toLowerCase()
|
||||||
|
.endsWith(".pdf") || report.filename
|
||||||
|
.toLowerCase()
|
||||||
|
.endsWith(".txt")}
|
||||||
|
<iframe
|
||||||
|
src="http://localhost:5000/api/reports/view/{report.filename}"
|
||||||
|
class="w-full h-full border-none"
|
||||||
|
title="Report Viewer"
|
||||||
|
></iframe>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex-1 flex flex-col items-center justify-center text-slate-400 gap-5"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="ri:file-warning-line"
|
||||||
|
width="64"
|
||||||
|
class="text-slate-500"
|
||||||
|
/>
|
||||||
|
<p>Preview not available for this file type.</p>
|
||||||
|
<a
|
||||||
|
href="http://localhost:5000/api/reports/view/{report.filename}"
|
||||||
|
download
|
||||||
|
class="bg-emerald-400 text-slate-900 px-6 py-3 rounded-full no-underline font-bold transition-transform hover:scale-105"
|
||||||
|
>
|
||||||
|
Download File
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
86
frontend/src/lib/components/catalogue/IncidentCard.svelte
Normal file
86
frontend/src/lib/components/catalogue/IncidentCard.svelte
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
|
||||||
|
interface Incident {
|
||||||
|
_id: string;
|
||||||
|
product_name: string;
|
||||||
|
detected_brand: string;
|
||||||
|
user_description?: string;
|
||||||
|
created_at: string;
|
||||||
|
analysis: {
|
||||||
|
verdict: string;
|
||||||
|
confidence: string;
|
||||||
|
severity: string;
|
||||||
|
reasoning: string;
|
||||||
|
is_greenwashing: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { incident, onclick }: { incident: Incident; onclick: () => void } =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-6 p-6 bg-black/80 backdrop-blur-[30px] border border-red-500/30 rounded-[24px] transition-all duration-300 shadow-[0_16px_48px_rgba(0,0,0,0.5)] w-full text-left cursor-pointer hover:bg-black/95 hover:border-red-500/60 hover:-translate-y-1 hover:scale-[1.01] hover:shadow-[0_24px_64px_rgba(0,0,0,0.6)] outline-none"
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-[52px] h-[52px] rounded-xe flex items-center justify-center shrink-0 rounded-[14px]
|
||||||
|
{incident.analysis?.severity === 'high'
|
||||||
|
? 'bg-red-500/20 text-red-500'
|
||||||
|
: 'bg-amber-500/20 text-amber-500'}"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:alert-fill" width="28" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-3 mb-1.5">
|
||||||
|
<h3 class="text-white text-[18px] font-bold m-0 italic">
|
||||||
|
{incident.product_name}
|
||||||
|
</h3>
|
||||||
|
{#if incident.detected_brand && incident.detected_brand !== "Unknown"}
|
||||||
|
<span
|
||||||
|
class="bg-white/10 text-white/70 px-2.5 py-0.5 rounded-full text-[12px] font-semibold"
|
||||||
|
>{incident.detected_brand}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-white/70 text-sm m-0 mb-2.5 leading-tight italic">
|
||||||
|
{incident.analysis?.verdict || "Greenwashing detected"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold capitalize
|
||||||
|
{incident.analysis?.severity === 'high'
|
||||||
|
? 'bg-red-500/20 text-red-400'
|
||||||
|
: incident.analysis?.severity === 'medium'
|
||||||
|
? 'bg-amber-500/20 text-amber-400'
|
||||||
|
: 'bg-emerald-500/20 text-emerald-400'}"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:error-warning-fill" width="14" />
|
||||||
|
{incident.analysis?.severity || "unknown"} severity
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-indigo-500/20 text-indigo-300"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:shield-check-fill" width="14" />
|
||||||
|
{incident.analysis?.confidence || "unknown"} confidence
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-white/10 text-white/60"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:calendar-line" width="14" />
|
||||||
|
{new Date(incident.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center gap-1 text-[11px] font-bold uppercase text-red-500"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:spam-2-fill" width="24" class="text-red-500" />
|
||||||
|
<span>Confirmed</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
233
frontend/src/lib/components/catalogue/IncidentModal.svelte
Normal file
233
frontend/src/lib/components/catalogue/IncidentModal.svelte
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import { fade, scale } from "svelte/transition";
|
||||||
|
|
||||||
|
interface Incident {
|
||||||
|
_id: string;
|
||||||
|
product_name: string;
|
||||||
|
detected_brand: string;
|
||||||
|
user_description?: string;
|
||||||
|
created_at: string;
|
||||||
|
analysis: {
|
||||||
|
verdict: string;
|
||||||
|
confidence: string;
|
||||||
|
severity: string;
|
||||||
|
reasoning: string;
|
||||||
|
is_greenwashing: boolean;
|
||||||
|
key_claims?: string[];
|
||||||
|
red_flags?: string[];
|
||||||
|
recommendations?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { incident, onclose }: { incident: Incident; onclose: () => void } =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/60 backdrop-blur-md z-1000 flex justify-center items-center p-5 outline-none"
|
||||||
|
onclick={onclose}
|
||||||
|
onkeydown={(e) => e.key === "Escape" && onclose()}
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-[700px] max-h-[85vh] w-full overflow-y-auto bg-black/85 backdrop-blur-[40px] border border-white/20 rounded-[32px] flex flex-col shadow-[0_32px_128px_rgba(0,0,0,0.7)] scrollbar-hide outline-none"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
transition:scale={{ duration: 300, start: 0.95 }}
|
||||||
|
role="document"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="px-10 py-[30px] bg-white/3 border-b border-red-500/20 flex justify-between items-center shrink-0"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Icon
|
||||||
|
icon="ri:alert-fill"
|
||||||
|
width="28"
|
||||||
|
class="text-red-500"
|
||||||
|
/>
|
||||||
|
<h2 class="m-0 text-white text-[28px] font-extrabold">
|
||||||
|
{incident.product_name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{#if incident.detected_brand && incident.detected_brand !== "Unknown"}
|
||||||
|
<span class="mt-1 text-white/50 text-sm font-medium"
|
||||||
|
>Brand: {incident.detected_brand}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="bg-white/5 border border-white/10 text-white w-10 h-10 rounded-xl flex items-center justify-center cursor-pointer transition-all duration-200 hover:bg-white/10 hover:rotate-90"
|
||||||
|
onclick={onclose}
|
||||||
|
>
|
||||||
|
<Icon icon="ri:close-line" width="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-10 flex flex-col gap-[30px]">
|
||||||
|
<!-- Status Badges -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] font-extrabold tracking-wider
|
||||||
|
{incident.analysis?.severity === 'high'
|
||||||
|
? 'bg-red-500/20 text-red-400 border border-red-500/30'
|
||||||
|
: incident.analysis?.severity === 'medium'
|
||||||
|
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
|
||||||
|
: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'} uppercase"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:error-warning-fill" width="18" />
|
||||||
|
{incident.analysis?.severity || "UNKNOWN"} SEVERITY
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] font-extrabold tracking-wider bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 uppercase"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:shield-check-fill" width="18" />
|
||||||
|
{incident.analysis?.confidence || "UNKNOWN"} CONFIDENCE
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] font-extrabold tracking-wider bg-white/10 text-white/70 border border-white/10 uppercase"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:calendar-check-fill" width="18" />
|
||||||
|
{new Date(incident.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verdict -->
|
||||||
|
<div class="bg-white/4 border border-white/6 rounded-[20px] p-6">
|
||||||
|
<h3
|
||||||
|
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:scales-3-fill" width="20" />
|
||||||
|
Verdict
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-amber-400 text-[18px] font-semibold m-0 leading-normal"
|
||||||
|
>
|
||||||
|
{incident.analysis?.verdict || "Greenwashing detected"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Analysis -->
|
||||||
|
<div class="bg-white/4 border border-white/6 rounded-[20px] p-6">
|
||||||
|
<h3
|
||||||
|
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:file-text-fill" width="20" />
|
||||||
|
Detailed Analysis
|
||||||
|
</h3>
|
||||||
|
<p class="text-white/85 text-[15px] leading-[1.7] m-0">
|
||||||
|
{incident.analysis?.reasoning ||
|
||||||
|
"No detailed analysis available."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Red Flags -->
|
||||||
|
{#if incident.analysis?.red_flags && incident.analysis.red_flags.length > 0}
|
||||||
|
<div
|
||||||
|
class="bg-white/4 border border-white/[0.06] rounded-[20px] p-6"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="flex items-center gap-[10px] text-red-400 text-base font-bold mb-4"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:flag-fill" width="20" />
|
||||||
|
Red Flags Identified
|
||||||
|
</h3>
|
||||||
|
<ul class="list-none p-0 m-0 flex flex-col gap-3">
|
||||||
|
{#each incident.analysis.red_flags as flag}
|
||||||
|
<li
|
||||||
|
class="flex items-start gap-3 text-red-300/80 text-sm leading-[1.6]"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="ri:error-warning-line"
|
||||||
|
width="16"
|
||||||
|
class="mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
{flag}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Key Claims -->
|
||||||
|
{#if incident.analysis?.key_claims && incident.analysis.key_claims.length > 0}
|
||||||
|
<div
|
||||||
|
class="bg-white/4 border border-white/[0.06] rounded-[20px] p-6"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:chat-quote-fill" width="20" />
|
||||||
|
Environmental Claims Made
|
||||||
|
</h3>
|
||||||
|
<ul class="list-none p-0 m-0 flex flex-col gap-3">
|
||||||
|
{#each incident.analysis.key_claims as claim}
|
||||||
|
<li
|
||||||
|
class="flex items-start gap-3 text-white/70 text-sm italic leading-[1.6]"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="ri:double-quotes-l"
|
||||||
|
width="16"
|
||||||
|
class="mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
{claim}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recommendations -->
|
||||||
|
{#if incident.analysis?.recommendations}
|
||||||
|
<div
|
||||||
|
class="bg-emerald-500/8 border border-emerald-500/20 rounded-[20px] p-6"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="flex items-center gap-[10px] text-emerald-400 text-base font-bold mb-4"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:lightbulb-fill" width="20" />
|
||||||
|
Consumer Recommendations
|
||||||
|
</h3>
|
||||||
|
<p class="text-emerald-300 text-[15px] leading-[1.6] m-0">
|
||||||
|
{incident.analysis.recommendations}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- User's Original Report -->
|
||||||
|
{#if incident.user_description}
|
||||||
|
<div
|
||||||
|
class="bg-indigo-500/8 border border-indigo-500/20 rounded-[20px] p-6"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="flex items-center gap-[10px] text-indigo-400 text-base font-bold mb-4"
|
||||||
|
>
|
||||||
|
<Icon icon="ri:user-voice-fill" width="20" />
|
||||||
|
Original User Report
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-indigo-200 text-[15px] italic leading-[1.6] m-0"
|
||||||
|
>
|
||||||
|
"{incident.user_description}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Custom utility to hide scrollbar if tailwind plugin not present */
|
||||||
|
.scrollbar-hide {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
53
frontend/src/lib/components/catalogue/Pagination.svelte
Normal file
53
frontend/src/lib/components/catalogue/Pagination.svelte
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
currentPage = $bindable(),
|
||||||
|
totalPages,
|
||||||
|
goToPage,
|
||||||
|
}: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
goToPage: (p: number) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="flex items-center justify-center gap-4 mt-6">
|
||||||
|
<button
|
||||||
|
class="w-12 h-12 flex items-center justify-center rounded-xl bg-white/5 border border-white/10 text-white cursor-pointer transition-all duration-200 hover:enabled:bg-white/15 hover:enabled:scale-105 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onclick={() => goToPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
<Icon icon="ri:arrow-left-s-line" width="24" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if totalPages <= 7}
|
||||||
|
{#each Array(totalPages) as _, i}
|
||||||
|
<button
|
||||||
|
class="px-[18px] h-12 flex items-center justify-center rounded-xl border font-bold text-sm cursor-pointer transition-all duration-200 hover:enabled:bg-white/15 hover:enabled:scale-105 disabled:opacity-30 disabled:cursor-not-allowed
|
||||||
|
{currentPage === i + 1
|
||||||
|
? 'bg-emerald-500 border-emerald-500 text-emerald-950'
|
||||||
|
: 'bg-white/5 border-white/10 text-white'}"
|
||||||
|
onclick={() => goToPage(i + 1)}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<span class="text-white/50 text-sm font-semibold self-center">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-12 h-12 flex items-center justify-center rounded-xl bg-white/5 border border-white/10 text-white cursor-pointer transition-all duration-200 hover:enabled:bg-white/15 hover:enabled:scale-105 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onclick={() => goToPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
<Icon icon="ri:arrow-right-s-line" width="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
120
frontend/src/lib/components/catalogue/ReportCard.svelte
Normal file
120
frontend/src/lib/components/catalogue/ReportCard.svelte
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
|
||||||
|
interface Report {
|
||||||
|
company_name: string;
|
||||||
|
year: string | number;
|
||||||
|
sector: string;
|
||||||
|
greenwashing_score: number | string;
|
||||||
|
filename: string;
|
||||||
|
title?: string;
|
||||||
|
snippet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
report,
|
||||||
|
openReport,
|
||||||
|
searchQuery,
|
||||||
|
}: {
|
||||||
|
report: Report;
|
||||||
|
openReport: (r: Report) => void;
|
||||||
|
searchQuery: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function getFileDetails(filename: string) {
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase();
|
||||||
|
if (ext === "pdf")
|
||||||
|
return { type: "PDF Document", icon: "ri:file-pdf-2-line" };
|
||||||
|
if (ext === "xlsx" || ext === "csv")
|
||||||
|
return { type: "Data Spreadsheet", icon: "ri:file-excel-2-line" };
|
||||||
|
if (ext === "txt")
|
||||||
|
return { type: "Text Report", icon: "ri:file-text-line" };
|
||||||
|
return { type: "Impact Report", icon: "ri:file-info-line" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreColor(score: string | number) {
|
||||||
|
const s = Number(score);
|
||||||
|
if (s >= 80) return "bg-emerald-500";
|
||||||
|
if (s >= 50) return "bg-amber-500";
|
||||||
|
return "bg-red-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileDetails = $derived(getFileDetails(report.filename));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="group flex items-center gap-6 bg-black/80 backdrop-blur-[30px] border border-white/20 rounded-3xl p-8 w-full text-left cursor-pointer transition-all duration-300 hover:bg-black/90 hover:border-emerald-500/60 hover:-translate-y-1 hover:scale-[1.01] hover:shadow-[0_24px_48px_rgba(0,0,0,0.5)] relative overflow-hidden outline-none"
|
||||||
|
onclick={() => openReport(report)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 bg-emerald-500/10 rounded-[18px] flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-emerald-500 group-hover:-rotate-6"
|
||||||
|
>
|
||||||
|
<Icon icon={fileDetails.icon} width="32" class="text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-white text-xl font-extrabold m-0">
|
||||||
|
{report.company_name}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md text-[12px] font-bold"
|
||||||
|
>{report.year}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if report.snippet}
|
||||||
|
<p
|
||||||
|
class="text-white/60 text-sm leading-relaxed mb-4 line-clamp-2 m-0"
|
||||||
|
>
|
||||||
|
{@html report.snippet.replace(
|
||||||
|
new RegExp(searchQuery || "", "gi"),
|
||||||
|
(match) =>
|
||||||
|
`<span class="text-emerald-400 bg-emerald-500/20 px-0.5 rounded font-semibold">${match}</span>`,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-white/40 text-sm mb-4 m-0">
|
||||||
|
{report.sector} Sector • Impact Report
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-bold tracking-tight bg-white/5 text-white/60 max-w-[200px] truncate"
|
||||||
|
title={report.filename}
|
||||||
|
>
|
||||||
|
<Icon icon={fileDetails.icon} width="14" />
|
||||||
|
{fileDetails.type}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-bold tracking-tight bg-emerald-500/10 text-emerald-400"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="ri:checkbox-circle-fill"
|
||||||
|
width="14"
|
||||||
|
class="text-emerald-500"
|
||||||
|
/>
|
||||||
|
Analyzed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if report.greenwashing_score}
|
||||||
|
<div class="text-center ml-5">
|
||||||
|
<div
|
||||||
|
class="w-[52px] h-[52px] rounded-xe flex items-center justify-center mb-1 rounded-[14px] {getScoreColor(
|
||||||
|
report.greenwashing_score,
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
<span class="text-emerald-950 text-[18px] font-black"
|
||||||
|
>{Math.round(Number(report.greenwashing_score))}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-white/40 text-[10px] font-extrabold uppercase tracking-widest"
|
||||||
|
>Trust Score</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@
|
|||||||
let description = $state("");
|
let description = $state("");
|
||||||
let image = $state<string | null>(null);
|
let image = $state<string | null>(null);
|
||||||
let submitted = $state(false);
|
let submitted = $state(false);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let analysisResult = $state<any>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const initialName = new URLSearchParams(window.location.search).get(
|
const initialName = new URLSearchParams(window.location.search).get(
|
||||||
@@ -37,12 +40,75 @@
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
const progressSteps = [
|
||||||
if (!isValid) return;
|
{ id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
label: "Detecting brand logos...",
|
||||||
|
icon: "ri:search-eye-line",
|
||||||
|
},
|
||||||
|
{ id: 3, label: "Searching database...", icon: "ri:database-2-line" },
|
||||||
|
{ id: 4, label: "AI analysis in progress...", icon: "ri:robot-2-line" },
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
label: "Generating verdict...",
|
||||||
|
icon: "ri:file-shield-2-line",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentStep = $state(0);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!isValid || isLoading) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
currentStep = 1;
|
||||||
|
|
||||||
|
// Simulate progress steps
|
||||||
|
const stepInterval = setInterval(() => {
|
||||||
|
if (currentStep < progressSteps.length) {
|
||||||
|
currentStep++;
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
"http://localhost:5000/api/incidents/submit",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
product_name: productName,
|
||||||
|
description: description,
|
||||||
|
image: image, // Base64 encoded
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
clearInterval(stepInterval);
|
||||||
|
currentStep = progressSteps.length; // Complete all steps
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === "success") {
|
||||||
|
// Brief pause to show completion
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
analysisResult = data;
|
||||||
submitted = true;
|
submitted = true;
|
||||||
setTimeout(() => {
|
} else {
|
||||||
window.history.back();
|
error = data.message || "Failed to submit report";
|
||||||
}, 2000);
|
}
|
||||||
|
} catch (e) {
|
||||||
|
clearInterval(stepInterval);
|
||||||
|
error = "Failed to connect to server. Please try again.";
|
||||||
|
console.error("Submit error:", e);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
currentStep = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -52,8 +118,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-container">
|
<div class="content-container">
|
||||||
{#if submitted}
|
{#if submitted && analysisResult}
|
||||||
<div class="glass-card success-card">
|
<div class="glass-card success-card">
|
||||||
|
{#if analysisResult.is_greenwashing}
|
||||||
|
<div class="icon-circle warning">
|
||||||
|
<iconify-icon
|
||||||
|
icon="ri:alert-fill"
|
||||||
|
width="60"
|
||||||
|
style="color: #f59e0b;"
|
||||||
|
></iconify-icon>
|
||||||
|
</div>
|
||||||
|
<h2 class="success-title warning-text">
|
||||||
|
Greenwashing Detected!
|
||||||
|
</h2>
|
||||||
|
{:else}
|
||||||
<div class="icon-circle success">
|
<div class="icon-circle success">
|
||||||
<iconify-icon
|
<iconify-icon
|
||||||
icon="ri:checkbox-circle-fill"
|
icon="ri:checkbox-circle-fill"
|
||||||
@@ -61,11 +139,59 @@
|
|||||||
style="color: #4ade80;"
|
style="color: #4ade80;"
|
||||||
></iconify-icon>
|
></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="success-title">Report Submitted!</h2>
|
<h2 class="success-title">Report Analyzed</h2>
|
||||||
<p class="success-subtitle">
|
{/if}
|
||||||
Thank you for keeping companies honest.
|
|
||||||
|
<div class="analysis-result">
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Verdict:</span>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.analysis?.verdict || "N/A"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Confidence:</span>
|
||||||
|
<span
|
||||||
|
class="result-value badge {analysisResult.analysis
|
||||||
|
?.confidence}"
|
||||||
|
>
|
||||||
|
{analysisResult.analysis?.confidence || "unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if analysisResult.is_greenwashing}
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Severity:</span>
|
||||||
|
<span
|
||||||
|
class="result-value badge severity-{analysisResult
|
||||||
|
.analysis?.severity}"
|
||||||
|
>
|
||||||
|
{analysisResult.analysis?.severity || "unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="result-item full-width">
|
||||||
|
<span class="result-label">Analysis:</span>
|
||||||
|
<p class="result-text">
|
||||||
|
{analysisResult.analysis?.reasoning ||
|
||||||
|
"No details available"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{#if analysisResult.detected_brand && analysisResult.detected_brand !== "Unknown"}
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Detected Brand:</span>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.detected_brand}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="back-btn" onclick={() => window.history.back()}>
|
||||||
|
<iconify-icon icon="ri:arrow-left-line" width="20"
|
||||||
|
></iconify-icon>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
<h1 class="page-title">Report Greenwashing</h1>
|
<h1 class="page-title">Report Greenwashing</h1>
|
||||||
@@ -143,6 +269,68 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">
|
||||||
|
<iconify-icon
|
||||||
|
icon="ri:error-warning-line"
|
||||||
|
width="18"
|
||||||
|
></iconify-icon>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-header">
|
||||||
|
<iconify-icon
|
||||||
|
icon="ri:shield-check-line"
|
||||||
|
width="24"
|
||||||
|
class="pulse"
|
||||||
|
></iconify-icon>
|
||||||
|
<span>Analyzing Report</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-steps">
|
||||||
|
{#each progressSteps as step}
|
||||||
|
<div
|
||||||
|
class="progress-step"
|
||||||
|
class:active={currentStep >= step.id}
|
||||||
|
class:current={currentStep === step.id}
|
||||||
|
>
|
||||||
|
<div class="step-icon">
|
||||||
|
{#if currentStep > step.id}
|
||||||
|
<iconify-icon
|
||||||
|
icon="ri:check-line"
|
||||||
|
width="16"
|
||||||
|
></iconify-icon>
|
||||||
|
{:else if currentStep === step.id}
|
||||||
|
<iconify-icon
|
||||||
|
icon={step.icon}
|
||||||
|
width="16"
|
||||||
|
class="spin-slow"
|
||||||
|
></iconify-icon>
|
||||||
|
{:else}
|
||||||
|
<iconify-icon
|
||||||
|
icon={step.icon}
|
||||||
|
width="16"
|
||||||
|
></iconify-icon>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="step-label"
|
||||||
|
>{step.label}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
style="width: {(currentStep /
|
||||||
|
progressSteps.length) *
|
||||||
|
100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="submit-button"
|
class="submit-button"
|
||||||
class:disabled={!isValid}
|
class:disabled={!isValid}
|
||||||
@@ -150,10 +338,11 @@
|
|||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<iconify-icon icon="ri:alert-fill" width="20"
|
<iconify-icon icon="ri:shield-flash-line" width="20"
|
||||||
></iconify-icon>
|
></iconify-icon>
|
||||||
Submit Report
|
Analyze for Greenwashing
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -175,22 +364,22 @@
|
|||||||
.content-container {
|
.content-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 100px 24px 120px;
|
padding: 80px 20px 40px;
|
||||||
max-width: 600px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-section {
|
.header-section {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 36px;
|
font-size: 28px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: -1px;
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
@@ -212,19 +401,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-card {
|
.form-card {
|
||||||
padding: 36px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-content {
|
.form-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
@@ -412,6 +601,239 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
color: #f59e0b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-circle.warning {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.full-width {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-value {
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.high {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.badge.medium {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.badge.low {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-high {
|
||||||
|
background: #ef4444 !important;
|
||||||
|
}
|
||||||
|
.severity-medium {
|
||||||
|
background: #f59e0b !important;
|
||||||
|
}
|
||||||
|
.severity-low {
|
||||||
|
background: #22c55e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
margin-top: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.spin) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.spin-slow) {
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.pulse) {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
background: rgba(34, 197, 94, 0.08);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #4ade80;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.current {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.current .step-label {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active .step-icon {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.current .step-icon {
|
||||||
|
background: #4ade80;
|
||||||
|
color: #051f18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-step.active .step-label {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #22c55e, #4ade80);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.desktop-bg {
|
.desktop-bg {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
Reference in New Issue
Block a user