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')
|
||||
from .routes.reports import reports_bp
|
||||
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
|
||||
|
||||
|
||||
@@ -23,7 +23,33 @@ Based on the context provided, give a final verdict:
|
||||
|
||||
def ask(prompt):
|
||||
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):
|
||||
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"):
|
||||
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)
|
||||
response = client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=prompt,
|
||||
)
|
||||
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:
|
||||
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:
|
||||
# Attempt to extract info from filename
|
||||
# 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 image = $state<string | null>(null);
|
||||
let submitted = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let analysisResult = $state<any>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const initialName = new URLSearchParams(window.location.search).get(
|
||||
@@ -37,12 +40,75 @@
|
||||
input.click();
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
const progressSteps = [
|
||||
{ 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;
|
||||
setTimeout(() => {
|
||||
window.history.back();
|
||||
}, 2000);
|
||||
} else {
|
||||
error = data.message || "Failed to submit report";
|
||||
}
|
||||
} catch (e) {
|
||||
clearInterval(stepInterval);
|
||||
error = "Failed to connect to server. Please try again.";
|
||||
console.error("Submit error:", e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
currentStep = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,8 +118,20 @@
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
{#if submitted}
|
||||
{#if submitted && analysisResult}
|
||||
<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">
|
||||
<iconify-icon
|
||||
icon="ri:checkbox-circle-fill"
|
||||
@@ -61,11 +139,59 @@
|
||||
style="color: #4ade80;"
|
||||
></iconify-icon>
|
||||
</div>
|
||||
<h2 class="success-title">Report Submitted!</h2>
|
||||
<p class="success-subtitle">
|
||||
Thank you for keeping companies honest.
|
||||
<h2 class="success-title">Report Analyzed</h2>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
</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}
|
||||
<div class="header-section">
|
||||
<h1 class="page-title">Report Greenwashing</h1>
|
||||
@@ -143,6 +269,68 @@
|
||||
</button>
|
||||
</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
|
||||
class="submit-button"
|
||||
class:disabled={!isValid}
|
||||
@@ -150,10 +338,11 @@
|
||||
onclick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
<iconify-icon icon="ri:alert-fill" width="20"
|
||||
<iconify-icon icon="ri:shield-flash-line" width="20"
|
||||
></iconify-icon>
|
||||
Submit Report
|
||||
Analyze for Greenwashing
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -175,22 +364,22 @@
|
||||
.content-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 100px 24px 120px;
|
||||
max-width: 600px;
|
||||
padding: 80px 20px 40px;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
letter-spacing: -1px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@@ -212,19 +401,19 @@
|
||||
}
|
||||
|
||||
.form-card {
|
||||
padding: 36px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -412,6 +601,239 @@
|
||||
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) {
|
||||
.desktop-bg {
|
||||
display: block;
|
||||
|
||||
Reference in New Issue
Block a user