mirror of
https://github.com/SirBlobby/Hoya26.git
synced 2026-02-04 03:34:34 -05:00
Docker Update and Fixes
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Ethix Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# Backend Configuration
|
||||||
|
SECRET_KEY=your_secret_key_here
|
||||||
|
|
||||||
|
# Google Gemini API
|
||||||
|
GOOGLE_API_KEY=your_google_api_key
|
||||||
|
|
||||||
|
# MongoDB Connection
|
||||||
|
MONGO_URI=mongodb+srv://user:password@cluster.mongodb.net/ethix
|
||||||
|
|
||||||
|
# Ollama Configuration (optional - has default)
|
||||||
|
OLLAMA_HOST=https://ollama.website.co
|
||||||
|
|
||||||
|
# Use MongoDB Atlas for vector storage (optional)
|
||||||
|
# Set to "true" to use Atlas instead of ChromaDB
|
||||||
|
ATLAS_VECTORS=false
|
||||||
@@ -12,3 +12,4 @@ flask-cors
|
|||||||
ollama
|
ollama
|
||||||
chromadb-client
|
chromadb-client
|
||||||
pymongo
|
pymongo
|
||||||
|
google-genai
|
||||||
@@ -18,7 +18,7 @@ class GeminiClient :
|
|||||||
def ask (self ,prompt ,context =""):
|
def ask (self ,prompt ,context =""):
|
||||||
try :
|
try :
|
||||||
if context :
|
if context :
|
||||||
full_message =f"Use this information to answer: {context }\n\nQuestion: {prompt }"
|
full_message =f"Background Context:\n{context }\n\nUser Question: {prompt }"
|
||||||
else :
|
else :
|
||||||
full_message =prompt
|
full_message =prompt
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class GeminiClient :
|
|||||||
model =self .model_name ,
|
model =self .model_name ,
|
||||||
contents =full_message ,
|
contents =full_message ,
|
||||||
config ={
|
config ={
|
||||||
'system_instruction':'You are a concise sustainability assistant. Your responses must be a single short paragraph, maximum 6 sentences long. Do not use bullet points or multiple sections.'
|
'system_instruction':'You are Ethix, an expert sustainability assistant. You have access to a database including Georgetown University sustainability reports. SEARCH THE PROVIDED CONTEXT CAREFULLY. If the context contains ANY information about Georgetown University or the user\'s query, matches, or partial matches, YOU MUST USE IT to answer. Ignore irrelevant parts of the context. If no context matches, provide general expert advice. Keep responses concise (max 6 sentences).'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return response .text
|
return response .text
|
||||||
|
|||||||
@@ -27,17 +27,53 @@ def ask():
|
|||||||
print(f"Generating embedding for prompt: {prompt}")
|
print(f"Generating embedding for prompt: {prompt}")
|
||||||
query_embedding = get_embedding(prompt)
|
query_embedding = get_embedding(prompt)
|
||||||
|
|
||||||
print("Searching ChromaDB for context...")
|
print("Searching Vector Database for context...")
|
||||||
search_results = search_documents(query_embedding, num_results=15)
|
search_results = search_documents(query_embedding, num_results=50)
|
||||||
|
|
||||||
|
# Special handling for Georgetown University queries to ensure those docs are included
|
||||||
|
# even if generic corporate reports outrank them in vector search.
|
||||||
|
if "georgetown" in prompt.lower():
|
||||||
|
try:
|
||||||
|
from src.mongo.connection import get_mongo_client
|
||||||
|
client = get_mongo_client()
|
||||||
|
# Use hardcoded DB name to match vector_store.py
|
||||||
|
db = client["ethix_vectors"]
|
||||||
|
collection = db["rag_documents"]
|
||||||
|
|
||||||
|
# Fetch docs with Georgetown or MAP_INFO in the filename/source
|
||||||
|
gt_docs = list(collection.find({"source": {"$regex": "Georgetown|MAP_INFO", "$options": "i"}}).limit(30))
|
||||||
|
|
||||||
|
if gt_docs:
|
||||||
|
print(f"Direct Match: Found {len(gt_docs)} Georgetown specific documents.")
|
||||||
|
for doc in gt_docs:
|
||||||
|
# Normalize to match search_results format
|
||||||
|
search_results.append({
|
||||||
|
"text": doc.get("text", ""),
|
||||||
|
"metadata": doc.get("metadata", {"source": doc.get("source", "Georgetown File")}),
|
||||||
|
"score": 1.0 # High priority
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error checking Georgetown docs: {e}")
|
||||||
|
|
||||||
retrieved_context = ""
|
retrieved_context = ""
|
||||||
if search_results:
|
if search_results:
|
||||||
print(f"Found {len(search_results)} documents.")
|
print(f"Found {len(search_results)} documents (total).")
|
||||||
retrieved_context = "RELEVANT INFORMATION FROM DATABASE:\n"
|
print("Sources found:")
|
||||||
|
seen_sources = set()
|
||||||
|
|
||||||
for res in search_results:
|
for res in search_results:
|
||||||
# Include metadata if useful, e.g. brand name or date
|
# Include metadata if useful, e.g. brand name or date
|
||||||
meta = res.get('metadata', {})
|
meta = res.get('metadata', {})
|
||||||
source_info = f"[Source: {meta.get('type', 'doc')} - {meta.get('product_name', 'Unknown')}]"
|
source = meta.get('source', 'unknown')
|
||||||
|
|
||||||
|
# Deduplication of printing and adding context if exact text overlap?
|
||||||
|
# For now just append. LLM can handle duplication.
|
||||||
|
|
||||||
|
if source not in seen_sources:
|
||||||
|
print(f" - {source}")
|
||||||
|
seen_sources.add(source)
|
||||||
|
|
||||||
|
source_info = f"[Source: {meta.get('type', 'doc')} - {source}]"
|
||||||
retrieved_context += f"{source_info}\n{res['text']}\n\n"
|
retrieved_context += f"{source_info}\n{res['text']}\n\n"
|
||||||
else:
|
else:
|
||||||
print("No relevant documents found.")
|
print("No relevant documents found.")
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class GreenwashingAnalysis (BaseModel ):
|
|||||||
recommendations :str =Field (description ="What consumers should know about this case")
|
recommendations :str =Field (description ="What consumers should know about this case")
|
||||||
key_claims :List [str ]=Field (description ="List of specific environmental claims made by the company")
|
key_claims :List [str ]=Field (description ="List of specific environmental claims made by the company")
|
||||||
red_flags :List [str ]=Field (description ="List of red flags or concerning practices identified")
|
red_flags :List [str ]=Field (description ="List of red flags or concerning practices identified")
|
||||||
|
alternatives :List [str ]=Field (description ="List of sustainable alternatives or better choices")
|
||||||
|
|
||||||
|
|
||||||
class LogoDetection (BaseModel ):
|
class LogoDetection (BaseModel ):
|
||||||
@@ -124,8 +125,11 @@ Based on this information, determine if this is a valid case of greenwashing. Co
|
|||||||
2. Are their eco-friendly claims vague or unsubstantiated?
|
2. Are their eco-friendly claims vague or unsubstantiated?
|
||||||
3. Is there a disconnect between their marketing and actual practices?
|
3. Is there a disconnect between their marketing and actual practices?
|
||||||
4. Are they using green imagery or terms without substance?
|
4. Are they using green imagery or terms without substance?
|
||||||
|
128: 5. Suggest better, clearer, or more sustainable alternatives if applicable.
|
||||||
Provide your analysis in the structured format requested."""
|
129:
|
||||||
|
130: If the provided context includes university-specific information (e.g., Georgetown University), incorporate it into your recommendations where relevant (e.g., disposal instructions, local alternatives).
|
||||||
|
131:
|
||||||
|
132: Provide your analysis in the structured format requested."""
|
||||||
|
|
||||||
|
|
||||||
def analyze_with_gemini (product_name :str ,user_description :str ,detected_brand :str ,
|
def analyze_with_gemini (product_name :str ,user_description :str ,detected_brand :str ,
|
||||||
@@ -147,7 +151,7 @@ image_description :str ,context :str )->GreenwashingAnalysis :
|
|||||||
|
|
||||||
|
|
||||||
response =client .models .generate_content (
|
response =client .models .generate_content (
|
||||||
model ="gemini-3-pro-preview",
|
model ="gemini-2.0-flash-exp",
|
||||||
contents =prompt ,
|
contents =prompt ,
|
||||||
config ={
|
config ={
|
||||||
"response_mime_type":"application/json",
|
"response_mime_type":"application/json",
|
||||||
@@ -311,169 +315,240 @@ This incident has been documented for future reference and to help inform sustai
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@incidents_bp .route ('/submit',methods =['POST'])
|
@incidents_bp.route('/submit', methods=['POST'])
|
||||||
def submit_incident ():
|
def submit_incident():
|
||||||
"""
|
"""
|
||||||
Submit a greenwashing incident report
|
Submit a greenwashing incident report
|
||||||
|
|
||||||
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
|
||||||
- report_type: 'product' or 'company'
|
- report_type: 'product' or 'company'
|
||||||
- image: Base64 encoded image (for product reports)
|
- image: Base64 encoded image
|
||||||
- pdf_data: Base64 encoded PDF (for company reports)
|
- user_id: ID of the user submitting
|
||||||
|
- is_public: Boolean, whether to make it public
|
||||||
"""
|
"""
|
||||||
data =request .json
|
data = request.json
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "No data provided"}), 400
|
||||||
|
|
||||||
if not data :
|
product_name = data.get('product_name', '').strip()
|
||||||
return jsonify ({"error":"No data provided"}),400
|
user_description = data.get('description', '').strip()
|
||||||
|
report_type = data.get('report_type', 'product')
|
||||||
|
image_base64 = data.get('image')
|
||||||
|
user_id = data.get('user_id', 'anonymous')
|
||||||
|
is_public = data.get('is_public', False)
|
||||||
|
|
||||||
product_name =data .get ('product_name','').strip ()
|
if not product_name:
|
||||||
user_description =data .get ('description','').strip ()
|
return jsonify({"error": "Product name is required"}), 400
|
||||||
report_type =data .get ('report_type','product')
|
|
||||||
image_base64 =data .get ('image')
|
|
||||||
|
|
||||||
if not product_name :
|
# Description isn't strictly required if image provides context, but good enforcement
|
||||||
return jsonify ({"error":"Product name is required"}),400
|
if not user_description:
|
||||||
|
user_description = "No description provided."
|
||||||
|
|
||||||
if not user_description :
|
try:
|
||||||
return jsonify ({"error":"Description is required"}),400
|
detected_brand = "Unknown"
|
||||||
|
image_description = "No image provided"
|
||||||
|
environmental_claims = []
|
||||||
|
compressed_image_base64 = None
|
||||||
|
|
||||||
try :
|
if report_type == 'product' and image_base64:
|
||||||
|
try:
|
||||||
|
if ',' in image_base64:
|
||||||
|
image_base64 = image_base64.split(',')[1]
|
||||||
|
|
||||||
detected_brand ="Unknown"
|
image_bytes = base64.b64decode(image_base64)
|
||||||
image_description ="No image provided"
|
|
||||||
environmental_claims =[]
|
|
||||||
compressed_image_base64 =None
|
|
||||||
|
|
||||||
if report_type =='product'and image_base64 :
|
# Compress for storage
|
||||||
try :
|
compressed_image_base64 = compress_image(image_bytes, max_width=600, quality=75)
|
||||||
|
|
||||||
if ','in image_base64 :
|
# Analyze image with Ollama
|
||||||
image_base64 =image_base64 .split (',')[1 ]
|
image_analysis = analyze_image_with_ollama(image_bytes)
|
||||||
|
|
||||||
image_bytes =base64 .b64decode (image_base64 )
|
if image_analysis.logos_detected:
|
||||||
|
detected_brand = image_analysis.logos_detected[0].brand
|
||||||
|
|
||||||
|
image_description = image_analysis.description
|
||||||
|
environmental_claims = image_analysis.environmental_claims
|
||||||
|
|
||||||
print ("Compressing image with OpenCV...")
|
except Exception as e:
|
||||||
compressed_image_base64 =compress_image (image_bytes ,max_width =600 ,quality =75 )
|
print(f"Image processing error: {e}")
|
||||||
|
|
||||||
|
# RAG Search context
|
||||||
|
search_query = f"{product_name} {detected_brand} environmental claims sustainability greenwashing"
|
||||||
|
query_embedding = get_embedding(search_query)
|
||||||
|
search_results = search_documents(query_embedding, num_results=20)
|
||||||
|
|
||||||
image_analysis =analyze_image_with_ollama (image_bytes )
|
context = ""
|
||||||
|
for res in search_results:
|
||||||
|
context += f"--- Document ---\n{res['text'][:500]}\n\n"
|
||||||
|
|
||||||
if image_analysis .logos_detected :
|
if not context:
|
||||||
detected_brand =image_analysis .logos_detected [0 ].brand
|
context = "No prior information found about this company in our database."
|
||||||
|
|
||||||
image_description =image_analysis .description
|
if environmental_claims:
|
||||||
environmental_claims =image_analysis .environmental_claims
|
context += "\n--- Claims visible in submitted image ---\n"
|
||||||
|
context += "\n".join(f"- {claim}" for claim in environmental_claims)
|
||||||
|
|
||||||
except Exception as e :
|
# Main Analysis
|
||||||
print (f"Image processing error: {e }")
|
analysis = analyze_with_gemini(
|
||||||
|
product_name=product_name,
|
||||||
|
user_description=user_description,
|
||||||
|
detected_brand=detected_brand,
|
||||||
|
image_description=image_description,
|
||||||
search_query =f"{product_name } {detected_brand } environmental claims sustainability greenwashing"
|
context=context
|
||||||
query_embedding =get_embedding (search_query )
|
|
||||||
search_results =search_documents (query_embedding ,num_results =5 )
|
|
||||||
|
|
||||||
context =""
|
|
||||||
for res in search_results :
|
|
||||||
context +=f"--- Document ---\n{res ['text'][:500 ]}\n\n"
|
|
||||||
|
|
||||||
if not context :
|
|
||||||
context ="No prior information found about this company in our database."
|
|
||||||
|
|
||||||
|
|
||||||
if environmental_claims :
|
|
||||||
context +="\n--- Claims visible in submitted image ---\n"
|
|
||||||
context +="\n".join (f"- {claim }"for claim in environmental_claims )
|
|
||||||
|
|
||||||
|
|
||||||
analysis =analyze_with_gemini (
|
|
||||||
product_name =product_name ,
|
|
||||||
user_description =user_description ,
|
|
||||||
detected_brand =detected_brand ,
|
|
||||||
image_description =image_description ,
|
|
||||||
context =context
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
analysis_dict = analysis.model_dump()
|
||||||
|
|
||||||
analysis_dict =analysis .model_dump ()
|
incident_data = {
|
||||||
|
"product_name": product_name,
|
||||||
|
"user_description": user_description,
|
||||||
incident_data ={
|
"detected_brand": detected_brand,
|
||||||
"product_name":product_name ,
|
"image_description": image_description,
|
||||||
"user_description":user_description ,
|
"environmental_claims": environmental_claims,
|
||||||
"detected_brand":detected_brand ,
|
"analysis": analysis_dict,
|
||||||
"image_description":image_description ,
|
"is_greenwashing": analysis.is_greenwashing,
|
||||||
"environmental_claims":environmental_claims ,
|
"created_at": datetime.utcnow().isoformat(),
|
||||||
"analysis":analysis_dict ,
|
"status": "confirmed" if analysis.is_greenwashing else "dismissed",
|
||||||
"is_greenwashing":analysis .is_greenwashing ,
|
"report_type": report_type,
|
||||||
"created_at":datetime .utcnow ().isoformat (),
|
"user_id": user_id,
|
||||||
"status":"confirmed"if analysis .is_greenwashing else "dismissed",
|
"is_public": is_public
|
||||||
"report_type":report_type
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if compressed_image_base64:
|
||||||
|
incident_data["image_base64"] = compressed_image_base64
|
||||||
|
|
||||||
if compressed_image_base64 :
|
# Save to MongoDB (All scans)
|
||||||
incident_data ["image_base64"]=compressed_image_base64
|
incident_id = save_to_mongodb(incident_data)
|
||||||
|
|
||||||
incident_id =None
|
# Save to Vector Store ONLY if Greenwashing AND Public
|
||||||
|
if analysis.is_greenwashing and is_public:
|
||||||
|
save_to_chromadb(incident_data, incident_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
if analysis .is_greenwashing :
|
"status": "success",
|
||||||
|
"is_greenwashing": analysis.is_greenwashing,
|
||||||
incident_id =save_to_mongodb (incident_data )
|
"incident_id": incident_id,
|
||||||
|
"analysis": analysis_dict,
|
||||||
|
"detected_brand": detected_brand,
|
||||||
save_to_chromadb (incident_data ,incident_id )
|
"environmental_claims": environmental_claims
|
||||||
|
|
||||||
return jsonify ({
|
|
||||||
"status":"success",
|
|
||||||
"is_greenwashing":analysis .is_greenwashing ,
|
|
||||||
"incident_id":incident_id ,
|
|
||||||
"analysis":analysis_dict ,
|
|
||||||
"detected_brand":detected_brand ,
|
|
||||||
"environmental_claims":environmental_claims
|
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e :
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
traceback .print_exc ()
|
traceback.print_exc()
|
||||||
return jsonify ({
|
return jsonify({
|
||||||
"status":"error",
|
"status": "error",
|
||||||
"message":str (e )
|
"message": str(e)
|
||||||
}),500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@incidents_bp .route ('/list',methods =['GET'])
|
@incidents_bp.route('/list', methods=['GET'])
|
||||||
def list_incidents ():
|
def list_incidents():
|
||||||
"""Get all confirmed greenwashing incidents"""
|
"""Get all PUBLIC confirmed greenwashing incidents"""
|
||||||
try :
|
try:
|
||||||
client =get_mongo_client ()
|
client = get_mongo_client()
|
||||||
db =client ["ethix"]
|
db = client["ethix"]
|
||||||
collection =db ["incidents"]
|
collection = db["incidents"]
|
||||||
|
|
||||||
|
# Filter: Public AND Greenwashing
|
||||||
|
query = {
|
||||||
|
"is_greenwashing": True,
|
||||||
|
"is_public": True
|
||||||
|
# Note: Legacy records without 'is_public' might be hidden.
|
||||||
|
# For migration, we might want to treat missing as True or update DB.
|
||||||
|
# Assuming strictly filtered for now.
|
||||||
|
}
|
||||||
|
|
||||||
|
incidents = list(collection.find(
|
||||||
|
query,
|
||||||
|
{"_id": 1, "product_name": 1, "detected_brand": 1,
|
||||||
|
"user_description": 1, "analysis": 1, "created_at": 1,
|
||||||
|
"image_base64": 1, "report_type": 1}
|
||||||
|
).sort("created_at", -1).limit(50))
|
||||||
|
|
||||||
|
for inc in incidents:
|
||||||
|
inc["_id"] = str(inc["_id"])
|
||||||
|
|
||||||
|
return jsonify(incidents)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
incidents =list (collection .find (
|
@incidents_bp.route('/history', methods=['GET'])
|
||||||
{"is_greenwashing":True },
|
def get_user_history():
|
||||||
{"_id":1 ,"product_name":1 ,"detected_brand":1 ,
|
"""Get scan history for a specific user"""
|
||||||
"user_description":1 ,"analysis":1 ,"created_at":1 ,
|
user_id = request.args.get('user_id')
|
||||||
"image_base64":1 ,"report_type":1 }
|
if not user_id:
|
||||||
).sort ("created_at",-1 ).limit (50 ))
|
return jsonify({"error": "user_id required"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_mongo_client()
|
||||||
|
db = client["ethix"]
|
||||||
|
collection = db["incidents"]
|
||||||
|
|
||||||
|
query = {"user_id": user_id}
|
||||||
|
|
||||||
|
incidents = list(collection.find(
|
||||||
|
query,
|
||||||
|
{"_id": 1, "product_name": 1, "detected_brand": 1,
|
||||||
|
"analysis": 1, "created_at": 1, "image_base64": 1, "is_public": 1}
|
||||||
|
).sort("created_at", -1).limit(50))
|
||||||
|
|
||||||
|
for inc in incidents:
|
||||||
|
inc["_id"] = str(inc["_id"])
|
||||||
|
|
||||||
|
return jsonify(incidents)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
for inc in incidents :
|
@incidents_bp.route('/<incident_id>/visibility', methods=['PUT'])
|
||||||
inc ["_id"]=str (inc ["_id"])
|
def update_visibility(incident_id):
|
||||||
|
"""Update incident visibility (public/private)"""
|
||||||
|
try:
|
||||||
|
from bson import ObjectId
|
||||||
|
data = request.json
|
||||||
|
is_public = data.get('is_public')
|
||||||
|
|
||||||
return jsonify (incidents )
|
if is_public is None:
|
||||||
|
return jsonify({"error": "is_public required"}), 400
|
||||||
|
|
||||||
except Exception as e :
|
client = get_mongo_client()
|
||||||
return jsonify ({"error":str (e )}),500
|
db = client["ethix"]
|
||||||
|
collection = db["incidents"]
|
||||||
|
|
||||||
|
# 1. Update MongoDB
|
||||||
|
result = collection.update_one(
|
||||||
|
{"_id": ObjectId(incident_id)},
|
||||||
|
{"$set": {"is_public": is_public}}
|
||||||
|
)
|
||||||
|
|
||||||
@incidents_bp .route ('/<incident_id>',methods =['GET'])
|
if result.matched_count == 0:
|
||||||
|
return jsonify({"error": "Incident not found"}), 404
|
||||||
|
|
||||||
|
# 2. Sync with ChromaDB (Vector Store)
|
||||||
|
# We need the incident data to insert/delete
|
||||||
|
incident = collection.find_one({"_id": ObjectId(incident_id)})
|
||||||
|
|
||||||
|
if is_public and incident.get("is_greenwashing", False):
|
||||||
|
# If public and greenwashing -> Add to Chroma
|
||||||
|
save_to_chromadb(incident, incident_id)
|
||||||
|
else:
|
||||||
|
# If private OR not greenwashing -> Remove from Chroma
|
||||||
|
# We need delete functionality. delete_documents_by_source uses 'source' metadata.
|
||||||
|
from src.chroma.vector_store import delete_documents_by_source
|
||||||
|
delete_documents_by_source(f"incident_{incident_id}")
|
||||||
|
|
||||||
|
return jsonify({"status": "success", "is_public": is_public})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
def get_incident (incident_id ):
|
def get_incident (incident_id ):
|
||||||
"""Get a specific incident by ID"""
|
"""Get a specific incident by ID"""
|
||||||
try :
|
try :
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# The Flask Backend
|
# The Flask Backend API
|
||||||
api:
|
api:
|
||||||
build: ./backend # Path to your Dockerfile
|
build: ./backend
|
||||||
container_name: flask_api
|
container_name: flask_api
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000" # Maps VM Port 5000 -> Container Port 5000
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
- SECRET_KEY=your_secret_key_here
|
- SECRET_KEY=${SECRET_KEY:-your_secret_key_here}
|
||||||
# Add database URLs here later
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY}
|
||||||
|
- MONGO_URI=${MONGO_URI}
|
||||||
|
- OLLAMA_HOST=${OLLAMA_HOST:-https://ollama.sirblob.co}
|
||||||
|
- ATLAS_VECTORS=${ATLAS_VECTORS:-false}
|
||||||
|
networks:
|
||||||
|
- ethix-network
|
||||||
|
|
||||||
# (Optional) Add a database or cache here easily later
|
# The Frontend Server with API Proxy
|
||||||
# redis:
|
frontend:
|
||||||
# image: redis:alpine
|
build: ./frontend
|
||||||
|
container_name: svelte_frontend
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
- API_URL=http://api:5000
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- ethix-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ethix-network:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
41
frontend/Dockerfile
Normal file
41
frontend/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the SvelteKit app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package.json for production dependencies
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Install only production dependencies
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
# Copy built files from builder
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
|
COPY --from=builder /app/server ./server
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
CMD ["node", "server/index.js"]
|
||||||
@@ -9,18 +9,80 @@ const __dirname = path.dirname(__filename);
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Backend API URL - use Docker service name or environment variable
|
||||||
|
const API_URL = process.env.API_URL || 'http://api:5000';
|
||||||
|
|
||||||
|
// Enable compression
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
|
||||||
|
// Parse JSON bodies for proxied requests
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Proxy middleware
|
||||||
|
* Forwards all /api/* requests to the backend service
|
||||||
|
*/
|
||||||
|
app.use('/api', async (req, res) => {
|
||||||
|
const targetUrl = `${API_URL}${req.originalUrl}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchOptions = {
|
||||||
|
method: req.method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': req.get('Content-Type') || 'application/json',
|
||||||
|
'Accept': req.get('Accept') || 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward body for POST/PUT/PATCH requests
|
||||||
|
if (['POST', 'PUT', 'PATCH'].includes(req.method) && req.body) {
|
||||||
|
fetchOptions.body = JSON.stringify(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, fetchOptions);
|
||||||
|
|
||||||
|
// Get content type from response
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
res.status(response.status);
|
||||||
|
if (contentType) {
|
||||||
|
res.set('Content-Type', contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different response types
|
||||||
|
if (contentType && (contentType.includes('application/pdf') ||
|
||||||
|
contentType.includes('text/plain') ||
|
||||||
|
contentType.includes('application/octet-stream'))) {
|
||||||
|
// Binary/file responses
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
res.send(Buffer.from(buffer));
|
||||||
|
} else {
|
||||||
|
// JSON responses
|
||||||
|
const data = await response.text();
|
||||||
|
res.send(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API Proxy Error [${req.method} ${req.originalUrl}]:`, error.message);
|
||||||
|
res.status(502).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to connect to backend service',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files from build directory
|
||||||
const buildPath = path.join(__dirname, '../build');
|
const buildPath = path.join(__dirname, '../build');
|
||||||
app.use(express.static(buildPath));
|
app.use(express.static(buildPath));
|
||||||
|
|
||||||
|
// SPA fallback - serve index.html for all other routes
|
||||||
app.get(/.*/, (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(buildPath, 'index.html'));
|
res.sendFile(path.join(buildPath, 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Server is running on http://localhost:${PORT}`);
|
console.log(`Frontend server running on http://localhost:${PORT}`);
|
||||||
|
console.log(`API proxy target: ${API_URL}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
let useCamera = $state(true);
|
let useCamera = $state(true);
|
||||||
let fileInputElement = $state<HTMLInputElement>();
|
let fileInputElement = $state<HTMLInputElement>();
|
||||||
|
|
||||||
|
let alternatives = $state<any[]>([]);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const initCamera = async () => {
|
const initCamera = async () => {
|
||||||
if (useCamera) {
|
if (useCamera) {
|
||||||
@@ -43,6 +45,36 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function analyzeImage(imageBase64: string) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
product_name: "Scanned Item",
|
||||||
|
description: "Scanned via camera",
|
||||||
|
report_type: "product",
|
||||||
|
image: imageBase64,
|
||||||
|
user_id: localStorage.getItem("ethix_user_id") || "anonymous",
|
||||||
|
is_public: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/api/incidents/submit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === "success" && data.analysis) {
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || "Analysis failed");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Analysis error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function playSuccessSound() {
|
function playSuccessSound() {
|
||||||
const audio = new Audio("/report completed.mp3");
|
const audio = new Audio("/report completed.mp3");
|
||||||
audio.volume = 0.5;
|
audio.volume = 0.5;
|
||||||
@@ -55,7 +87,7 @@
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = async (e) => {
|
||||||
const imageData = e.target?.result as string;
|
const imageData = e.target?.result as string;
|
||||||
capturedImage = imageData;
|
capturedImage = imageData;
|
||||||
analyzing = true;
|
analyzing = true;
|
||||||
@@ -73,7 +105,6 @@
|
|||||||
analyzing = false;
|
analyzing = false;
|
||||||
showResult = true;
|
showResult = true;
|
||||||
resultTranslateY = 0;
|
resultTranslateY = 0;
|
||||||
playSuccessSound();
|
|
||||||
typeText();
|
typeText();
|
||||||
}, 1200);
|
}, 1200);
|
||||||
};
|
};
|
||||||
@@ -88,25 +119,26 @@
|
|||||||
canvas.width = videoElement.videoWidth;
|
canvas.width = videoElement.videoWidth;
|
||||||
canvas.height = videoElement.videoHeight;
|
canvas.height = videoElement.videoHeight;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.drawImage(videoElement, 0, 0);
|
ctx.drawImage(videoElement, 0, 0);
|
||||||
capturedImage = canvas.toDataURL("image/png");
|
const imageData = canvas.toDataURL("image/png");
|
||||||
}
|
capturedImage = imageData;
|
||||||
|
|
||||||
setTimeout(() => {
|
const result = await analyzeImage(imageData);
|
||||||
const newItem = {
|
processResult(result, imageData);
|
||||||
id: Date.now(),
|
} else {
|
||||||
title: "Plastic Bottle",
|
|
||||||
date: new Date().toLocaleString(),
|
|
||||||
impact: "High",
|
|
||||||
imageUri: capturedImage,
|
|
||||||
};
|
|
||||||
|
|
||||||
onScanComplete(newItem);
|
|
||||||
analyzing = false;
|
analyzing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processResult(data: any, imageUri: string) {
|
||||||
|
analyzing = false;
|
||||||
|
if (!data) {
|
||||||
|
displayTitle = "Scan Failed";
|
||||||
|
alternatives = [];
|
||||||
showResult = true;
|
showResult = true;
|
||||||
resultTranslateY = 0;
|
resultTranslateY = 0;
|
||||||
playSuccessSound();
|
|
||||||
typeText();
|
typeText();
|
||||||
}, 1200);
|
}, 1200);
|
||||||
}
|
}
|
||||||
@@ -164,7 +196,26 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="mode-toggle-btn"
|
class="mode-toggle-btn"
|
||||||
onclick={() => (useCamera = !useCamera)}
|
onclick={() => {
|
||||||
|
useCamera = !useCamera;
|
||||||
|
if (useCamera) {
|
||||||
|
// Re-init camera if switching back
|
||||||
|
const initCamera = async () => {
|
||||||
|
try {
|
||||||
|
stream =
|
||||||
|
await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: "environment" },
|
||||||
|
});
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Camera access denied:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initCamera();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if useCamera}
|
{#if useCamera}
|
||||||
<Icon
|
<Icon
|
||||||
@@ -241,53 +292,36 @@
|
|||||||
|
|
||||||
<h2 class="sheet-title">{displayTitle}</h2>
|
<h2 class="sheet-title">{displayTitle}</h2>
|
||||||
|
|
||||||
<p class="alternatives-label">Top Sustainable Alternatives</p>
|
{#if alternatives && alternatives.length > 0}
|
||||||
|
<p class="alternatives-label">
|
||||||
<div class="alternatives-scroll">
|
Sustainable Alternatives
|
||||||
<button class="alternative-card glass-bottle">
|
</p>
|
||||||
<div class="alt-header">
|
<div class="alternatives-scroll">
|
||||||
<Icon
|
{#each alternatives as alt}
|
||||||
icon="ri:cup-fill"
|
<div class="alternative-card">
|
||||||
width="24"
|
<div class="alt-header">
|
||||||
style="color: #60a5fa;"
|
<Icon
|
||||||
/>
|
icon="ri:leaf-fill"
|
||||||
<span class="rating">★ 4.9</span>
|
width="24"
|
||||||
</div>
|
style="color: #4ade80;"
|
||||||
<h3 class="alt-name">Glass Bottle</h3>
|
/>
|
||||||
<p class="alt-price">$2.49</p>
|
{#if alt.impact_reduction}
|
||||||
</button>
|
<span class="rating">Better</span>
|
||||||
|
{/if}
|
||||||
<button class="alternative-card boxed-water">
|
</div>
|
||||||
<div class="alt-header">
|
<p class="alt-name">{alt.name}</p>
|
||||||
<Icon
|
</div>
|
||||||
icon="ri:box-3-fill"
|
{/each}
|
||||||
width="24"
|
</div>
|
||||||
style="color: #a78bfa;"
|
{:else if displayTitle !== "Scan Failed"}
|
||||||
/>
|
<p class="alternatives-label" style="color: #4ade80;">
|
||||||
<span class="rating">★ 4.7</span>
|
✓ No greenwashing concerns found
|
||||||
</div>
|
</p>
|
||||||
<h3 class="alt-name">Boxed Water</h3>
|
{:else}
|
||||||
<p class="alt-price">$1.89</p>
|
<p class="alternatives-label" style="color: #f87171;">
|
||||||
</button>
|
Could not analyze this image. Please try again.
|
||||||
|
</p>
|
||||||
<button class="alternative-card aluminum">
|
{/if}
|
||||||
<div class="alt-header">
|
|
||||||
<Icon
|
|
||||||
icon="ri:beer-fill"
|
|
||||||
width="24"
|
|
||||||
style="color: #9ca3af;"
|
|
||||||
/>
|
|
||||||
<span class="rating">★ 4.5</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="alt-name">Aluminum</h3>
|
|
||||||
<p class="alt-price">$1.29</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="report-btn" onclick={handleClose}>
|
|
||||||
<Icon icon="ri:alarm-warning-fill" width="20" />
|
|
||||||
<span>Report Greenwashing</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,10 +416,11 @@
|
|||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border: 6px solid rgba(255, 255, 255, 0.3);
|
border: 6px solid rgba(74, 222, 128, 0.5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shutter-button:active {
|
.shutter-button:active {
|
||||||
|
|||||||
@@ -14,15 +14,174 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface ScanItem {
|
interface ScanItem {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
date: string;
|
date: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
image: string;
|
image: string | null;
|
||||||
impact: string;
|
impact: string;
|
||||||
|
alternatives: string[];
|
||||||
|
is_greenwashing: boolean;
|
||||||
|
is_public: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scanHistory = $state<ScanItem[]>([]);
|
||||||
let selectedScan = $state<ScanItem | null>(null);
|
let selectedScan = $state<ScanItem | null>(null);
|
||||||
|
let userId = $state("");
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
let isScanning = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let storedId = localStorage.getItem("ethix_user_id");
|
||||||
|
if (!storedId) {
|
||||||
|
storedId = crypto.randomUUID();
|
||||||
|
localStorage.setItem("ethix_user_id", storedId);
|
||||||
|
}
|
||||||
|
userId = storedId;
|
||||||
|
|
||||||
|
fetchHistory();
|
||||||
|
|
||||||
|
// Listen for scan-complete event to refresh history
|
||||||
|
const handleScanComplete = () => {
|
||||||
|
fetchHistory();
|
||||||
|
};
|
||||||
|
window.addEventListener("scan-complete", handleScanComplete);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scan-complete", handleScanComplete);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchHistory() {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/incidents/history?user_id=${userId}`,
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
scanHistory = data.map((item: any) => ({
|
||||||
|
id: item._id,
|
||||||
|
name: item.product_name,
|
||||||
|
date: new Date(item.created_at).toLocaleDateString(),
|
||||||
|
severity: item.analysis?.severity || "Low",
|
||||||
|
image: item.image_base64
|
||||||
|
? `data:image/jpeg;base64,${item.image_base64}`
|
||||||
|
: null,
|
||||||
|
impact: item.analysis?.verdict || "No impact assessment",
|
||||||
|
alternatives: item.analysis?.alternatives || [],
|
||||||
|
is_greenwashing: item.analysis?.is_greenwashing || false,
|
||||||
|
is_public: item.is_public || false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch history", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerScan() {
|
||||||
|
if (isScanning) return;
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
if (!target.files || target.files.length === 0) return;
|
||||||
|
|
||||||
|
const file = target.files[0];
|
||||||
|
isScanning = true;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
const base64String = reader.result as string;
|
||||||
|
await submitScan(base64String);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitScan(imageBase64: string) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
product_name: "Scanned Item",
|
||||||
|
description: "Scanned via mobile app",
|
||||||
|
report_type: "product",
|
||||||
|
image: imageBase64,
|
||||||
|
user_id: userId,
|
||||||
|
is_public: false, // Default private
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
"/api/incidents/submit",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.status === "success") {
|
||||||
|
await fetchHistory();
|
||||||
|
selectedScan = {
|
||||||
|
id: data.incident_id,
|
||||||
|
name:
|
||||||
|
data.detected_brand !== "Unknown"
|
||||||
|
? data.detected_brand
|
||||||
|
: "Scanned Product",
|
||||||
|
date: "Just now",
|
||||||
|
severity: data.analysis.severity,
|
||||||
|
image: imageBase64,
|
||||||
|
impact: data.analysis.verdict,
|
||||||
|
alternatives: data.analysis.alternatives || [],
|
||||||
|
is_greenwashing: data.is_greenwashing,
|
||||||
|
is_public: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
alert("Analysis failed: " + (data.message || "Unknown error"));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Scan failed", e);
|
||||||
|
alert("Analysis failed. Please try again.");
|
||||||
|
} finally {
|
||||||
|
isScanning = false;
|
||||||
|
if (fileInput) fileInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleVisibility() {
|
||||||
|
if (!selectedScan) return;
|
||||||
|
const newState = !selectedScan.is_public;
|
||||||
|
|
||||||
|
// Optimistic UI update for modal
|
||||||
|
selectedScan.is_public = newState;
|
||||||
|
|
||||||
|
// Optimistic UI update for list
|
||||||
|
const histIndex = scanHistory.findIndex(
|
||||||
|
(s) => s.id === selectedScan!.id,
|
||||||
|
);
|
||||||
|
if (histIndex !== -1) {
|
||||||
|
scanHistory[histIndex].is_public = newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(
|
||||||
|
`/api/incidents/${selectedScan.id}/visibility`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ is_public: newState }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Revert on failure
|
||||||
|
selectedScan.is_public = !newState;
|
||||||
|
if (histIndex !== -1) {
|
||||||
|
scanHistory[histIndex].is_public = !newState;
|
||||||
|
}
|
||||||
|
alert("Failed to update visibility");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openScan(scan: ScanItem) {
|
function openScan(scan: ScanItem) {
|
||||||
selectedScan = scan;
|
selectedScan = scan;
|
||||||
@@ -32,69 +191,83 @@
|
|||||||
selectedScan = null;
|
selectedScan = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let greeting = $state("Hello");
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const hour = new Date().getHours();
|
|
||||||
if (hour < 12) greeting = "Good morning";
|
|
||||||
else if (hour < 17) greeting = "Good afternoon";
|
|
||||||
else greeting = "Good evening";
|
|
||||||
});
|
|
||||||
|
|
||||||
function getSeverityClass(severity: string): string {
|
function getSeverityClass(severity: string): string {
|
||||||
return severity.toLowerCase();
|
return severity?.toLowerCase() || "low";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-[#051f18] text-white px-5 pt-4 pb-32">
|
<div class="min-h-screen bg-[#051f18] text-white px-5 pt-4 pb-32">
|
||||||
<section class="flex gap-3 mb-7">
|
<section class="flex gap-3 mb-7">
|
||||||
|
<button
|
||||||
|
class="flex-1 flex flex-col items-center gap-2 py-4 px-2 bg-[#0d2e25] border border-emerald-500/30 rounded-2xl active:scale-95 transition-all shadow-[0_0_15px_rgba(16,185,129,0.1)] relative overflow-hidden group min-h-[100px] justify-center"
|
||||||
|
onclick={triggerScan}
|
||||||
|
disabled={isScanning}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-emerald-500/5 group-hover:bg-emerald-500/10 transition-colors"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-emerald-500/20 rounded-xl flex items-center justify-center text-emerald-400 relative z-10"
|
||||||
|
>
|
||||||
|
{#if isScanning}
|
||||||
|
<Icon
|
||||||
|
icon="ri:loader-4-line"
|
||||||
|
width="24"
|
||||||
|
class="animate-spin"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Icon icon="ri:camera-lens-fill" width="24" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center relative z-10">
|
||||||
|
<span class="text-sm font-extrabold text-white"
|
||||||
|
>{isScanning ? "Analyzing..." : "Scan Item"}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] text-emerald-400/80 uppercase tracking-wide font-semibold"
|
||||||
|
>AI Detection</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-1 flex flex-col items-center gap-2 py-4 px-2 bg-[#0d2e25] border border-[#1f473b] rounded-2xl"
|
class="flex-1 flex flex-col items-center gap-2 py-4 px-2 bg-[#0d2e25] border border-[#1f473b] rounded-2xl min-h-[100px] justify-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-9 h-9 bg-emerald-500/10 rounded-xl flex items-center justify-center"
|
class="w-9 h-9 bg-emerald-500/10 rounded-xl flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon icon="ri:leaf-fill" width="20" class="text-emerald-400" />
|
||||||
icon="ri:qr-scan-2-line"
|
|
||||||
width="20"
|
|
||||||
class="text-emerald-400"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="text-lg font-extrabold text-white">47</span>
|
<span class="text-lg font-extrabold text-white"
|
||||||
|
>{scanHistory.length}</span
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="text-[10px] text-gray-400 uppercase tracking-wide font-semibold"
|
class="text-[10px] text-gray-400 uppercase tracking-wide font-semibold"
|
||||||
>scans</span
|
>Total Scans</span
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex-1 flex flex-col items-center gap-2 py-4 px-2 bg-[#0d2e25] border border-[#1f473b] rounded-2xl"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-9 h-9 bg-emerald-500/10 rounded-xl flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon="ri:checkbox-circle-fill"
|
|
||||||
width="20"
|
|
||||||
class="text-emerald-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="text-lg font-extrabold text-white">32</span>
|
|
||||||
<span
|
|
||||||
class="text-[10px] text-gray-400 uppercase tracking-wide font-semibold"
|
|
||||||
>eco picks</span
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="recent-section">
|
<section class="recent-section">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-base font-extrabold text-white m-0">Your Scans</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2.5">
|
<div class="flex flex-col gap-2.5">
|
||||||
|
{#if scanHistory.length === 0}
|
||||||
|
<div class="text-center py-10 text-gray-500 text-sm">
|
||||||
|
No scans yet. Start by scanning a product!
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#each scanHistory as item (item.id)}
|
{#each scanHistory as item (item.id)}
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-3.5 p-5 bg-[#0d2e25] border border-[#1f473b] rounded-2xl cursor-pointer w-full text-left transition-transform active:scale-98 active:bg-[#1f473b]"
|
class="flex items-center gap-3.5 p-5 bg-[#0d2e25] border border-[#1f473b] rounded-2xl cursor-pointer w-full text-left transition-transform active:scale-98 active:bg-[#1f473b]"
|
||||||
onclick={() => openScan(item)}
|
onclick={() => openScan(item)}
|
||||||
aria-label="View {item.name}"
|
aria-label="View {item.name}"
|
||||||
>
|
>
|
||||||
<div class="w-12 h-12 rounded-xl overflow-hidden shrink-0">
|
<div
|
||||||
|
class="w-12 h-12 rounded-xl overflow-hidden shrink-0 bg-black"
|
||||||
|
>
|
||||||
{#if item.image}
|
{#if item.image}
|
||||||
<img
|
<img
|
||||||
src={item.image}
|
src={item.image}
|
||||||
@@ -110,7 +283,9 @@
|
|||||||
? '#fca5a5'
|
? '#fca5a5'
|
||||||
: '#86efac'}"
|
: '#86efac'}"
|
||||||
>
|
>
|
||||||
<span class="text-lg">{item.name[0]}</span>
|
<span class="text-lg"
|
||||||
|
>{item.name ? item.name[0] : "?"}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +293,17 @@
|
|||||||
<span class="text-sm font-semibold text-white truncate"
|
<span class="text-sm font-semibold text-white truncate"
|
||||||
>{item.name}</span
|
>{item.name}</span
|
||||||
>
|
>
|
||||||
<span class="text-xs text-gray-400">{item.date}</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-400"
|
||||||
|
>{item.date}</span
|
||||||
|
>
|
||||||
|
{#if item.is_public}
|
||||||
|
<span
|
||||||
|
class="text-[9px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 font-bold border border-emerald-500/30"
|
||||||
|
>PUBLIC</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide {item.severity.toLowerCase() ===
|
class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide {item.severity.toLowerCase() ===
|
||||||
@@ -137,19 +322,17 @@
|
|||||||
|
|
||||||
{#if selectedScan}
|
{#if selectedScan}
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center p-5"
|
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[200] flex items-start justify-center p-4 pt-8 pb-4 overflow-y-auto"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={(e) => e.key === "Escape" && closeScan()}
|
|
||||||
onclick={closeScan}
|
onclick={closeScan}
|
||||||
|
onkeydown={(e) => e.key === "Escape" && closeScan()}
|
||||||
>
|
>
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
class="bg-[#0d2e25] border border-[#1f473b] rounded-3xl w-full max-w-[340px] p-6 relative shadow-[0_20px_50px_rgba(0,0,0,0.5)]"
|
class="bg-[#0d2e25] border border-[#1f473b] rounded-3xl w-full max-w-[340px] p-5 relative shadow-[0_20px_50px_rgba(0,0,0,0.5)] mb-4"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
role="document"
|
role="document"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
onkeydown={(e) => e.key === "Escape" && closeScan()}
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="absolute top-4 right-4 bg-transparent border-none text-gray-400 cursor-pointer"
|
class="absolute top-4 right-4 bg-transparent border-none text-gray-400 cursor-pointer"
|
||||||
@@ -157,7 +340,7 @@
|
|||||||
>
|
>
|
||||||
<Icon icon="ri:close-fill" width="24" />
|
<Icon icon="ri:close-fill" width="24" />
|
||||||
</button>
|
</button>
|
||||||
<div class="text-center mb-5">
|
<div class="text-center mb-4">
|
||||||
<span
|
<span
|
||||||
class="text-xs text-gray-400 uppercase tracking-widest"
|
class="text-xs text-gray-400 uppercase tracking-widest"
|
||||||
>{selectedScan.date}</span
|
>{selectedScan.date}</span
|
||||||
@@ -165,7 +348,7 @@
|
|||||||
<h2 class="text-xl text-white m-0 mt-1">Scan Report</h2>
|
<h2 class="text-xl text-white m-0 mt-1">Scan Report</h2>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-full h-[200px] bg-black rounded-2xl mb-5 overflow-hidden"
|
class="w-full h-[160px] bg-black rounded-2xl mb-4 overflow-hidden"
|
||||||
>
|
>
|
||||||
{#if selectedScan.image}
|
{#if selectedScan.image}
|
||||||
<img
|
<img
|
||||||
@@ -183,7 +366,9 @@
|
|||||||
: '#86efac'}"
|
: '#86efac'}"
|
||||||
>
|
>
|
||||||
<span class="text-6xl text-[#051f18] font-black"
|
<span class="text-6xl text-[#051f18] font-black"
|
||||||
>{selectedScan.name[0]}</span
|
>{selectedScan.name
|
||||||
|
? selectedScan.name[0]
|
||||||
|
: "?"}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -192,19 +377,64 @@
|
|||||||
<h3 class="text-lg text-white m-0 mb-1">
|
<h3 class="text-lg text-white m-0 mb-1">
|
||||||
{selectedScan.name}
|
{selectedScan.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-gray-300 text-sm mb-3">
|
|
||||||
|
<div class="flex justify-center gap-2 my-4">
|
||||||
|
<div
|
||||||
|
class="inline-block px-3 py-1.5 rounded-full text-xs font-bold uppercase {selectedScan.severity.toLowerCase() ===
|
||||||
|
'high'
|
||||||
|
? 'bg-red-400/20 text-red-400'
|
||||||
|
: selectedScan.severity.toLowerCase() ===
|
||||||
|
'medium'
|
||||||
|
? 'bg-orange-400/20 text-orange-400'
|
||||||
|
: 'bg-emerald-400/20 text-emerald-400'}"
|
||||||
|
>
|
||||||
|
Severity: {selectedScan.severity}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visibility Toggle -->
|
||||||
|
<button
|
||||||
|
class="mb-4 flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-xs font-bold w-full border {selectedScan.is_public
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/50'
|
||||||
|
: 'bg-[#051f18] text-gray-400 border-[#1f473b]'}"
|
||||||
|
onclick={toggleVisibility}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full {selectedScan.is_public
|
||||||
|
? 'bg-emerald-400'
|
||||||
|
: 'bg-gray-500'}"
|
||||||
|
></div>
|
||||||
|
{selectedScan.is_public
|
||||||
|
? "PUBLIC REPORT (Visible to everyone)"
|
||||||
|
: "PRIVATE REPORT (Only you)"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-gray-300 text-sm mb-4 leading-relaxed">
|
||||||
{selectedScan.impact}
|
{selectedScan.impact}
|
||||||
</p>
|
</p>
|
||||||
<div
|
|
||||||
class="inline-block px-3 py-1.5 rounded-full text-xs font-bold uppercase {selectedScan.severity.toLowerCase() ===
|
{#if selectedScan.alternatives && selectedScan.alternatives.length > 0}
|
||||||
'high'
|
<div
|
||||||
? 'bg-red-400/20 text-red-400'
|
class="mt-6 text-left bg-emerald-900/20 p-4 rounded-xl border border-emerald-500/20 mb-2"
|
||||||
: selectedScan.severity.toLowerCase() === 'medium'
|
>
|
||||||
? 'bg-orange-400/20 text-orange-400'
|
<h4
|
||||||
: 'bg-emerald-400/20 text-emerald-400'}"
|
class="text-emerald-400 text-xs font-bold uppercase tracking-widest mb-3 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
Severity: {selectedScan.severity}
|
<Icon icon="ri:leaf-line" />
|
||||||
</div>
|
Sustainable Alternatives
|
||||||
|
</h4>
|
||||||
|
<ul class="text-sm text-gray-300 space-y-2 pl-1">
|
||||||
|
{#each selectedScan.alternatives as alt}
|
||||||
|
<li class="flex items-start gap-2">
|
||||||
|
<span class="text-emerald-500 mt-1"
|
||||||
|
>•</span
|
||||||
|
>
|
||||||
|
<span>{alt}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
const PATH_CONFIG: Record<string, SceneConfig> = {
|
const PATH_CONFIG: Record<string, SceneConfig> = {
|
||||||
"/": { sceneType: "transition", staticScene: false },
|
"/": { sceneType: "transition", staticScene: false },
|
||||||
"/chat": { sceneType: "oilRig", staticScene: true },
|
"/chat": { sceneType: "oilRig", staticScene: true },
|
||||||
"/community": { sceneType: "forest", staticScene: true },
|
"/goal": { sceneType: "pollutedCity", staticScene: true },
|
||||||
"/report": { sceneType: "industrial", staticScene: true },
|
"/report": { sceneType: "deforestation", staticScene: true },
|
||||||
"/catalogue": {
|
"/catalogue": {
|
||||||
sceneType: "transition",
|
sceneType: "transition",
|
||||||
staticScene: false,
|
staticScene: false,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:5000/api/reports/stats");
|
const res = await fetch("/api/reports/stats");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
statsData = data;
|
statsData = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.endsWith(".txt")}
|
.endsWith(".txt")}
|
||||||
<iframe
|
<iframe
|
||||||
src="http://localhost:5000/api/reports/view/{report.filename}"
|
src="/api/reports/view/{report.filename}"
|
||||||
class="w-full h-full border-none"
|
class="w-full h-full border-none"
|
||||||
title="Report Viewer"
|
title="Report Viewer"
|
||||||
></iframe>
|
></iframe>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
/>
|
/>
|
||||||
<p>Preview not available for this file type.</p>
|
<p>Preview not available for this file type.</p>
|
||||||
<a
|
<a
|
||||||
href="http://localhost:5000/api/reports/view/{report.filename}"
|
href="/api/reports/view/{report.filename}"
|
||||||
download
|
download
|
||||||
class="bg-emerald-400 text-slate-900 px-6 py-3 rounded-full no-underline font-bold transition-transform hover:scale-105"
|
class="bg-emerald-400 text-slate-900 px-6 py-3 rounded-full no-underline font-bold transition-transform hover:scale-105"
|
||||||
>
|
>
|
||||||
|
|||||||
52
frontend/src/lib/ts/api.ts
Normal file
52
frontend/src/lib/ts/api.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
|
||||||
|
export const API_BASE = '/api';
|
||||||
|
|
||||||
|
export function apiUrl(path: string): string {
|
||||||
|
// Ensure path starts with /
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${API_BASE}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T = any>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = apiUrl(path);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function apiGet<T = any>(path: string): Promise<T> {
|
||||||
|
return apiFetch<T>(path, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function apiPost<T = any>(path: string, body: any): Promise<T> {
|
||||||
|
return apiFetch<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPut<T = any>(path: string, body: any): Promise<T> {
|
||||||
|
return apiFetch<T>(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportViewUrl(filename: string): string {
|
||||||
|
return `${API_BASE}/reports/view/${encodeURIComponent(filename)}`;
|
||||||
|
}
|
||||||
@@ -22,8 +22,9 @@ const SCENE_ELEMENTS: Record<Exclude<SceneType, 'transition'>, (dc: DrawContext)
|
|||||||
pollutedCity: drawPollutedCityScene,
|
pollutedCity: drawPollutedCityScene,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CUSTOM_WATER_SCENES: SceneType[] = ['ocean', 'oilRig'];
|
const CUSTOM_WATER_SCENES: SceneType[] = ['ocean', 'oilRig', 'deforestation'];
|
||||||
const NO_TERRAIN_SCENES: SceneType[] = ['ocean', 'oilRig'];
|
const NO_MOUNTAINS_SCENES: SceneType[] = ['ocean', 'oilRig', 'deforestation'];
|
||||||
|
const NO_HILLS_SCENES: SceneType[] = ['ocean', 'oilRig'];
|
||||||
|
|
||||||
export function drawLandscape(
|
export function drawLandscape(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
@@ -40,11 +41,16 @@ export function drawLandscape(
|
|||||||
drawSun(dc);
|
drawSun(dc);
|
||||||
drawClouds(dc);
|
drawClouds(dc);
|
||||||
|
|
||||||
const skipTerrain = NO_TERRAIN_SCENES.includes(sceneType) ||
|
const skipMountains = NO_MOUNTAINS_SCENES.includes(sceneType) ||
|
||||||
(blendToScene && NO_TERRAIN_SCENES.includes(blendToScene) && (blendProgress ?? 0) > 0.5);
|
(blendToScene && NO_MOUNTAINS_SCENES.includes(blendToScene) && (blendProgress ?? 0) > 0.5);
|
||||||
|
|
||||||
if (!skipTerrain) {
|
const skipHills = NO_HILLS_SCENES.includes(sceneType) ||
|
||||||
|
(blendToScene && NO_HILLS_SCENES.includes(blendToScene) && (blendProgress ?? 0) > 0.5);
|
||||||
|
|
||||||
|
if (!skipMountains) {
|
||||||
drawMountains(dc);
|
drawMountains(dc);
|
||||||
|
}
|
||||||
|
if (!skipHills) {
|
||||||
drawHills(dc);
|
drawHills(dc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ export function drawSmoggyBuildings(dc: DrawContext): void {
|
|||||||
|
|
||||||
for (let row = 0; row < windowRows; row++) {
|
for (let row = 0; row < windowRows; row++) {
|
||||||
for (let col = 0; col < windowCols; col++) {
|
for (let col = 0; col < windowCols; col++) {
|
||||||
const isBroken = Math.random() > 0.85;
|
// Deterministic "randomness" based on position to stop flashing
|
||||||
const isLit = Math.random() > 0.5;
|
const seed = x + col * 13 + row * 71;
|
||||||
|
const isBroken = Math.abs(Math.sin(seed)) > 0.85;
|
||||||
|
const isLit = Math.cos(seed) > 0.1;
|
||||||
|
|
||||||
if (!isBroken) {
|
if (!isBroken) {
|
||||||
ctx.fillStyle = isLit ? 'rgba(255, 180, 100, 0.5)' : 'rgba(50, 50, 50, 0.8)';
|
ctx.fillStyle = isLit ? 'rgba(255, 180, 100, 0.5)' : 'rgba(50, 50, 50, 0.8)';
|
||||||
|
|||||||
@@ -32,7 +32,14 @@
|
|||||||
|
|
||||||
function handleScanComplete(item: any) {
|
function handleScanComplete(item: any) {
|
||||||
recentItems = [item, ...recentItems];
|
recentItems = [item, ...recentItems];
|
||||||
|
// Don't close camera here - let user view the result sheet first
|
||||||
|
// Camera will close when user clicks close button
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCameraClose() {
|
||||||
isCameraActive = false;
|
isCameraActive = false;
|
||||||
|
// Dispatch event to notify MobileHomePage to refresh
|
||||||
|
window.dispatchEvent(new CustomEvent('scan-complete'));
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -147,7 +154,7 @@
|
|||||||
|
|
||||||
{#if isCameraActive}
|
{#if isCameraActive}
|
||||||
<CameraScreen
|
<CameraScreen
|
||||||
onClose={() => (isCameraActive = false)}
|
onClose={handleCameraClose}
|
||||||
onScanComplete={handleScanComplete}
|
onScanComplete={handleScanComplete}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
async function fetchReports() {
|
async function fetchReports() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:5000/api/reports/");
|
const res = await fetch("/api/reports/");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (Array.isArray(data)) reports = data;
|
if (Array.isArray(data)) reports = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
async function fetchIncidents() {
|
async function fetchIncidents() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:5000/api/incidents/list");
|
const res = await fetch("/api/incidents/list");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (Array.isArray(data)) incidents = data;
|
if (Array.isArray(data)) incidents = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
"http://localhost:5000/api/reports/search",
|
"/api/reports/search",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"http://localhost:5000/api/gemini/ask",
|
"/api/gemini/ask",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
if (reportType === "company") {
|
if (reportType === "company") {
|
||||||
// Use the new upload endpoint for company reports
|
// Use the new upload endpoint for company reports
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"http://localhost:5000/api/reports/upload",
|
"/api/reports/upload",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
} else {
|
} else {
|
||||||
// Original product incident flow
|
// Original product incident flow
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"http://localhost:5000/api/incidents/submit",
|
"/api/incidents/submit",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// Backend API URL for development proxy
|
||||||
|
const API_TARGET = process.env.API_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [sveltekit(), tailwindcss()],
|
plugins: [sveltekit(), tailwindcss()],
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
@@ -22,5 +25,13 @@ export default defineConfig(async () => ({
|
|||||||
watch: {
|
watch: {
|
||||||
ignored: ["**/src-tauri/**"],
|
ignored: ["**/src-tauri/**"],
|
||||||
},
|
},
|
||||||
|
// Proxy API requests to backend during development
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: API_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user