mirror of
https://github.com/SirBlobby/Hoya26.git
synced 2026-02-03 19:24:34 -05:00
Restore code and save recent updates
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
flask
|
||||
gunicorn
|
||||
ultralytics
|
||||
opencv-python
|
||||
opencv-python-headless
|
||||
transformers
|
||||
torch
|
||||
pandas
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
|
||||
from .config import YOLO26_MODELS
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
const config = activeConfig();
|
||||
|
||||
if (!config.staticScene || config.scenes) {
|
||||
// Always use window scroll now
|
||||
|
||||
scrollContainer = null;
|
||||
updateMeasurements();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
frontend/src/lib/components/chat/ChatInput.svelte
Normal file
45
frontend/src/lib/components/chat/ChatInput.svelte
Normal 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>
|
||||
43
frontend/src/lib/components/chat/ChatMessage.svelte
Normal file
43
frontend/src/lib/components/chat/ChatMessage.svelte
Normal 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>
|
||||
14
frontend/src/lib/components/chat/LoadingBubble.svelte
Normal file
14
frontend/src/lib/components/chat/LoadingBubble.svelte
Normal 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>
|
||||
73
frontend/src/lib/components/chat/Mascot.svelte
Normal file
73
frontend/src/lib/components/chat/Mascot.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user