Restore code and save recent updates

This commit is contained in:
2026-01-25 03:31:01 +00:00
parent bae861c71f
commit 5ce0b4d278
54 changed files with 2963 additions and 2899 deletions

View File

@@ -1,24 +1,24 @@
# Use a lightweight Python image
FROM python:3.9-slim
# Set working directory inside the container
WORKDIR /app
# Copy requirements first (for better caching)
COPY requirements.txt .
# Install dependencies
# 'gunicorn' must be in your requirements.txt or installed here
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install gunicorn
# Copy the rest of the application
COPY . .
# Expose the internal port (Gunicorn default is 8000, or we choose one)
EXPOSE 5000
# Command to run production server
# -w 4: 4 worker processes
# -b 0.0.0.0:5000: Bind to all interfaces inside container on port 5000
CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:5000", "app:app"]

View File

@@ -1,7 +1,7 @@
flask
gunicorn
ultralytics
opencv-python
opencv-python-headless
transformers
torch
pandas

View File

@@ -72,7 +72,7 @@ def delete_documents_by_source(source_file, collection_name=COLLECTION_NAME):
def get_all_metadatas (collection_name =COLLECTION_NAME ,limit =None ):
collection =get_collection (collection_name )
# Only fetch metadatas to be lightweight
if limit :
results =collection .get (include =["metadatas"],limit =limit )
else :

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
import argparse
from .config import YOLO26_MODELS

View File

@@ -23,22 +23,22 @@ 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-3-flash-preview", contents=prompt).text
return client .models .generate_content (model ="gemini-3-pro-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.

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3
import argparse
import json
import sys

View File

@@ -4,6 +4,8 @@ Uses structured outputs with Pydantic for reliable JSON responses
"""
import base64
import os
import cv2
import numpy as np
from datetime import datetime
from flask import Blueprint ,request ,jsonify
from google import genai
@@ -17,7 +19,7 @@ from src.mongo.connection import get_mongo_client
incidents_bp =Blueprint ('incidents',__name__ )
# Initialize detector lazily
_detector =None
def get_detector ():
@@ -27,7 +29,52 @@ def get_detector():
return _detector
# ============= Pydantic Models for Structured Outputs =============
def compress_image (image_bytes :bytes ,max_width :int =800 ,quality :int =85 )->str :
"""
Compress image using OpenCV and return Base64 string
Args:
image_bytes: Original image bytes
max_width: Maximum width for resized image
quality: JPEG quality (1-100)
Returns:
Base64 encoded compressed image
"""
try :
nparr =np .frombuffer (image_bytes ,np .uint8 )
img =cv2 .imdecode (nparr ,cv2 .IMREAD_COLOR )
if img is None :
raise ValueError ("Failed to decode image")
height ,width =img .shape [:2 ]
if width >max_width :
ratio =max_width /width
new_width =max_width
new_height =int (height *ratio )
img =cv2 .resize (img ,(new_width ,new_height ),interpolation =cv2 .INTER_AREA )
encode_param =[int (cv2 .IMWRITE_JPEG_QUALITY ),quality ]
_ ,buffer =cv2 .imencode ('.jpg',img ,encode_param )
compressed_base64 =base64 .b64encode (buffer ).decode ('utf-8')
return compressed_base64
except Exception as e :
print (f"Image compression error: {e }")
return base64 .b64encode (image_bytes ).decode ('utf-8')
class GreenwashingAnalysis (BaseModel ):
"""Structured output for greenwashing analysis"""
@@ -58,7 +105,7 @@ class ImageAnalysis(BaseModel):
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.
@@ -98,9 +145,9 @@ def analyze_with_gemini(product_name: str, user_description: str, detected_brand
client =genai .Client (api_key =api_key )
# Use structured output with Pydantic schema
response =client .models .generate_content (
model="gemini-3-flash-preview",
model ="gemini-3-pro-preview",
contents =prompt ,
config ={
"response_mime_type":"application/json",
@@ -108,7 +155,7 @@ def analyze_with_gemini(product_name: str, user_description: str, detected_brand
}
)
# Validate and parse the response
analysis =GreenwashingAnalysis .model_validate_json (response .text )
return analysis
@@ -142,17 +189,17 @@ Respond with structured JSON matching the schema provided."""
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 (
@@ -182,40 +229,62 @@ def save_to_mongodb(incident_data: dict) -> str:
def save_to_chromadb (incident_data :dict ,incident_id :str ):
"""Save incident as context for the chatbot"""
"""
Save incident as context for the chatbot
Includes verdict, full analysis, and environmental impact information
"""
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',[]))
env_claims ="\n".join (f"- {claim }"for claim in incident_data .get ('environmental_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')})
Report Date: {incident_data ['created_at']}
Company/Product: {incident_data ['product_name']}
Detected Brand: {incident_data .get ('detected_brand','Unknown brand')}
Status: {incident_data ['status']}
USER REPORT: {incident_data['user_description']}
=== VERDICT ===
{analysis ['verdict']}
ANALYSIS VERDICT: {analysis['verdict']}
Confidence: {analysis['confidence']}
Severity: {analysis['severity']}
Greenwashing Detected: {'YES'if analysis ['is_greenwashing']else 'NO'}
Confidence Level: {analysis ['confidence']}
Severity Assessment: {analysis ['severity']}
DETAILED REASONING:
=== USER COMPLAINT ===
{incident_data ['user_description']}
=== IMAGE ANALYSIS ===
{incident_data .get ('image_description','No image analysis available')}
=== ENVIRONMENTAL CLAIMS IDENTIFIED ===
{env_claims if env_claims else 'No specific environmental claims identified'}
=== DETAILED ANALYSIS & REASONING ===
{analysis ['reasoning']}
KEY ENVIRONMENTAL CLAIMS MADE:
{key_claims}
=== KEY MARKETING CLAIMS ===
{key_claims if key_claims else 'No key claims identified'}
RED FLAGS IDENTIFIED:
{red_flags}
=== RED FLAGS IDENTIFIED ===
{red_flags if red_flags else 'No specific red flags identified'}
CONSUMER RECOMMENDATIONS:
=== CONSUMER RECOMMENDATIONS ===
{analysis ['recommendations']}
=== ENVIRONMENTAL IMPACT ASSESSMENT ===
This report highlights potential misleading environmental claims by {incident_data .get ('detected_brand','the company')}.
Consumers should be aware that {analysis ['severity']} severity greenwashing has been identified with {analysis ['confidence']} confidence.
This incident has been documented for future reference and to help inform sustainable purchasing decisions.
"""
# Get embedding for the incident
embedding =get_embedding (text )
# Store in ChromaDB with metadata
metadata ={
"type":"incident_report",
"source":f"incident_{incident_id }",
@@ -224,7 +293,11 @@ CONSUMER RECOMMENDATIONS:
"severity":analysis ['severity'],
"confidence":analysis ['confidence'],
"is_greenwashing":True ,
"created_at": incident_data['created_at']
"verdict":analysis ['verdict'],
"status":incident_data ['status'],
"created_at":incident_data ['created_at'],
"num_red_flags":len (analysis .get ('red_flags',[])),
"num_claims":len (analysis .get ('key_claims',[]))
}
insert_documents (
@@ -233,8 +306,10 @@ CONSUMER RECOMMENDATIONS:
metadata_list =[metadata ]
)
print (f"✓ Incident #{incident_id } saved to ChromaDB for AI chat context")
# ============= API Endpoints =============
@incidents_bp .route ('/submit',methods =['POST'])
def submit_incident ():
@@ -244,7 +319,9 @@ def submit_incident():
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)
- report_type: 'product' or 'company'
- image: Base64 encoded image (for product reports)
- pdf_data: Base64 encoded PDF (for company reports)
"""
data =request .json
@@ -253,7 +330,8 @@ def submit_incident():
product_name =data .get ('product_name','').strip ()
user_description =data .get ('description','').strip ()
image_base64 = data.get('image') # Base64 encoded image
report_type =data .get ('report_type','product')
image_base64 =data .get ('image')
if not product_name :
return jsonify ({"error":"Product name is required"}),400
@@ -262,20 +340,25 @@ def submit_incident():
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 =[]
compressed_image_base64 =None
if image_base64:
if report_type =='product'and 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
print ("Compressing image with OpenCV...")
compressed_image_base64 =compress_image (image_bytes ,max_width =600 ,quality =75 )
image_analysis =analyze_image_with_ollama (image_bytes )
if image_analysis .logos_detected :
@@ -285,10 +368,11 @@ def submit_incident():
environmental_claims =image_analysis .environmental_claims
except Exception as e :
print(f"Image analysis error: {e}")
# Continue without image analysis
print (f"Image processing error: {e }")
# 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 )
@@ -300,12 +384,12 @@ def submit_incident():
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 ,
@@ -314,10 +398,10 @@ def submit_incident():
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 ,
@@ -327,17 +411,22 @@ def submit_incident():
"analysis":analysis_dict ,
"is_greenwashing":analysis .is_greenwashing ,
"created_at":datetime .utcnow ().isoformat (),
"status": "confirmed" if analysis.is_greenwashing else "dismissed"
"status":"confirmed"if analysis .is_greenwashing else "dismissed",
"report_type":report_type
}
if compressed_image_base64 :
incident_data ["image_base64"]=compressed_image_base64
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 ({
@@ -366,14 +455,15 @@ def list_incidents():
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}
"user_description":1 ,"analysis":1 ,"created_at":1 ,
"image_base64":1 ,"report_type":1 }
).sort ("created_at",-1 ).limit (50 ))
# Convert ObjectId to string
for inc in incidents :
inc ["_id"]=str (inc ["_id"])

View File

@@ -7,8 +7,8 @@ reports_bp = Blueprint('reports', __name__)
@reports_bp .route ('/',methods =['GET'])
def get_reports ():
try :
# Fetch all metadatas to ensure we get diversity.
# 60k items is manageable for metadata-only fetch.
metadatas =get_all_metadatas ()
unique_reports ={}
@@ -18,17 +18,17 @@ 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:
# 2020-tesla-impact-report.pdf
# google-2023-environmental-report.pdf
# ghgp_data_2021.xlsx
company_name ="Unknown"
year ="N/A"
@@ -36,13 +36,13 @@ def get_reports():
lower_name =filename .lower ()
# Extract Year
import re
year_match =re .search (r'20\d{2}',lower_name )
if year_match :
year =year_match .group (0 )
# Extract Company (heuristics)
if 'tesla'in lower_name :
company_name ="Tesla"
sector ="Automotive"
@@ -71,18 +71,18 @@ def get_reports():
company_name ="HP"
sector ="Tech"
else :
# Fallback: capitalize first word of filename
parts =re .split (r'[-_.]',filename )
if parts :
company_name =parts [0 ].capitalize ()
if company_name.isdigit(): # If starts with year
if company_name .isdigit ():
company_name =parts [1 ].capitalize ()if len (parts )>1 else "Unknown"
unique_reports [filename ]={
'company_name':company_name ,
'year':year ,
'sector':sector ,
'greenwashing_score': meta.get('greenwashing_score', 0), # Likely 0
'greenwashing_score':meta .get ('greenwashing_score',0 ),
'filename':filename ,
'title':f"{company_name } {year } Report"
}
@@ -107,15 +107,15 @@ def search_reports():
try :
import re
# Get embedding for the query
query_embedding =get_embedding (query )
# Search in Chroma - get more results to filter
results =search_documents (query_embedding ,num_results =50 )
query_lower =query .lower ()
# Helper function to extract company info
def extract_company_info (filename ):
company_name ="Unknown"
year ="N/A"
@@ -123,12 +123,12 @@ def search_reports():
lower_name =filename .lower ()
# Extract Year
year_match =re .search (r'20\d{2}',lower_name )
if year_match :
year =year_match .group (0 )
# Extract Company (heuristics)
if 'tesla'in lower_name :
company_name ="Tesla"
sector ="Automotive"
@@ -174,26 +174,26 @@ def search_reports():
filename =meta .get ('source')or meta .get ('filename','Unknown')
# Skip duplicates
if filename in seen_filenames :
continue
seen_filenames .add (filename )
company_name ,year ,sector =extract_company_info (filename )
# Calculate match score - boost if query matches company/filename
match_boost =0
if query_lower in filename .lower ():
match_boost = 1000 # Strong boost for filename match
match_boost =1000
if query_lower in company_name .lower ():
match_boost = 1000 # Strong boost for company match
match_boost =1000
# Semantic score (inverted distance, higher = better)
semantic_score =1 /(item .get ('score',1 )+0.001 )if item .get ('score')else 0
combined_score =match_boost +semantic_score
# Format snippet
snippet =text [:300 ]+"..."if len (text )>300 else text
output .append ({
@@ -207,10 +207,10 @@ def search_reports():
'_combined_score':combined_score
})
# Sort by combined score (descending - higher is better)
output .sort (key =lambda x :x .get ('_combined_score',0 ),reverse =True )
# Remove internal score field and limit results
for item in output :
item .pop ('_combined_score',None )
@@ -224,9 +224,9 @@ def view_report_file(filename):
import os
from flask import send_from_directory
# Dataset path relative to this file
# src/routes/reports.py -> src/routes -> src -> backend -> dataset
# So ../../../dataset
current_dir =os .path .dirname (os .path .abspath (__file__ ))
dataset_dir =os .path .join (current_dir ,'..','..','dataset')

View File

@@ -30,6 +30,7 @@
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tauri-apps/cli": "^2.9.6",
"@types/node": "^25.0.10",
"svelte": "^5.48.2",
"svelte-check": "^4.3.5",
"typescript": "~5.6.3",

View File

@@ -9,14 +9,14 @@ const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
// Enable gzip compression
app.use(compression());
// Serve static files from the build directory (one level up from server folder)
const buildPath = path.join(__dirname, '../build');
app.use(express.static(buildPath));
// Handle SPA routing: serve index.html for any unknown routes
app.get(/.*/, (req, res) => {
res.sendFile(path.join(buildPath, 'index.html'));
});

View File

@@ -1,4 +1,4 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)

View File

@@ -1,4 +1,4 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {

View File

@@ -201,7 +201,7 @@
const config = activeConfig();
if (!config.staticScene || config.scenes) {
// Always use window scroll now
scrollContainer = null;
updateMeasurements();
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import Pagination from "./Pagination.svelte";
let {
viewMode = $bindable(),
@@ -7,24 +8,32 @@
selectedCategory = $bindable(),
categories,
onSearchInput,
currentPage,
totalPages,
goToPage,
}: {
viewMode: "company" | "user";
searchQuery: string;
selectedCategory: string;
categories: string[];
onSearchInput: () => void;
currentPage: number;
totalPages: number;
goToPage: (page: number) => 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)]"
class="bg-black/60 backdrop-blur-2xl border border-white/15 rounded-4xl p-12 lg:p-14 mb-12 shadow-2xl shadow-black/50"
>
<div class="mb-10 px-4 text-center">
<h1
class="text-white text-[42px] lg:text-[48px] font-black m-0 tracking-[-2px]"
>
<!-- 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">
<p class="text-white/80 text-base lg:text-lg mt-3 font-medium">
{#if viewMode === "company"}
Search within verified company reports and impact assessments
{:else}
@@ -33,35 +42,35 @@
</p>
</div>
<!-- View Mode Toggle Switch -->
<div class="flex items-center justify-center gap-4 my-6">
<div class="flex items-center justify-center gap-5 my-8">
<span
class="flex items-center gap-1.5 text-sm font-semibold transition-all duration-300 {viewMode ===
class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode ===
'company'
? 'text-emerald-400'
: 'text-white/40'}"
: 'text-white/50'}"
>
<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"
class="relative w-16 h-8 bg-white/10 border-none rounded-full cursor-pointer transition-all duration-300 p-0 hover:bg-white/15 shadow-inner"
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 ===
class="absolute top-1/2 -translate-y-1/2 w-6 h-6 bg-emerald-500 rounded-full transition-all duration-300 shadow-[0_2px_12px_rgba(34,197,94,0.5)] {viewMode ===
'user'
? 'left-[calc(100%-25px)]'
: 'left-[3px]'}"
? 'left-[calc(100%-28px)]'
: 'left-1'}"
></span>
</button>
<span
class="flex items-center gap-1.5 text-sm font-semibold transition-all duration-300 {viewMode ===
class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode ===
'user'
? 'text-emerald-400'
: 'text-white/40'}"
: 'text-white/50'}"
>
<Icon icon="ri:user-voice-line" width="16" />
User Reports
@@ -69,33 +78,35 @@
</div>
{#if viewMode === "company"}
<div class="relative max-w-[600px] mx-auto mb-8">
<div class="relative max-w-[40.625rem] mx-auto mb-10">
<div
class="absolute left-5 top-1/2 -translate-y-1/2 text-white/60 flex items-center pointer-events-none"
class="absolute left-6 top-1/2 -translate-y-1/2 text-white/60 flex items-center pointer-events-none"
>
<Icon icon="ri:search-line" width="20" />
<Icon icon="ri:search-line" width="22" />
</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"
class="w-full bg-black/30 border border-white/15 rounded-full py-4 lg:py-5 pl-14 pr-6 text-white text-base lg:text-lg font-medium outline-none transition-all duration-200 focus:bg-black/40 focus:border-emerald-400 placeholder:text-white/50 shadow-inner"
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">
<div class="flex justify-center flex-wrap gap-3.5 mb-6">
{#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 ===
class="px-7 py-3 rounded-full text-sm lg:text-base 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'}"
? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_20px_rgba(34,197,94,0.4)]'
: 'bg-black/30 backdrop-blur-sm border-white/15 text-white/70 hover:bg-black/40 hover:text-white hover:-translate-y-0.5 hover:shadow-lg'}"
onclick={() => (selectedCategory = category)}
>
{category}
</button>
{/each}
</div>
<Pagination {currentPage} {totalPages} {goToPage} />
{/if}
</div>

View File

@@ -25,12 +25,13 @@
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"
class="bg-slate-900 border border-slate-700 rounded-3xl w-full max-w-5xl 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"
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="px-6 py-5 bg-slate-800 border-b border-slate-700 flex justify-between items-center shrink-0"

View File

@@ -7,6 +7,7 @@
detected_brand: string;
user_description?: string;
created_at: string;
image_base64?: string;
analysis: {
verdict: string;
confidence: string;
@@ -21,66 +22,82 @@
</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"
class="flex items-center gap-6 lg:gap-8 p-7 lg:p-8 bg-black/60 backdrop-blur-2xl border border-red-500/30 rounded-3xl transition-all duration-300 shadow-2xl shadow-black/50 w-full text-left cursor-pointer hover:bg-black/70 hover:border-red-500/60 hover:-translate-y-1 hover:scale-[1.01] hover:shadow-[0_24px_72px_rgba(0,0,0,0.7)] outline-none group"
{onclick}
>
{#if incident.image_base64}
<div
class="w-[52px] h-[52px] rounded-xe flex items-center justify-center shrink-0 rounded-[14px]
class="w-16 h-16 lg:w-20 lg:h-20 shrink-0 rounded-2xl overflow-hidden border border-white/10 group-hover:border-white/20 transition-colors"
>
<img
src="data:image/jpeg;base64,{incident.image_base64}"
alt={incident.product_name}
class="w-full h-full object-cover"
/>
</div>
{:else}
<div
class="w-14 h-14 lg:w-16 lg:h-16 rounded-xe flex items-center justify-center shrink-0 rounded-2xl transition-transform
{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" />
<Icon icon="ri:alert-fill" width="32" class="lg:w-9" />
</div>
{/if}
<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">
<div class="flex items-center gap-3 mb-2">
<h3
class="text-white text-[19px] lg:text-[20px] 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"
class="bg-white/10 backdrop-blur-sm text-white/80 px-3 py-1 rounded-full text-[12px] lg:text-[13px] font-semibold"
>{incident.detected_brand}</span
>
{/if}
</div>
<p class="text-white/70 text-sm m-0 mb-2.5 leading-tight italic">
<p
class="text-white/70 text-sm lg:text-base m-0 mb-3 leading-tight italic"
>
{incident.analysis?.verdict || "Greenwashing detected"}
</p>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2.5">
<span
class="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold capitalize
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[11px] lg:text-[12px] font-semibold capitalize backdrop-blur-sm
{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" />
<Icon icon="ri:error-warning-fill" width="15" />
{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"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[11px] lg:text-[12px] font-semibold bg-indigo-500/20 text-indigo-300 backdrop-blur-sm"
>
<Icon icon="ri:shield-check-fill" width="14" />
<Icon icon="ri:shield-check-fill" width="15" />
{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"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[11px] lg:text-[12px] font-semibold bg-white/10 text-white/70 backdrop-blur-sm"
>
<Icon icon="ri:calendar-line" width="14" />
<Icon icon="ri:calendar-line" width="15" />
{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"
class="flex flex-col items-center gap-1.5 text-[11px] lg:text-[12px] font-bold uppercase text-red-400"
>
<Icon icon="ri:spam-2-fill" width="24" class="text-red-500" />
<Icon icon="ri:spam-2-fill" width="26" class="text-red-400 lg:w-7" />
<span>Confirmed</span>
</div>
</button>

View File

@@ -8,6 +8,7 @@
detected_brand: string;
user_description?: string;
created_at: string;
image_base64?: string;
analysis: {
verdict: string;
confidence: string;
@@ -25,7 +26,7 @@
</script>
<div
class="fixed inset-0 bg-black/60 backdrop-blur-md z-1000 flex justify-center items-center p-5 outline-none"
class="fixed inset-0 bg-black/50 backdrop-blur-lg z-1000 flex justify-center items-center p-6 outline-none"
onclick={onclose}
onkeydown={(e) => e.key === "Escape" && onclose()}
transition:fade={{ duration: 200 }}
@@ -33,8 +34,10 @@
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"
class="max-w-3xl max-h-[88vh] w-full overflow-y-auto bg-white/8 backdrop-blur-2xl border border-white/20 rounded-4xl flex flex-col shadow-2xl shadow-black/60 scrollbar-hide outline-none"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
transition:scale={{ duration: 300, start: 0.95 }}
@@ -42,105 +45,138 @@
tabindex="0"
>
<div
class="px-10 py-[30px] bg-white/3 border-b border-red-500/20 flex justify-between items-center shrink-0"
class="px-10 lg:px-12 py-8 bg-white/5 border-b border-red-500/25 flex justify-between items-center shrink-0"
>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<Icon
icon="ri:alert-fill"
width="28"
class="text-red-500"
width="30"
class="text-red-400"
/>
<h2 class="m-0 text-white text-[28px] font-extrabold">
<h2
class="m-0 text-white text-[28px] lg:text-[32px] 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"
<span class="text-white/60 text-sm lg:text-base 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"
class="bg-white/5 border border-white/10 text-white w-11 h-11 rounded-xl flex items-center justify-center cursor-pointer transition-all duration-200 hover:bg-white/15 hover:rotate-90 hover:border-white/20"
onclick={onclose}
>
<Icon icon="ri:close-line" width="24" />
<Icon icon="ri:close-line" width="28" />
</button>
</div>
<div class="p-10 flex flex-col gap-[30px]">
<!-- Status Badges -->
<div class="flex flex-wrap gap-3">
<div class="p-10 lg:p-12 flex flex-col gap-8">
{#if incident.image_base64}
<div
class="w-full bg-black/20 rounded-3xl overflow-hidden border border-white/10 relative group"
>
<img
src="data:image/jpeg;base64,{incident.image_base64}"
alt="Evidence"
class="w-full max-h-[400px] object-contain bg-black/40"
/>
<div
class="absolute top-4 left-4 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-full flex items-center gap-2 border border-white/10"
>
<Icon
icon="ri:camera-fill"
width="16"
class="text-white/80"
/>
<span
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] font-extrabold tracking-wider
class="text-xs font-bold text-white uppercase tracking-wider"
>Evidence of Greenwashing</span
>
</div>
</div>
{/if}
<div class="flex flex-wrap gap-3.5">
<span
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] lg:text-[12px] font-extrabold tracking-wider backdrop-blur-sm
{incident.analysis?.severity === 'high'
? 'bg-red-500/20 text-red-400 border border-red-500/30'
? 'bg-red-500/25 text-red-300 border border-red-500/40'
: 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"
? 'bg-amber-500/25 text-amber-300 border border-amber-500/40'
: 'bg-emerald-500/25 text-emerald-300 border border-emerald-500/40'} 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"
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] lg:text-[12px] font-extrabold tracking-wider bg-indigo-500/25 text-indigo-300 border border-indigo-500/40 uppercase backdrop-blur-sm"
>
<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"
class="flex items-center gap-2 px-5 py-3 rounded-[14px] text-[11px] lg:text-[12px] font-extrabold tracking-wider bg-white/10 text-white/80 border border-white/20 uppercase backdrop-blur-sm"
>
<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"
<div
class="bg-white/5 backdrop-blur-sm border border-white/10 rounded-[20px] p-7"
>
<Icon icon="ri:scales-3-fill" width="20" />
<h3
class="flex items-center gap-2.5 text-white text-base lg:text-lg font-bold mb-4"
>
<Icon icon="ri:scales-3-fill" width="22" />
Verdict
</h3>
<p
class="text-amber-400 text-[18px] font-semibold m-0 leading-normal"
class="text-amber-300 text-[18px] lg:text-[19px] 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"
<div
class="bg-white/5 backdrop-blur-sm border border-white/10 rounded-[20px] p-7"
>
<Icon icon="ri:file-text-fill" width="20" />
<h3
class="flex items-center gap-2.5 text-white text-base lg:text-lg font-bold mb-4"
>
<Icon icon="ri:file-text-fill" width="22" />
Detailed Analysis
</h3>
<p class="text-white/85 text-[15px] leading-[1.7] m-0">
<p
class="text-white/85 text-[15px] lg:text-base 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"
class="bg-white/5 backdrop-blur-sm border border-white/10 rounded-[20px] p-7"
>
<h3
class="flex items-center gap-[10px] text-red-400 text-base font-bold mb-4"
class="flex items-center gap-2.5 text-red-300 text-base lg:text-lg font-bold mb-4"
>
<Icon icon="ri:flag-fill" width="20" />
<Icon icon="ri:flag-fill" width="22" />
Red Flags Identified
</h3>
<ul class="list-none p-0 m-0 flex flex-col gap-3">
<ul class="list-none p-0 m-0 flex flex-col gap-3.5">
{#each incident.analysis.red_flags as flag}
<li
class="flex items-start gap-3 text-red-300/80 text-sm leading-[1.6]"
class="flex items-start gap-3 text-red-200/80 text-sm lg:text-base leading-[1.6]"
>
<Icon
icon="ri:error-warning-line"
@@ -154,21 +190,21 @@
</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"
class="bg-white/5 backdrop-blur-sm border border-white/10 rounded-[20px] p-7"
>
<h3
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
class="flex items-center gap-2.5 text-white text-base lg:text-lg font-bold mb-4"
>
<Icon icon="ri:chat-quote-fill" width="20" />
<Icon icon="ri:chat-quote-fill" width="22" />
Environmental Claims Made
</h3>
<ul class="list-none p-0 m-0 flex flex-col gap-3">
<ul class="list-none p-0 m-0 flex flex-col gap-3.5">
{#each incident.analysis.key_claims as claim}
<li
class="flex items-start gap-3 text-white/70 text-sm italic leading-[1.6]"
class="flex items-start gap-3 text-white/75 text-sm lg:text-base italic leading-[1.6]"
>
<Icon
icon="ri:double-quotes-l"
@@ -182,36 +218,38 @@
</div>
{/if}
<!-- Recommendations -->
{#if incident.analysis?.recommendations}
<div
class="bg-emerald-500/8 border border-emerald-500/20 rounded-[20px] p-6"
class="bg-emerald-500/12 backdrop-blur-sm border border-emerald-500/30 rounded-[20px] p-7"
>
<h3
class="flex items-center gap-[10px] text-emerald-400 text-base font-bold mb-4"
class="flex items-center gap-2.5 text-emerald-300 text-base lg:text-lg font-bold mb-4"
>
<Icon icon="ri:lightbulb-fill" width="20" />
<Icon icon="ri:lightbulb-fill" width="22" />
Consumer Recommendations
</h3>
<p class="text-emerald-300 text-[15px] leading-[1.6] m-0">
<p
class="text-emerald-200 text-[15px] lg:text-base 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"
class="bg-indigo-500/12 backdrop-blur-sm border border-indigo-500/30 rounded-[20px] p-7"
>
<h3
class="flex items-center gap-[10px] text-indigo-400 text-base font-bold mb-4"
class="flex items-center gap-2.5 text-indigo-300 text-base lg:text-lg font-bold mb-4"
>
<Icon icon="ri:user-voice-fill" width="20" />
<Icon icon="ri:user-voice-fill" width="22" />
Original User Report
</h3>
<p
class="text-indigo-200 text-[15px] italic leading-[1.6] m-0"
class="text-indigo-200 text-[15px] lg:text-base italic leading-[1.6] m-0"
>
"{incident.user_description}"
</p>
@@ -222,7 +260,7 @@
</div>
<style>
/* Custom utility to hide scrollbar if tailwind plugin not present */
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;

View File

@@ -43,52 +43,52 @@
</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"
class="group flex items-center gap-6 lg:gap-8 bg-black/60 backdrop-blur-2xl border border-white/15 rounded-3xl p-7 lg:p-8 w-full text-left cursor-pointer transition-all duration-300 hover:bg-black/70 hover:border-emerald-500/50 hover:-translate-y-1 hover:scale-[1.01] hover:shadow-2xl hover:shadow-black/50 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"
class="w-16 h-16 lg:w-[4.375rem] lg:h-[4.375rem] bg-emerald-500/15 backdrop-blur-sm rounded-[1.125rem] flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-emerald-500 group-hover:-rotate-6 group-hover:scale-110"
>
<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">
<div class="flex items-center gap-3 mb-2.5">
<h3 class="text-white text-xl lg:text-[22px] 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"
class="text-emerald-300 bg-emerald-500/15 backdrop-blur-sm px-3 py-1 rounded-lg text-[12px] lg:text-[13px] 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"
class="text-white/70 text-sm lg:text-base 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>`,
`<span class="text-emerald-300 bg-emerald-500/25 px-1 rounded font-semibold">${match}</span>`,
)}
</p>
{:else}
<p class="text-white/40 text-sm mb-4 m-0">
<p class="text-white/50 text-sm lg:text-base mb-4 m-0">
{report.sector} Sector • Impact Report
</p>
{/if}
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2.5">
<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"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-white/10 backdrop-blur-sm text-white/70 max-w-xs truncate"
title={report.filename}
>
<Icon icon={fileDetails.icon} width="14" />
<Icon icon={fileDetails.icon} width="15" />
{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"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-emerald-500/15 backdrop-blur-sm text-emerald-300"
>
<Icon
icon="ri:checkbox-circle-fill"
@@ -101,18 +101,18 @@
</div>
{#if report.greenwashing_score}
<div class="text-center ml-5">
<div class="text-center ml-5 lg:ml-6">
<div
class="w-[52px] h-[52px] rounded-xe flex items-center justify-center mb-1 rounded-[14px] {getScoreColor(
class="w-14 h-14 lg:w-[3.875rem] lg:h-[3.875rem] rounded-xe flex items-center justify-center mb-2 rounded-2xl shadow-lg transition-transform group-hover:scale-110 {getScoreColor(
report.greenwashing_score,
)}"
>
<span class="text-emerald-950 text-[18px] font-black"
<span class="text-emerald-950 text-[19px] lg:text-[21px] font-black"
>{Math.round(Number(report.greenwashing_score))}</span
>
</div>
<span
class="text-white/40 text-[10px] font-extrabold uppercase tracking-widest"
class="text-white/50 text-[10px] lg:text-[11px] font-extrabold uppercase tracking-widest"
>Trust Score</span
>
</div>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import Icon from "@iconify/svelte";
let {
inputText = $bindable(),
isLoading,
onSend,
} = $props<{
inputText: string;
isLoading: boolean;
onSend: () => void;
}>();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}
</script>
<div
class="p-4 pb-8 md:pb-4 bg-[#051f18] md:bg-white/5 border-t border-[#1f473b] md:border-white/10 flex items-center gap-3 w-full"
>
<input
type="text"
class="flex-1 bg-[#0d2e25] md:bg-white/10 text-white p-3.5 px-5 border border-[#1f473b] md:border-white/10 rounded-full text-[15px] outline-none transition-all duration-200 placeholder-gray-500 focus:border-emerald-400 focus:bg-[#11382e] md:focus:bg-white/15"
placeholder="Ask about sustainability..."
bind:value={inputText}
onkeydown={handleKeyDown}
disabled={isLoading}
/>
<button
class="bg-emerald-500 w-12 h-12 rounded-full flex items-center justify-center text-white shadow-lg transition-all duration-200 hover:scale-105 hover:shadow-emerald-500/50 disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed"
onclick={onSend}
aria-label="Send message"
disabled={isLoading}
>
{#if isLoading}
<Icon icon="ri:loader-4-line" width="24" class="animate-spin" />
{:else}
<Icon icon="ri:send-plane-fill" width="24" />
{/if}
</button>
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { marked } from "marked";
let { text, sender } = $props<{
text: string;
sender: "user" | "ai";
}>();
</script>
<div
class="p-3 px-5 rounded-[20px] max-w-[85%] text-[15px] leading-relaxed transition-all duration-200
{sender === 'user'
? 'self-end bg-gradient-to-br from-emerald-500 to-emerald-600 text-white rounded-br-md shadow-lg shadow-emerald-500/20'
: 'self-start bg-teal-900/40 border border-white/10 text-white rounded-bl-md shadow-sm backdrop-blur-sm'}"
>
<div
class="message-content prose prose-invert prose-p:my-1 prose-headings:my-2 prose-strong:text-white prose-ul:my-1 max-w-none"
>
{@html marked.parse(text)}
</div>
</div>
<style>
:global(.message-content p) {
margin: 0 0 0.5rem 0;
}
:global(.message-content p:last-child) {
margin: 0;
}
:global(.message-content strong) {
font-weight: 700;
color: inherit;
}
:global(.message-content ul),
:global(.message-content ol) {
margin: 0.25rem 0 0.5rem 1.25rem;
padding: 0;
}
:global(.message-content li) {
margin-bottom: 0.25rem;
}
</style>

View File

@@ -0,0 +1,14 @@
<div
class="self-start bg-teal-900/40 border border-white/10 rounded-bl-md rounded-[20px] p-3 px-4 w-fit shadow-sm backdrop-blur-sm"
>
<div class="flex items-center gap-1">
<span
class="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:-0.32s]"
></span>
<span
class="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce [animation-delay:-0.16s]"
></span>
<span class="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce"
></span>
</div>
</div>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { onMount } from "svelte";
let canvasElement = $state<HTMLCanvasElement>();
onMount(() => {
if (!canvasElement) return;
const ctx = canvasElement.getContext("2d");
if (!ctx) return;
let frame = 0;
let animationId: number;
function animate() {
frame++;
if (!ctx || !canvasElement) return;
ctx.clearRect(0, 0, 40, 40);
const yOffset = Math.sin(frame * 0.05) * 3;
ctx.fillStyle = "#e0e0e0";
ctx.beginPath();
ctx.arc(20, 20, 18, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#22c55e";
ctx.beginPath();
ctx.arc(20, 20 + yOffset, 14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#16a34a";
ctx.beginPath();
ctx.moveTo(20, 8 + yOffset);
ctx.quadraticCurveTo(28, 4 + yOffset, 24, 16 + yOffset);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "white";
ctx.fillRect(14, 16 + yOffset, 3, 5);
ctx.fillRect(23, 16 + yOffset, 3, 5);
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(20, 26 + yOffset, 4, 0.2, Math.PI - 0.2);
ctx.stroke();
animationId = requestAnimationFrame(animate);
}
animate();
return () => {
if (animationId) cancelAnimationFrame(animationId);
};
});
</script>
<div class="relative w-10 h-10">
<canvas
bind:this={canvasElement}
width="40"
height="40"
class="w-10 h-10 drop-shadow-[0_4px_12px_rgba(16,185,129,0.4)]"
></canvas>
<div
class="absolute bottom-0.5 right-0.5 w-2.5 h-2.5 bg-emerald-400 border-2 border-[#051f18] rounded-full shadow-[0_0_8px_rgba(52,211,153,0.6)]"
></div>
</div>

View File

@@ -8,13 +8,12 @@
import IncidentCard from "$lib/components/catalogue/IncidentCard.svelte";
import CompanyModal from "$lib/components/catalogue/CompanyModal.svelte";
import IncidentModal from "$lib/components/catalogue/IncidentModal.svelte";
import Pagination from "$lib/components/catalogue/Pagination.svelte";
// View mode toggle
type ViewMode = "company" | "user";
let viewMode = $state<ViewMode>("company");
// Data Types
interface Report {
company_name: string;
year: string | number;
@@ -31,6 +30,8 @@
detected_brand: string;
user_description?: string;
created_at: string;
image_base64?: string;
report_type?: "product" | "company";
analysis: {
verdict: string;
confidence: string;
@@ -48,7 +49,7 @@
let searchQuery = $state("");
let isLoading = $state(false);
// Predefined categories
const categories = [
"All",
"Tech",
@@ -61,7 +62,7 @@
];
let selectedCategory = $state("All");
// Fetching logic
async function fetchReports() {
isLoading = true;
try {
@@ -93,7 +94,7 @@
fetchIncidents();
});
// Search
async function handleSearch() {
if (!searchQuery.trim()) {
fetchReports();
@@ -124,7 +125,7 @@
debounceTimer = setTimeout(handleSearch, 600);
}
// Pagination & Filtering
let currentPage = $state(1);
const itemsPerPage = 10;
@@ -154,11 +155,10 @@
function goToPage(page: number) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
window.scrollTo({ top: 0, behavior: "smooth" });
}
}
// Modals
let selectedReport = $state<Report | null>(null);
let selectedIncident = $state<Incident | null>(null);
</script>
@@ -172,7 +172,7 @@
</svelte:head>
<div class="relative w-full min-h-screen overflow-x-hidden">
<!-- Modals -->
{#if selectedReport}
<CompanyModal
report={selectedReport}
@@ -193,7 +193,7 @@
</div>
<div
class="relative z-10 px-6 pt-[100px] pb-[120px] max-w-[1000px] mx-auto"
class="relative z-10 px-6 sm:px-8 lg:px-12 pt-16 pb-35 max-w-275 mx-auto"
>
<CatalogueHeader
bind:viewMode
@@ -201,30 +201,35 @@
bind:selectedCategory
{categories}
{onSearchInput}
{currentPage}
{totalPages}
{goToPage}
/>
{#if isLoading}
<div class="flex flex-col items-center justify-center py-20 gap-4">
<div
class="flex flex-col items-center justify-center py-24 gap-5 bg-black/60 backdrop-blur-2xl rounded-3xl border border-white/15 shadow-2xl shadow-black/50 mt-8"
>
<Icon
icon="eos-icons:loading"
width="40"
width="48"
class="text-emerald-400"
/>
<p class="text-white/60 font-medium">
<p class="text-white/70 font-medium text-lg">
Syncing with database...
</p>
</div>
{:else if viewMode === "company"}
{#if filteredReports.length === 0}
<div
class="bg-black/40 backdrop-blur-md rounded-3xl p-12 text-center border border-white/10"
class="bg-black/60 backdrop-blur-2xl rounded-3xl p-16 text-center border border-white/15 shadow-2xl shadow-black/50 mt-8"
>
<p class="text-white/60 text-lg">
<p class="text-white/70 text-lg font-medium">
No reports found matching your criteria.
</p>
</div>
{:else}
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-6 mt-8">
{#each paginatedReports as report}
<ReportCard
{report}
@@ -233,24 +238,25 @@
/>
{/each}
</div>
<Pagination bind:currentPage {totalPages} {goToPage} />
{/if}
{:else if incidents.length === 0}
<div
class="bg-black/40 backdrop-blur-md rounded-3xl p-20 text-center border border-white/10 flex flex-col items-center gap-4"
class="bg-black/60 backdrop-blur-2xl rounded-3xl p-20 text-center border border-white/15 shadow-2xl shadow-black/50 flex flex-col items-center gap-5 mt-8"
>
<Icon
icon="ri:file-warning-line"
width="48"
width="56"
class="text-white/30"
/>
<p class="text-white/60 text-lg">No user reports yet.</p>
<p class="text-white/40 text-sm">
<p class="text-white/70 text-lg font-medium">
No user reports yet.
</p>
<p class="text-white/50 text-sm">
Be the first to report greenwashing!
</p>
</div>
{:else}
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-6 mt-8">
{#each incidents as incident}
<IncidentCard
{incident}
@@ -264,7 +270,7 @@
<style>
:global(body) {
background-color: #051010;
background-color: #0c0c0c;
color: white;
}
</style>

View File

@@ -1,10 +1,18 @@
<script lang="ts">
import { onMount } from "svelte";
import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte";
import Icon from "@iconify/svelte";
import { marked } from "marked";
import ChatMessage from "$lib/components/chat/ChatMessage.svelte";
import ChatInput from "$lib/components/chat/ChatInput.svelte";
import Mascot from "$lib/components/chat/Mascot.svelte";
import LoadingBubble from "$lib/components/chat/LoadingBubble.svelte";
let messages = $state([
type Message = {
id: number;
text: string;
sender: "user" | "ai";
};
let messages = $state<Message[]>([
{
id: 1,
text: "Hello! I'm Ethix AI. Ask me anything about recycling, sustainability, or green products.",
@@ -12,9 +20,8 @@
},
]);
let inputText = $state("");
let canvasElement = $state<HTMLCanvasElement>();
let isLoading = $state(false);
let chatWindowFn: HTMLDivElement | undefined = $state();
let chatWindowFn = $state<HTMLDivElement>();
function scrollToBottom() {
if (chatWindowFn) {
@@ -25,7 +32,7 @@
}
$effect(() => {
// Dependencies to trigger scroll
messages;
isLoading;
scrollToBottom();
@@ -38,7 +45,7 @@
const userMsg = {
id: Date.now(),
text: userText,
sender: "user",
sender: "user" as const,
};
messages = [...messages, userMsg];
inputText = "";
@@ -49,12 +56,8 @@
"http://localhost:5000/api/gemini/ask",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: userText,
}),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: userText }),
},
);
@@ -64,7 +67,7 @@
const aiMsg = {
id: Date.now() + 1,
text: data.reply,
sender: "ai",
sender: "ai" as const,
};
messages = [...messages, aiMsg];
} else {
@@ -74,7 +77,7 @@
const errorMsg = {
id: Date.now() + 1,
text: "Sorry, I'm having trouble connecting to my brain right now. Please try again later.",
sender: "ai",
sender: "ai" as const,
};
messages = [...messages, errorMsg];
console.error("Chat Error:", error);
@@ -82,63 +85,6 @@
isLoading = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
onMount(() => {
if (!canvasElement) return;
const ctx = canvasElement.getContext("2d");
if (!ctx) return;
let frame = 0;
function animate() {
frame++;
if (!ctx || !canvasElement) return;
ctx.clearRect(0, 0, 40, 40);
const yOffset = Math.sin(frame * 0.05) * 3; // Reduced amplitude
// Head
ctx.fillStyle = "#e0e0e0";
ctx.beginPath();
ctx.arc(20, 20, 18, 0, Math.PI * 2); // Center at 20,20, Radius 18
ctx.fill();
// Face/Visor
ctx.fillStyle = "#22c55e";
ctx.beginPath();
ctx.arc(20, 20 + yOffset, 14, 0, Math.PI * 2); // Center 20,20
ctx.fill();
// Reflection/Detail
ctx.fillStyle = "#16a34a";
ctx.beginPath();
ctx.moveTo(20, 8 + yOffset);
ctx.quadraticCurveTo(28, 4 + yOffset, 24, 16 + yOffset);
ctx.closePath();
ctx.fill();
// Eyes
ctx.fillStyle = "white";
ctx.fillRect(14, 16 + yOffset, 3, 5);
ctx.fillRect(23, 16 + yOffset, 3, 5);
// Smile
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(20, 26 + yOffset, 4, 0.2, Math.PI - 0.2);
ctx.stroke();
requestAnimationFrame(animate);
}
animate();
});
</script>
<svelte:head>
@@ -149,426 +95,68 @@
/>
</svelte:head>
<div class="page-wrapper">
<div class="desktop-bg">
<div class="fixed inset-0 w-full h-full overflow-hidden bg-[#0c0c0c]">
<div class="hidden md:block absolute inset-0 pointer-events-none">
<ParallaxLandscape />
</div>
<div class="chat-container">
<div class="chat-card">
<div class="header">
<div class="mascot-container">
<canvas
bind:this={canvasElement}
width="40"
height="40"
class="mascot-canvas"
></canvas>
<div class="mascot-status-dot"></div>
</div>
<div class="header-text-center">
<h1 class="page-title">Ethix Assistant</h1>
<div class="powered-by">
<div
class="relative z-10 max-w-6xl mx-auto h-full flex flex-col pt-24 pb-6 px-0 md:px-6 md:pt-20 md:pb-10"
>
<div
class="flex flex-col h-full bg-[#0d2e25] md:bg-black/40 md:backdrop-blur-xl border-x md:border border-[#1f473b] md:border-white/10 md:rounded-[32px] overflow-hidden shadow-2xl"
>
<div
class="p-3 px-5 border-b border-[#1f473b] md:border-white/10 bg-[#051f18] md:bg-transparent flex items-center gap-3 shrink-0"
>
<Mascot />
<div class="flex flex-col items-start gap-0.5">
<h1
class="text-white text-base font-bold leading-tight m-0"
>
Ethix Assistant
</h1>
<div
class="flex items-center gap-1 text-[10px] font-semibold text-emerald-400 tracking-wide bg-emerald-500/10 px-2 py-0.5 rounded-xl border border-emerald-500/20"
>
<Icon icon="ri:shining-fill" width="10" />
<span>Powered by Gemini</span>
</div>
</div>
</div>
<div class="chat-window">
<div class="messages-container" bind:this={chatWindowFn}>
{#each messages as msg (msg.id)}
<div
class="message"
class:user-message={msg.sender === "user"}
class:ai-message={msg.sender === "ai"}
class="flex-1 flex flex-col overflow-hidden bg-[#0d2e25] md:bg-transparent"
>
<div class="message-content">
{@html marked.parse(msg.text)}
</div>
</div>
<div
class="flex-1 overflow-y-auto p-6 pb-5 flex flex-col gap-4 scroll-smooth scrollbar-none"
bind:this={chatWindowFn}
>
{#each messages as msg (msg.id)}
<ChatMessage text={msg.text} sender={msg.sender} />
{/each}
{#if isLoading}
<div class="message ai-message loading-bubble">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
<LoadingBubble />
{/if}
</div>
<div class="input-container">
<input
type="text"
class="message-input"
placeholder="Ask about sustainability..."
bind:value={inputText}
onkeydown={handleKeyDown}
/>
<button
class="send-button"
onclick={sendMessage}
aria-label="Send message"
>
<Icon icon="ri:send-plane-fill" width="24" />
</button>
</div>
<ChatInput bind:inputText {isLoading} onSend={sendMessage} />
</div>
</div>
</div>
</div>
<style>
.page-wrapper {
width: 100%;
height: 100vh;
position: relative;
overflow: hidden;
}
.desktop-bg {
.scrollbar-none::-webkit-scrollbar {
display: none;
}
.chat-container {
position: relative;
z-index: 10;
padding: 100px 24px 40px;
max-width: 800px;
margin: 0 auto;
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.chat-card {
background: #0d2e25;
border: 1px solid #1f473b;
border-radius: 32px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.header {
padding: 12px 20px;
border-bottom: 1px solid #1f473b;
background: #051f18;
display: flex;
flex-direction: row; /* Horizontal Layout for Compactness */
align-items: center;
justify-content: flex-start;
gap: 12px;
}
.header-text-center {
display: flex;
flex-direction: column;
align-items: flex-start; /* Left Align Text */
gap: 2px;
}
.mascot-container {
position: relative;
width: 40px;
height: 40px;
}
.mascot-canvas {
width: 40px;
height: 40px;
filter: drop-shadow(0 4px 12px rgba(16, 185, 129, 0.4));
}
.mascot-status-dot {
position: absolute;
bottom: 2px;
right: 2px;
width: 10px;
height: 10px;
background: #34d399;
border: 2px solid #051f18;
border-radius: 50%;
box-shadow: 0 0 8px rgba(52, 211, 153, 0.6);
}
.page-title {
color: white;
font-size: 16px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.powered-by {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
color: #34d399;
letter-spacing: 0.5px;
background: rgba(34, 197, 94, 0.1);
padding: 2px 8px;
border-radius: 12px;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.chat-window {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #0d2e25;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
padding-bottom: 20px;
display: flex;
flex-direction: column;
gap: 16px;
scroll-behavior: smooth;
}
.message {
padding: 12px 18px;
border-radius: 20px;
max-width: 85%;
font-size: 15px;
line-height: 1.5;
}
/* Message Content Markdown Styles */
.message-content :global(p) {
margin: 0 0 8px 0;
}
.message-content :global(p:last-child) {
margin: 0;
}
.message-content :global(strong) {
font-weight: 700;
color: inherit;
}
.message-content :global(ul),
.message-content :global(ol) {
margin: 4px 0 8px 20px;
padding: 0;
}
.message-content :global(li) {
margin-bottom: 4px;
}
.message-content :global(h1),
.message-content :global(h2),
.message-content :global(h3) {
font-weight: 700;
font-size: 1.1em;
margin: 8px 0 4px 0;
}
.user-message {
align-self: flex-end;
background: linear-gradient(135deg, #10b981, #059669);
border-bottom-right-radius: 6px;
color: white;
font-weight: 500;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
}
.ai-message {
align-self: flex-start;
background: #134e4a; /* Teal-900 for better visibility */
border-bottom-left-radius: 6px;
color: white; /* White text for contrast */
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Loading Bubble */
.loading-bubble {
padding: 12px 16px;
width: fit-content;
}
.typing-indicator {
display: flex;
align-items: center;
gap: 4px;
}
.typing-indicator span {
width: 6px;
height: 6px;
background-color: #34d399;
border-radius: 50%;
display: inline-block;
animation: bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) {
animation-delay: -0.32s;
}
.typing-indicator span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
.input-container {
padding: 16px 20px;
padding-bottom: 30px;
background: #051f18;
border-top: 1px solid #1f473b;
display: flex;
align-items: center;
gap: 12px;
}
.message-input {
flex: 1;
background: #0d2e25;
color: white;
padding: 14px 20px;
border: 1px solid #1f473b;
border-radius: 50px;
font-size: 15px;
outline: none;
transition: all 0.2s;
}
.message-input:focus {
border-color: #34d399;
background: #11382e;
}
.message-input::placeholder {
color: #6b7280;
}
.send-button {
background: #10b981;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
color: white;
}
.send-button:hover {
transform: scale(1.08);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.5);
}
@media (min-width: 768px) {
.desktop-bg {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.chat-container {
padding-top: 100px;
height: auto;
max-width: 1200px;
}
.chat-card {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 32px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
height: 85vh;
max-height: 900px;
}
.header {
background: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.chat-window {
background: transparent;
}
.ai-message {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.input-container {
background: rgba(255, 255, 255, 0.05);
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 16px;
}
.message-input {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.message-input:focus {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(34, 197, 94, 0.5);
}
}
@media (max-width: 767px) {
.chat-container {
padding: 0;
max-width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.chat-card {
height: 100%;
max-height: none;
border-radius: 0;
border: none;
}
.header {
padding: 12px 16px;
padding-top: 50px; /* Safe area for some mobile devices */
}
.messages-container {
padding-bottom: 20px;
}
.input-container {
padding-bottom: 120px;
}
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>

View File

@@ -30,7 +30,11 @@
{#if item}
<div class="safe-area">
<div class="header">
<button class="back-button" on:click={() => window.history.back()}>
<button
class="back-button"
onclick={() => window.history.back()}
aria-label="Go back"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 320 512"
@@ -65,7 +69,9 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill={item.impact === "High" ? "#ef4444" : "#22c55e"}
fill={item.impact === "High"
? "#ef4444"
: "#22c55e"}
class="alert-icon"
>
<path
@@ -76,17 +82,21 @@
<h3>Analysis Result</h3>
<p>
{#if item.impact === "High"}
This item takes 450+ years to decompose. Consider switching
to sustainable alternatives immediately.
This item takes 450+ years to decompose.
Consider switching to sustainable
alternatives immediately.
{:else}
This item is eco-friendly or easily recyclable. Good choice!
This item is eco-friendly or easily
recyclable. Good choice!
{/if}
</p>
</div>
</div>
<div class="alternatives-section">
<h3 class="alternatives-title">Recommended Alternatives</h3>
<h3 class="alternatives-title">
Recommended Alternatives
</h3>
<div class="alternatives-scroll">
<div class="alternative-card glass">
<div class="alt-header">
@@ -142,7 +152,10 @@
</div>
{#if item.impact !== "Low"}
<button class="report-button" on:click={navigateToReport}>
<button
class="report-button"
onclick={navigateToReport}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 448 512"

View File

@@ -4,6 +4,9 @@
let productName = $state("");
let description = $state("");
let image = $state<string | null>(null);
let reportType = $state<"product" | "company">("product");
let pdfData = $state<string | null>(null);
let pdfName = $state<string | null>(null);
let submitted = $state(false);
let isLoading = $state(false);
let analysisResult = $state<any>(null);
@@ -40,6 +43,25 @@
input.click();
}
async function pickPdf() {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/pdf";
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
pdfName = file.name;
const reader = new FileReader();
reader.onload = (event) => {
pdfData = event.target?.result as string;
};
reader.readAsDataURL(file);
}
};
input.click();
}
const progressSteps = [
{ id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" },
{
@@ -65,7 +87,7 @@
error = null;
currentStep = 1;
// Simulate progress steps
const stepInterval = setInterval(() => {
if (currentStep < progressSteps.length) {
currentStep++;
@@ -83,18 +105,20 @@
body: JSON.stringify({
product_name: productName,
description: description,
image: image, // Base64 encoded
report_type: reportType,
image: reportType === "product" ? image : null,
pdf_data: reportType === "company" ? pdfData : null,
}),
},
);
clearInterval(stepInterval);
currentStep = progressSteps.length; // Complete all steps
currentStep = progressSteps.length;
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;
@@ -200,10 +224,28 @@
<div class="glass-card form-card">
<div class="form-content">
<div class="form-group">
<label class="label" for="productName"
>Product Name</label
<div class="report-type-toggle">
<button
class="toggle-option"
class:active={reportType === "product"}
onclick={() => (reportType = "product")}
>
Product Incident
</button>
<button
class="toggle-option"
class:active={reportType === "company"}
onclick={() => (reportType = "company")}
>
Company Report
</button>
</div>
<div class="form-group">
<label class="label" for="productName">
{reportType === "product"
? "Product Name"
: "Company Name"}
</label>
<div class="input-wrapper">
<iconify-icon
icon="ri:price-tag-3-line"
@@ -213,7 +255,9 @@
type="text"
id="productName"
class="input"
placeholder="e.g. 'Eco-Friendly' Water Bottle"
placeholder={reportType === "product"
? "e.g. 'Eco-Friendly' Water Bottle"
: "e.g. Acme Corp"}
bind:value={productName}
/>
</div>
@@ -238,7 +282,12 @@
</div>
<div class="form-group">
<span class="label">Evidence (Photo)</span>
<span class="label">
{reportType === "product"
? "Evidence (Photo)"
: "Company Report (PDF)"}
</span>
{#if reportType === "product"}
<button
class="image-picker"
onclick={pickImage}
@@ -267,6 +316,36 @@
</div>
{/if}
</button>
{:else}
<button
class="image-picker pdf-picker"
onclick={pickPdf}
type="button"
>
{#if pdfData}
<div class="picker-placeholder active-pdf">
<iconify-icon
icon="ri:file-pdf-2-fill"
width="48"
style="color: #ef4444;"
></iconify-icon>
<p class="pdf-name">{pdfName}</p>
<span class="change-text"
>Click to change</span
>
</div>
{:else}
<div class="picker-placeholder">
<iconify-icon
icon="ri:file-upload-line"
width="32"
style="color: rgba(255,255,255,0.4);"
></iconify-icon>
<p>Upload PDF Report</p>
</div>
{/if}
</button>
{/if}
</div>
{#if error}
@@ -410,6 +489,51 @@
gap: 16px;
}
.report-type-toggle {
display: flex;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.toggle-option {
flex: 1;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: 10px;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-option:hover {
color: white;
background: rgba(255, 255, 255, 0.05);
}
.toggle-option.active {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.pdf-name {
color: white !important;
font-size: 14px;
text-align: center;
padding: 0 20px;
word-break: break-all;
}
.change-text {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
margin-top: 4px;
}
.form-group {
display: flex;
flex-direction: column;