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 FROM python:3.9-slim
# Set working directory inside the container
WORKDIR /app WORKDIR /app
# Copy requirements first (for better caching)
COPY requirements.txt . 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 --no-cache-dir -r requirements.txt
RUN pip install gunicorn RUN pip install gunicorn
# Copy the rest of the application
COPY . . COPY . .
# Expose the internal port (Gunicorn default is 8000, or we choose one)
EXPOSE 5000 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"] CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:5000", "app:app"]

View File

@@ -1,7 +1,7 @@
flask flask
gunicorn gunicorn
ultralytics ultralytics
opencv-python opencv-python-headless
transformers transformers
torch torch
pandas 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 ): def get_all_metadatas (collection_name =COLLECTION_NAME ,limit =None ):
collection =get_collection (collection_name ) collection =get_collection (collection_name )
# Only fetch metadatas to be lightweight
if limit : if limit :
results =collection .get (include =["metadatas"],limit =limit ) results =collection .get (include =["metadatas"],limit =limit )
else : else :

View File

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

View File

@@ -23,22 +23,22 @@ Based on the context provided, give a final verdict:
def ask (prompt ): def ask (prompt ):
client =genai .Client (api_key =os .environ .get ("GOOGLE_API_KEY")) client =genai .Client (api_key =os .environ .get ("GOOGLE_API_KEY"))
return client.models.generate_content(model="gemini-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 ): def ask_gemini_with_rag (prompt ,category =None ):
"""Ask Gemini with RAG context from the vector database.""" """Ask Gemini with RAG context from the vector database."""
# Get embedding for the prompt
query_embedding =get_embedding (prompt ) query_embedding =get_embedding (prompt )
# Search for relevant documents
results =search_documents (query_embedding ,num_results =5 ) results =search_documents (query_embedding ,num_results =5 )
# Build context from results
context ="" context =""
for res in results : for res in results :
context +=f"--- Document ---\n{res ['text']}\n\n" 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. 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. 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 argparse
import json import json
import sys import sys

View File

@@ -4,6 +4,8 @@ Uses structured outputs with Pydantic for reliable JSON responses
""" """
import base64 import base64
import os import os
import cv2
import numpy as np
from datetime import datetime from datetime import datetime
from flask import Blueprint ,request ,jsonify from flask import Blueprint ,request ,jsonify
from google import genai from google import genai
@@ -17,7 +19,7 @@ from src.mongo.connection import get_mongo_client
incidents_bp =Blueprint ('incidents',__name__ ) incidents_bp =Blueprint ('incidents',__name__ )
# Initialize detector lazily
_detector =None _detector =None
def get_detector (): def get_detector ():
@@ -27,7 +29,52 @@ def get_detector():
return _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 ): class GreenwashingAnalysis (BaseModel ):
"""Structured output for greenwashing analysis""" """Structured output for greenwashing analysis"""
@@ -58,7 +105,7 @@ class ImageAnalysis(BaseModel):
packaging_description :str =Field (description ="Description of the product packaging and design") 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. 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 ) client =genai .Client (api_key =api_key )
# Use structured output with Pydantic schema
response =client .models .generate_content ( response =client .models .generate_content (
model="gemini-3-flash-preview", model ="gemini-3-pro-preview",
contents =prompt , contents =prompt ,
config ={ config ={
"response_mime_type":"application/json", "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 ) analysis =GreenwashingAnalysis .model_validate_json (response .text )
return analysis return analysis
@@ -142,17 +189,17 @@ Respond with structured JSON matching the schema provided."""
options ={'temperature':0.1 } options ={'temperature':0.1 }
) )
# Validate and parse
analysis =ImageAnalysis .model_validate_json (response ['message']['content']) analysis =ImageAnalysis .model_validate_json (response ['message']['content'])
return analysis return analysis
except Exception as e : except Exception as e :
print (f"Ollama structured analysis failed: {e }") print (f"Ollama structured analysis failed: {e }")
# Fall back to basic detection
detector =get_detector () detector =get_detector ()
result =detector .detect_from_bytes (image_bytes ) result =detector .detect_from_bytes (image_bytes )
# Convert to structured format
logos =[] logos =[]
for logo in result .get ('logos_detected',[]): for logo in result .get ('logos_detected',[]):
logos .append (LogoDetection ( 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 ): 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'] 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',[])) 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',[])) 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 } text =f"""GREENWASHING INCIDENT REPORT #{incident_id }
Date: {incident_data['created_at']} Report Date: {incident_data ['created_at']}
Company/Product: {incident_data['product_name']} ({incident_data.get('detected_brand', 'Unknown brand')}) 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']} Greenwashing Detected: {'YES'if analysis ['is_greenwashing']else 'NO'}
Confidence: {analysis['confidence']} Confidence Level: {analysis ['confidence']}
Severity: {analysis['severity']} 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']} {analysis ['reasoning']}
KEY ENVIRONMENTAL CLAIMS MADE: === KEY MARKETING CLAIMS ===
{key_claims} {key_claims if key_claims else 'No key claims identified'}
RED FLAGS IDENTIFIED: === RED FLAGS IDENTIFIED ===
{red_flags} {red_flags if red_flags else 'No specific red flags identified'}
CONSUMER RECOMMENDATIONS: === CONSUMER RECOMMENDATIONS ===
{analysis ['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 ) embedding =get_embedding (text )
# Store in ChromaDB with metadata
metadata ={ metadata ={
"type":"incident_report", "type":"incident_report",
"source":f"incident_{incident_id }", "source":f"incident_{incident_id }",
@@ -224,7 +293,11 @@ CONSUMER RECOMMENDATIONS:
"severity":analysis ['severity'], "severity":analysis ['severity'],
"confidence":analysis ['confidence'], "confidence":analysis ['confidence'],
"is_greenwashing":True , "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 ( insert_documents (
@@ -233,8 +306,10 @@ CONSUMER RECOMMENDATIONS:
metadata_list =[metadata ] metadata_list =[metadata ]
) )
print (f"✓ Incident #{incident_id } saved to ChromaDB for AI chat context")
# ============= API Endpoints =============
@incidents_bp .route ('/submit',methods =['POST']) @incidents_bp .route ('/submit',methods =['POST'])
def submit_incident (): def submit_incident ():
@@ -244,7 +319,9 @@ def submit_incident():
Expects JSON with: Expects JSON with:
- product_name: Name of the product/company - product_name: Name of the product/company
- description: User's description of the misleading claim - 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 data =request .json
@@ -253,7 +330,8 @@ def submit_incident():
product_name =data .get ('product_name','').strip () product_name =data .get ('product_name','').strip ()
user_description =data .get ('description','').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 : if not product_name :
return jsonify ({"error":"Product name is required"}),400 return jsonify ({"error":"Product name is required"}),400
@@ -262,20 +340,25 @@ def submit_incident():
return jsonify ({"error":"Description is required"}),400 return jsonify ({"error":"Description is required"}),400
try : try :
# Step 1: Analyze image with Ollama (structured output)
detected_brand ="Unknown" detected_brand ="Unknown"
image_description ="No image provided" image_description ="No image provided"
environmental_claims =[] environmental_claims =[]
compressed_image_base64 =None
if image_base64: if report_type =='product'and image_base64 :
try : try :
# Remove data URL prefix if present
if ','in image_base64 : if ','in image_base64 :
image_base64 =image_base64 .split (',')[1 ] image_base64 =image_base64 .split (',')[1 ]
image_bytes =base64 .b64decode (image_base64 ) 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 ) image_analysis =analyze_image_with_ollama (image_bytes )
if image_analysis .logos_detected : if image_analysis .logos_detected :
@@ -285,10 +368,11 @@ def submit_incident():
environmental_claims =image_analysis .environmental_claims environmental_claims =image_analysis .environmental_claims
except Exception as e : except Exception as e :
print(f"Image analysis error: {e}") print (f"Image processing 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" search_query =f"{product_name } {detected_brand } environmental claims sustainability greenwashing"
query_embedding =get_embedding (search_query ) query_embedding =get_embedding (search_query )
search_results =search_documents (query_embedding ,num_results =5 ) search_results =search_documents (query_embedding ,num_results =5 )
@@ -300,12 +384,12 @@ def submit_incident():
if not context : if not context :
context ="No prior information found about this company in our database." context ="No prior information found about this company in our database."
# Add environmental claims from image to context
if environmental_claims : if environmental_claims :
context +="\n--- Claims visible in submitted image ---\n" context +="\n--- Claims visible in submitted image ---\n"
context +="\n".join (f"- {claim }"for claim in environmental_claims ) context +="\n".join (f"- {claim }"for claim in environmental_claims )
# Step 3: Analyze with Gemini (structured output)
analysis =analyze_with_gemini ( analysis =analyze_with_gemini (
product_name =product_name , product_name =product_name ,
user_description =user_description , user_description =user_description ,
@@ -314,10 +398,10 @@ def submit_incident():
context =context context =context
) )
# Convert Pydantic model to dict
analysis_dict =analysis .model_dump () analysis_dict =analysis .model_dump ()
# Step 4: Prepare incident data
incident_data ={ incident_data ={
"product_name":product_name , "product_name":product_name ,
"user_description":user_description , "user_description":user_description ,
@@ -327,17 +411,22 @@ def submit_incident():
"analysis":analysis_dict , "analysis":analysis_dict ,
"is_greenwashing":analysis .is_greenwashing , "is_greenwashing":analysis .is_greenwashing ,
"created_at":datetime .utcnow ().isoformat (), "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 incident_id =None
# Step 5: If greenwashing detected, save to databases
if analysis .is_greenwashing : if analysis .is_greenwashing :
# Save to MongoDB
incident_id =save_to_mongodb (incident_data ) incident_id =save_to_mongodb (incident_data )
# Save to ChromaDB for chatbot context
save_to_chromadb (incident_data ,incident_id ) save_to_chromadb (incident_data ,incident_id )
return jsonify ({ return jsonify ({
@@ -366,14 +455,15 @@ def list_incidents():
db =client ["ethix"] db =client ["ethix"]
collection =db ["incidents"] collection =db ["incidents"]
# Get recent incidents with full analysis details
incidents =list (collection .find ( incidents =list (collection .find (
{"is_greenwashing":True }, {"is_greenwashing":True },
{"_id":1 ,"product_name":1 ,"detected_brand":1 , {"_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 )) ).sort ("created_at",-1 ).limit (50 ))
# Convert ObjectId to string
for inc in incidents : for inc in incidents :
inc ["_id"]=str (inc ["_id"]) inc ["_id"]=str (inc ["_id"])

View File

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

View File

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

View File

@@ -9,14 +9,14 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// Enable gzip compression
app.use(compression()); app.use(compression());
// Serve static files from the build directory (one level up from server folder)
const buildPath = path.join(__dirname, '../build'); const buildPath = path.join(__dirname, '../build');
app.use(express.static(buildPath)); app.use(express.static(buildPath));
// Handle SPA routing: serve index.html for any unknown routes
app.get(/.*/, (req, res) => { app.get(/.*/, (req, res) => {
res.sendFile(path.join(buildPath, 'index.html')); 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] #[tauri::command]
fn greet(name: &str) -> String { fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name) 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")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import Pagination from "./Pagination.svelte";
let { let {
viewMode = $bindable(), viewMode = $bindable(),
@@ -7,24 +8,32 @@
selectedCategory = $bindable(), selectedCategory = $bindable(),
categories, categories,
onSearchInput, onSearchInput,
currentPage,
totalPages,
goToPage,
}: { }: {
viewMode: "company" | "user"; viewMode: "company" | "user";
searchQuery: string; searchQuery: string;
selectedCategory: string; selectedCategory: string;
categories: string[]; categories: string[];
onSearchInput: () => void; onSearchInput: () => void;
currentPage: number;
totalPages: number;
goToPage: (page: number) => void;
} = $props(); } = $props();
</script> </script>
<div <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 Sustainability Database
</h1> </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"} {#if viewMode === "company"}
Search within verified company reports and impact assessments Search within verified company reports and impact assessments
{:else} {:else}
@@ -33,35 +42,35 @@
</p> </p>
</div> </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 <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' 'company'
? 'text-emerald-400' ? 'text-emerald-400'
: 'text-white/40'}" : 'text-white/50'}"
> >
<Icon icon="ri:building-2-line" width="16" /> <Icon icon="ri:building-2-line" width="16" />
Company Company
</span> </span>
<button <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={() => onclick={() =>
(viewMode = viewMode === "company" ? "user" : "company")} (viewMode = viewMode === "company" ? "user" : "company")}
aria-label="Toggle between company and user reports" aria-label="Toggle between company and user reports"
> >
<span <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' 'user'
? 'left-[calc(100%-25px)]' ? 'left-[calc(100%-28px)]'
: 'left-[3px]'}" : 'left-1'}"
></span> ></span>
</button> </button>
<span <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' 'user'
? 'text-emerald-400' ? 'text-emerald-400'
: 'text-white/40'}" : 'text-white/50'}"
> >
<Icon icon="ri:user-voice-line" width="16" /> <Icon icon="ri:user-voice-line" width="16" />
User Reports User Reports
@@ -69,33 +78,35 @@
</div> </div>
{#if viewMode === "company"} {#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 <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> </div>
<input <input
type="text" 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')..." placeholder="Search for companies, topics (e.g., 'emissions')..."
bind:value={searchQuery} bind:value={searchQuery}
oninput={onSearchInput} oninput={onSearchInput}
/> />
</div> </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} {#each categories as category}
<button <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 category
? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_15px_rgba(34,197,94,0.3)]' ? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_20px_rgba(34,197,94,0.4)]'
: 'bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:text-white hover:-translate-y-0.5'}" : '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)} onclick={() => (selectedCategory = category)}
> >
{category} {category}
</button> </button>
{/each} {/each}
</div> </div>
<Pagination {currentPage} {totalPages} {goToPage} />
{/if} {/if}
</div> </div>

View File

@@ -25,12 +25,13 @@
aria-label="Close modal" aria-label="Close modal"
> >
<div <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()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
transition:scale={{ duration: 300, start: 0.95 }} transition:scale={{ duration: 300, start: 0.95 }}
role="document" role="dialog"
tabindex="0" aria-modal="true"
tabindex="-1"
> >
<div <div
class="px-6 py-5 bg-slate-800 border-b border-slate-700 flex justify-between items-center shrink-0" 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; detected_brand: string;
user_description?: string; user_description?: string;
created_at: string; created_at: string;
image_base64?: string;
analysis: { analysis: {
verdict: string; verdict: string;
confidence: string; confidence: string;
@@ -21,66 +22,82 @@
</script> </script>
<button <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} {onclick}
> >
{#if incident.image_base64}
<div <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' {incident.analysis?.severity === 'high'
? 'bg-red-500/20 text-red-500' ? 'bg-red-500/20 text-red-500'
: 'bg-amber-500/20 text-amber-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> </div>
{/if}
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1.5"> <div class="flex items-center gap-3 mb-2">
<h3 class="text-white text-[18px] font-bold m-0 italic"> <h3
class="text-white text-[19px] lg:text-[20px] font-bold m-0 italic"
>
{incident.product_name} {incident.product_name}
</h3> </h3>
{#if incident.detected_brand && incident.detected_brand !== "Unknown"} {#if incident.detected_brand && incident.detected_brand !== "Unknown"}
<span <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 >{incident.detected_brand}</span
> >
{/if} {/if}
</div> </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"} {incident.analysis?.verdict || "Greenwashing detected"}
</p> </p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2.5">
<span <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' {incident.analysis?.severity === 'high'
? 'bg-red-500/20 text-red-400' ? 'bg-red-500/20 text-red-400'
: incident.analysis?.severity === 'medium' : incident.analysis?.severity === 'medium'
? 'bg-amber-500/20 text-amber-400' ? 'bg-amber-500/20 text-amber-400'
: 'bg-emerald-500/20 text-emerald-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 {incident.analysis?.severity || "unknown"} severity
</span> </span>
<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 {incident.analysis?.confidence || "unknown"} confidence
</span> </span>
<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()} {new Date(incident.created_at).toLocaleDateString()}
</span> </span>
</div> </div>
</div> </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> <span>Confirmed</span>
</div> </div>
</button> </button>

View File

@@ -8,6 +8,7 @@
detected_brand: string; detected_brand: string;
user_description?: string; user_description?: string;
created_at: string; created_at: string;
image_base64?: string;
analysis: { analysis: {
verdict: string; verdict: string;
confidence: string; confidence: string;
@@ -25,7 +26,7 @@
</script> </script>
<div <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} onclick={onclose}
onkeydown={(e) => e.key === "Escape" && onclose()} onkeydown={(e) => e.key === "Escape" && onclose()}
transition:fade={{ duration: 200 }} transition:fade={{ duration: 200 }}
@@ -33,8 +34,10 @@
tabindex="0" tabindex="0"
aria-label="Close modal" aria-label="Close modal"
> >
<div <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()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
transition:scale={{ duration: 300, start: 0.95 }} transition:scale={{ duration: 300, start: 0.95 }}
@@ -42,105 +45,138 @@
tabindex="0" tabindex="0"
> >
<div <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"> <div class="flex items-center gap-3">
<Icon <Icon
icon="ri:alert-fill" icon="ri:alert-fill"
width="28" width="30"
class="text-red-500" 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} {incident.product_name}
</h2> </h2>
</div> </div>
{#if incident.detected_brand && incident.detected_brand !== "Unknown"} {#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 >Brand: {incident.detected_brand}</span
> >
{/if} {/if}
</div> </div>
<button <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} onclick={onclose}
> >
<Icon icon="ri:close-line" width="24" /> <Icon icon="ri:close-line" width="28" />
</button> </button>
</div> </div>
<div class="p-10 flex flex-col gap-[30px]"> <div class="p-10 lg:p-12 flex flex-col gap-8">
<!-- Status Badges -->
<div class="flex flex-wrap gap-3"> {#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 <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' {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' : incident.analysis?.severity === 'medium'
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30' ? 'bg-amber-500/25 text-amber-300 border border-amber-500/40'
: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'} uppercase" : 'bg-emerald-500/25 text-emerald-300 border border-emerald-500/40'} uppercase"
> >
<Icon icon="ri:error-warning-fill" width="18" /> <Icon icon="ri:error-warning-fill" width="18" />
{incident.analysis?.severity || "UNKNOWN"} SEVERITY {incident.analysis?.severity || "UNKNOWN"} SEVERITY
</span> </span>
<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" /> <Icon icon="ri:shield-check-fill" width="18" />
{incident.analysis?.confidence || "UNKNOWN"} CONFIDENCE {incident.analysis?.confidence || "UNKNOWN"} CONFIDENCE
</span> </span>
<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" /> <Icon icon="ri:calendar-check-fill" width="18" />
{new Date(incident.created_at).toLocaleDateString()} {new Date(incident.created_at).toLocaleDateString()}
</span> </span>
</div> </div>
<!-- Verdict -->
<div class="bg-white/4 border border-white/6 rounded-[20px] p-6"> <div
<h3 class="bg-white/5 backdrop-blur-sm border border-white/10 rounded-[20px] p-7"
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
> >
<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 Verdict
</h3> </h3>
<p <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"} {incident.analysis?.verdict || "Greenwashing detected"}
</p> </p>
</div> </div>
<!-- Detailed Analysis -->
<div class="bg-white/4 border border-white/6 rounded-[20px] p-6"> <div
<h3 class="bg-white/5 backdrop-blur-sm border border-white/10 rounded-[20px] p-7"
class="flex items-center gap-[10px] text-white text-base font-bold mb-4"
> >
<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 Detailed Analysis
</h3> </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 || {incident.analysis?.reasoning ||
"No detailed analysis available."} "No detailed analysis available."}
</p> </p>
</div> </div>
<!-- Red Flags -->
{#if incident.analysis?.red_flags && incident.analysis.red_flags.length > 0} {#if incident.analysis?.red_flags && incident.analysis.red_flags.length > 0}
<div <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 <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 Red Flags Identified
</h3> </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} {#each incident.analysis.red_flags as flag}
<li <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
icon="ri:error-warning-line" icon="ri:error-warning-line"
@@ -154,21 +190,21 @@
</div> </div>
{/if} {/if}
<!-- Key Claims -->
{#if incident.analysis?.key_claims && incident.analysis.key_claims.length > 0} {#if incident.analysis?.key_claims && incident.analysis.key_claims.length > 0}
<div <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 <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 Environmental Claims Made
</h3> </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} {#each incident.analysis.key_claims as claim}
<li <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
icon="ri:double-quotes-l" icon="ri:double-quotes-l"
@@ -182,36 +218,38 @@
</div> </div>
{/if} {/if}
<!-- Recommendations -->
{#if incident.analysis?.recommendations} {#if incident.analysis?.recommendations}
<div <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 <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 Consumer Recommendations
</h3> </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} {incident.analysis.recommendations}
</p> </p>
</div> </div>
{/if} {/if}
<!-- User's Original Report -->
{#if incident.user_description} {#if incident.user_description}
<div <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 <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 Original User Report
</h3> </h3>
<p <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}" "{incident.user_description}"
</p> </p>
@@ -222,7 +260,7 @@
</div> </div>
<style> <style>
/* Custom utility to hide scrollbar if tailwind plugin not present */
.scrollbar-hide { .scrollbar-hide {
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;

View File

@@ -43,52 +43,52 @@
</script> </script>
<button <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)} onclick={() => openReport(report)}
> >
<div <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" /> <Icon icon={fileDetails.icon} width="32" class="text-white" />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2.5">
<h3 class="text-white text-xl font-extrabold m-0"> <h3 class="text-white text-xl lg:text-[22px] font-extrabold m-0">
{report.company_name} {report.company_name}
</h3> </h3>
<span <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 >{report.year}</span
> >
</div> </div>
{#if report.snippet} {#if report.snippet}
<p <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( {@html report.snippet.replace(
new RegExp(searchQuery || "", "gi"), new RegExp(searchQuery || "", "gi"),
(match) => (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> </p>
{:else} {: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 {report.sector} Sector • Impact Report
</p> </p>
{/if} {/if}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2.5">
<span <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} title={report.filename}
> >
<Icon icon={fileDetails.icon} width="14" /> <Icon icon={fileDetails.icon} width="15" />
{fileDetails.type} {fileDetails.type}
</span> </span>
<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
icon="ri:checkbox-circle-fill" icon="ri:checkbox-circle-fill"
@@ -101,18 +101,18 @@
</div> </div>
{#if report.greenwashing_score} {#if report.greenwashing_score}
<div class="text-center ml-5"> <div class="text-center ml-5 lg:ml-6">
<div <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, 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 >{Math.round(Number(report.greenwashing_score))}</span
> >
</div> </div>
<span <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 >Trust Score</span
> >
</div> </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 IncidentCard from "$lib/components/catalogue/IncidentCard.svelte";
import CompanyModal from "$lib/components/catalogue/CompanyModal.svelte"; import CompanyModal from "$lib/components/catalogue/CompanyModal.svelte";
import IncidentModal from "$lib/components/catalogue/IncidentModal.svelte"; import IncidentModal from "$lib/components/catalogue/IncidentModal.svelte";
import Pagination from "$lib/components/catalogue/Pagination.svelte";
// View mode toggle
type ViewMode = "company" | "user"; type ViewMode = "company" | "user";
let viewMode = $state<ViewMode>("company"); let viewMode = $state<ViewMode>("company");
// Data Types
interface Report { interface Report {
company_name: string; company_name: string;
year: string | number; year: string | number;
@@ -31,6 +30,8 @@
detected_brand: string; detected_brand: string;
user_description?: string; user_description?: string;
created_at: string; created_at: string;
image_base64?: string;
report_type?: "product" | "company";
analysis: { analysis: {
verdict: string; verdict: string;
confidence: string; confidence: string;
@@ -48,7 +49,7 @@
let searchQuery = $state(""); let searchQuery = $state("");
let isLoading = $state(false); let isLoading = $state(false);
// Predefined categories
const categories = [ const categories = [
"All", "All",
"Tech", "Tech",
@@ -61,7 +62,7 @@
]; ];
let selectedCategory = $state("All"); let selectedCategory = $state("All");
// Fetching logic
async function fetchReports() { async function fetchReports() {
isLoading = true; isLoading = true;
try { try {
@@ -93,7 +94,7 @@
fetchIncidents(); fetchIncidents();
}); });
// Search
async function handleSearch() { async function handleSearch() {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
fetchReports(); fetchReports();
@@ -124,7 +125,7 @@
debounceTimer = setTimeout(handleSearch, 600); debounceTimer = setTimeout(handleSearch, 600);
} }
// Pagination & Filtering
let currentPage = $state(1); let currentPage = $state(1);
const itemsPerPage = 10; const itemsPerPage = 10;
@@ -154,11 +155,10 @@
function goToPage(page: number) { function goToPage(page: number) {
if (page >= 1 && page <= totalPages) { if (page >= 1 && page <= totalPages) {
currentPage = page; currentPage = page;
window.scrollTo({ top: 0, behavior: "smooth" });
} }
} }
// Modals
let selectedReport = $state<Report | null>(null); let selectedReport = $state<Report | null>(null);
let selectedIncident = $state<Incident | null>(null); let selectedIncident = $state<Incident | null>(null);
</script> </script>
@@ -172,7 +172,7 @@
</svelte:head> </svelte:head>
<div class="relative w-full min-h-screen overflow-x-hidden"> <div class="relative w-full min-h-screen overflow-x-hidden">
<!-- Modals -->
{#if selectedReport} {#if selectedReport}
<CompanyModal <CompanyModal
report={selectedReport} report={selectedReport}
@@ -193,7 +193,7 @@
</div> </div>
<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 <CatalogueHeader
bind:viewMode bind:viewMode
@@ -201,30 +201,35 @@
bind:selectedCategory bind:selectedCategory
{categories} {categories}
{onSearchInput} {onSearchInput}
{currentPage}
{totalPages}
{goToPage}
/> />
{#if isLoading} {#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
icon="eos-icons:loading" icon="eos-icons:loading"
width="40" width="48"
class="text-emerald-400" class="text-emerald-400"
/> />
<p class="text-white/60 font-medium"> <p class="text-white/70 font-medium text-lg">
Syncing with database... Syncing with database...
</p> </p>
</div> </div>
{:else if viewMode === "company"} {:else if viewMode === "company"}
{#if filteredReports.length === 0} {#if filteredReports.length === 0}
<div <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. No reports found matching your criteria.
</p> </p>
</div> </div>
{:else} {:else}
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-6 mt-8">
{#each paginatedReports as report} {#each paginatedReports as report}
<ReportCard <ReportCard
{report} {report}
@@ -233,24 +238,25 @@
/> />
{/each} {/each}
</div> </div>
<Pagination bind:currentPage {totalPages} {goToPage} />
{/if} {/if}
{:else if incidents.length === 0} {:else if incidents.length === 0}
<div <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
icon="ri:file-warning-line" icon="ri:file-warning-line"
width="48" width="56"
class="text-white/30" class="text-white/30"
/> />
<p class="text-white/60 text-lg">No user reports yet.</p> <p class="text-white/70 text-lg font-medium">
<p class="text-white/40 text-sm"> No user reports yet.
</p>
<p class="text-white/50 text-sm">
Be the first to report greenwashing! Be the first to report greenwashing!
</p> </p>
</div> </div>
{:else} {:else}
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-6 mt-8">
{#each incidents as incident} {#each incidents as incident}
<IncidentCard <IncidentCard
{incident} {incident}
@@ -264,7 +270,7 @@
<style> <style>
:global(body) { :global(body) {
background-color: #051010; background-color: #0c0c0c;
color: white; color: white;
} }
</style> </style>

View File

@@ -1,10 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte"; import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte";
import Icon from "@iconify/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, id: 1,
text: "Hello! I'm Ethix AI. Ask me anything about recycling, sustainability, or green products.", text: "Hello! I'm Ethix AI. Ask me anything about recycling, sustainability, or green products.",
@@ -12,9 +20,8 @@
}, },
]); ]);
let inputText = $state(""); let inputText = $state("");
let canvasElement = $state<HTMLCanvasElement>();
let isLoading = $state(false); let isLoading = $state(false);
let chatWindowFn: HTMLDivElement | undefined = $state(); let chatWindowFn = $state<HTMLDivElement>();
function scrollToBottom() { function scrollToBottom() {
if (chatWindowFn) { if (chatWindowFn) {
@@ -25,7 +32,7 @@
} }
$effect(() => { $effect(() => {
// Dependencies to trigger scroll
messages; messages;
isLoading; isLoading;
scrollToBottom(); scrollToBottom();
@@ -38,7 +45,7 @@
const userMsg = { const userMsg = {
id: Date.now(), id: Date.now(),
text: userText, text: userText,
sender: "user", sender: "user" as const,
}; };
messages = [...messages, userMsg]; messages = [...messages, userMsg];
inputText = ""; inputText = "";
@@ -49,12 +56,8 @@
"http://localhost:5000/api/gemini/ask", "http://localhost:5000/api/gemini/ask",
{ {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify({ prompt: userText }),
},
body: JSON.stringify({
prompt: userText,
}),
}, },
); );
@@ -64,7 +67,7 @@
const aiMsg = { const aiMsg = {
id: Date.now() + 1, id: Date.now() + 1,
text: data.reply, text: data.reply,
sender: "ai", sender: "ai" as const,
}; };
messages = [...messages, aiMsg]; messages = [...messages, aiMsg];
} else { } else {
@@ -74,7 +77,7 @@
const errorMsg = { const errorMsg = {
id: Date.now() + 1, id: Date.now() + 1,
text: "Sorry, I'm having trouble connecting to my brain right now. Please try again later.", 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]; messages = [...messages, errorMsg];
console.error("Chat Error:", error); console.error("Chat Error:", error);
@@ -82,63 +85,6 @@
isLoading = false; 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> </script>
<svelte:head> <svelte:head>
@@ -149,426 +95,68 @@
/> />
</svelte:head> </svelte:head>
<div class="page-wrapper"> <div class="fixed inset-0 w-full h-full overflow-hidden bg-[#0c0c0c]">
<div class="desktop-bg"> <div class="hidden md:block absolute inset-0 pointer-events-none">
<ParallaxLandscape /> <ParallaxLandscape />
</div> </div>
<div class="chat-container"> <div
<div class="chat-card"> 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="header"> >
<div class="mascot-container"> <div
<canvas 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"
bind:this={canvasElement} >
width="40"
height="40" <div
class="mascot-canvas" 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"
></canvas> >
<div class="mascot-status-dot"></div> <Mascot />
</div> <div class="flex flex-col items-start gap-0.5">
<div class="header-text-center"> <h1
<h1 class="page-title">Ethix Assistant</h1> class="text-white text-base font-bold leading-tight m-0"
<div class="powered-by"> >
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" /> <Icon icon="ri:shining-fill" width="10" />
<span>Powered by Gemini</span> <span>Powered by Gemini</span>
</div> </div>
</div> </div>
</div> </div>
<div class="chat-window">
<div class="messages-container" bind:this={chatWindowFn}>
{#each messages as msg (msg.id)}
<div <div
class="message" class="flex-1 flex flex-col overflow-hidden bg-[#0d2e25] md:bg-transparent"
class:user-message={msg.sender === "user"}
class:ai-message={msg.sender === "ai"}
> >
<div class="message-content"> <div
{@html marked.parse(msg.text)} class="flex-1 overflow-y-auto p-6 pb-5 flex flex-col gap-4 scroll-smooth scrollbar-none"
</div> bind:this={chatWindowFn}
</div> >
{#each messages as msg (msg.id)}
<ChatMessage text={msg.text} sender={msg.sender} />
{/each} {/each}
{#if isLoading} {#if isLoading}
<div class="message ai-message loading-bubble"> <LoadingBubble />
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
{/if} {/if}
</div> </div>
<div class="input-container"> <ChatInput bind:inputText {isLoading} onSend={sendMessage} />
<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>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.page-wrapper {
width: 100%;
height: 100vh;
position: relative;
overflow: hidden;
}
.desktop-bg { .scrollbar-none::-webkit-scrollbar {
display: none; display: none;
} }
.chat-container { .scrollbar-none {
position: relative; -ms-overflow-style: none;
z-index: 10; scrollbar-width: none;
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;
}
} }
</style> </style>

View File

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

View File

@@ -4,6 +4,9 @@
let productName = $state(""); let productName = $state("");
let description = $state(""); let description = $state("");
let image = $state<string | null>(null); 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 submitted = $state(false);
let isLoading = $state(false); let isLoading = $state(false);
let analysisResult = $state<any>(null); let analysisResult = $state<any>(null);
@@ -40,6 +43,25 @@
input.click(); 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 = [ const progressSteps = [
{ id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" }, { id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" },
{ {
@@ -65,7 +87,7 @@
error = null; error = null;
currentStep = 1; currentStep = 1;
// Simulate progress steps
const stepInterval = setInterval(() => { const stepInterval = setInterval(() => {
if (currentStep < progressSteps.length) { if (currentStep < progressSteps.length) {
currentStep++; currentStep++;
@@ -83,18 +105,20 @@
body: JSON.stringify({ body: JSON.stringify({
product_name: productName, product_name: productName,
description: description, description: description,
image: image, // Base64 encoded report_type: reportType,
image: reportType === "product" ? image : null,
pdf_data: reportType === "company" ? pdfData : null,
}), }),
}, },
); );
clearInterval(stepInterval); clearInterval(stepInterval);
currentStep = progressSteps.length; // Complete all steps currentStep = progressSteps.length;
const data = await response.json(); const data = await response.json();
if (data.status === "success") { if (data.status === "success") {
// Brief pause to show completion
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
analysisResult = data; analysisResult = data;
submitted = true; submitted = true;
@@ -200,10 +224,28 @@
<div class="glass-card form-card"> <div class="glass-card form-card">
<div class="form-content"> <div class="form-content">
<div class="form-group"> <div class="report-type-toggle">
<label class="label" for="productName" <button
>Product Name</label 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"> <div class="input-wrapper">
<iconify-icon <iconify-icon
icon="ri:price-tag-3-line" icon="ri:price-tag-3-line"
@@ -213,7 +255,9 @@
type="text" type="text"
id="productName" id="productName"
class="input" 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} bind:value={productName}
/> />
</div> </div>
@@ -238,7 +282,12 @@
</div> </div>
<div class="form-group"> <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 <button
class="image-picker" class="image-picker"
onclick={pickImage} onclick={pickImage}
@@ -267,6 +316,36 @@
</div> </div>
{/if} {/if}
</button> </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> </div>
{#if error} {#if error}
@@ -410,6 +489,51 @@
gap: 16px; 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 { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;