mirror of
https://github.com/SirBlobby/Hoya26.git
synced 2026-02-04 03:34: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
|
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"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
flask
|
flask
|
||||||
gunicorn
|
gunicorn
|
||||||
ultralytics
|
ultralytics
|
||||||
opencv-python
|
opencv-python-headless
|
||||||
transformers
|
transformers
|
||||||
torch
|
torch
|
||||||
pandas
|
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 ):
|
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 :
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from .config import YOLO26_MODELS
|
from .config import YOLO26_MODELS
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user