Docker Update and Fixes

This commit is contained in:
2026-01-25 17:36:15 +00:00
parent b7b718d4ca
commit 295be1ed8e
21 changed files with 886 additions and 289 deletions

18
.env.example Normal file
View 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

View File

@@ -12,3 +12,4 @@ flask-cors
ollama ollama
chromadb-client chromadb-client
pymongo pymongo
google-genai

View File

@@ -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

View File

@@ -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.")

View File

@@ -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 :

View File

@@ -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
View 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"]

View File

@@ -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}`);
}); });

View File

@@ -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;
const result = await analyzeImage(imageData);
processResult(result, imageData);
} else {
analyzing = false;
}
} }
setTimeout(() => { function processResult(data: any, imageUri: string) {
const newItem = {
id: Date.now(),
title: "Plastic Bottle",
date: new Date().toLocaleString(),
impact: "High",
imageUri: capturedImage,
};
onScanComplete(newItem);
analyzing = false; 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">
Sustainable Alternatives
</p>
<div class="alternatives-scroll"> <div class="alternatives-scroll">
<button class="alternative-card glass-bottle"> {#each alternatives as alt}
<div class="alternative-card">
<div class="alt-header"> <div class="alt-header">
<Icon <Icon
icon="ri:cup-fill" icon="ri:leaf-fill"
width="24" width="24"
style="color: #60a5fa;" style="color: #4ade80;"
/> />
<span class="rating">★ 4.9</span> {#if alt.impact_reduction}
<span class="rating">Better</span>
{/if}
</div> </div>
<h3 class="alt-name">Glass Bottle</h3> <p class="alt-name">{alt.name}</p>
<p class="alt-price">$2.49</p>
</button>
<button class="alternative-card boxed-water">
<div class="alt-header">
<Icon
icon="ri:box-3-fill"
width="24"
style="color: #a78bfa;"
/>
<span class="rating">★ 4.7</span>
</div> </div>
<h3 class="alt-name">Boxed Water</h3> {/each}
<p class="alt-price">$1.89</p>
</button>
<button class="alternative-card aluminum">
<div class="alt-header">
<Icon
icon="ri:beer-fill"
width="24"
style="color: #9ca3af;"
/>
<span class="rating">★ 4.5</span>
</div> </div>
<h3 class="alt-name">Aluminum</h3> {:else if displayTitle !== "Scan Failed"}
<p class="alt-price">$1.29</p> <p class="alternatives-label" style="color: #4ade80;">
</button> ✓ No greenwashing concerns found
</div> </p>
{:else}
<button class="report-btn" onclick={handleClose}> <p class="alternatives-label" style="color: #f87171;">
<Icon icon="ri:alarm-warning-fill" width="20" /> Could not analyze this image. Please try again.
<span>Report Greenwashing</span> </p>
</button> {/if}
</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 {

View File

@@ -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 <div
class="flex-1 flex flex-col items-center gap-2 py-4 px-2 bg-[#0d2e25] border border-[#1f473b] rounded-2xl" 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
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,20 +377,65 @@
<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">
{selectedScan.impact} <div class="flex justify-center gap-2 my-4">
</p>
<div <div
class="inline-block px-3 py-1.5 rounded-full text-xs font-bold uppercase {selectedScan.severity.toLowerCase() === class="inline-block px-3 py-1.5 rounded-full text-xs font-bold uppercase {selectedScan.severity.toLowerCase() ===
'high' 'high'
? 'bg-red-400/20 text-red-400' ? 'bg-red-400/20 text-red-400'
: selectedScan.severity.toLowerCase() === 'medium' : selectedScan.severity.toLowerCase() ===
'medium'
? 'bg-orange-400/20 text-orange-400' ? 'bg-orange-400/20 text-orange-400'
: 'bg-emerald-400/20 text-emerald-400'}" : 'bg-emerald-400/20 text-emerald-400'}"
> >
Severity: {selectedScan.severity} Severity: {selectedScan.severity}
</div> </div>
</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}
</p>
{#if selectedScan.alternatives && selectedScan.alternatives.length > 0}
<div
class="mt-6 text-left bg-emerald-900/20 p-4 rounded-xl border border-emerald-500/20 mb-2"
>
<h4
class="text-emerald-400 text-xs font-bold uppercase tracking-widest mb-3 flex items-center gap-2"
>
<Icon icon="ri:leaf-line" />
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>
{/if} {/if}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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"
> >

View 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)}`;
}

View File

@@ -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);
} }

View File

@@ -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)';

View File

@@ -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}

View File

@@ -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" },

View File

@@ -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" },

View File

@@ -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: {

View File

@@ -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,
}
}
}, },
})); }));