mirror of
https://github.com/SirBlobby/Hoya26.git
synced 2026-02-04 03:34:34 -05:00
Database and Reports Update
This commit is contained in:
120
README.md
120
README.md
@@ -0,0 +1,120 @@
|
|||||||
|
# Ethix
|
||||||
|
|
||||||
|
A sustainability platform that helps users detect and report greenwashing - misleading environmental claims made by companies and products. Powered by AI analysis using Google Gemini and Ollama.
|
||||||
|
|
||||||
|
## About
|
||||||
|
|
||||||
|
Ethix enables users to:
|
||||||
|
|
||||||
|
- **Scan Products**: Use camera to detect brand logos and analyze environmental claims
|
||||||
|
- **Report Greenwashing**: Submit incidents with evidence (images or PDF reports)
|
||||||
|
- **Browse Reports**: Explore company sustainability reports and user-submitted incidents
|
||||||
|
- **Chat with AI**: Get answers about sustainability, recycling, and eco-friendly alternatives
|
||||||
|
|
||||||
|
## Technology Overview
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- SvelteKit with Svelte 5
|
||||||
|
- TypeScript
|
||||||
|
- TailwindCSS 4
|
||||||
|
- Tauri (desktop builds)
|
||||||
|
- Three.js (3D effects)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- Flask (Python)
|
||||||
|
- Google Gemini AI
|
||||||
|
- Ollama (embeddings and vision)
|
||||||
|
- ChromaDB (vector database)
|
||||||
|
- MongoDB (document storage)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+ or Bun
|
||||||
|
- Python 3.10+
|
||||||
|
- MongoDB instance
|
||||||
|
- Google API Key
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
bun install # or npm install
|
||||||
|
bun run dev # or npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Port | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| Frontend | 5173 | SvelteKit development server |
|
||||||
|
| Backend | 5000 | Flask API server |
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
|
||||||
|
The application requires access to:
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| ChromaDB | Vector storage for RAG |
|
||||||
|
| Ollama | Embeddings and vision models |
|
||||||
|
| MongoDB | Incident and metadata storage |
|
||||||
|
| Google Gemini | AI analysis and chat |
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Greenwashing Detection
|
||||||
|
|
||||||
|
Submit product photos or company PDF reports for AI-powered analysis. The system:
|
||||||
|
|
||||||
|
1. Detects brand logos using vision AI
|
||||||
|
2. Extracts text from documents
|
||||||
|
3. Searches for relevant context in the database
|
||||||
|
4. Provides structured verdicts with confidence levels
|
||||||
|
|
||||||
|
### RAG-Powered Chat
|
||||||
|
|
||||||
|
The AI assistant uses Retrieval-Augmented Generation to provide accurate, context-aware responses about sustainability topics.
|
||||||
|
|
||||||
|
### Catalogue System
|
||||||
|
|
||||||
|
Browse and search:
|
||||||
|
|
||||||
|
- Company sustainability reports with semantic search
|
||||||
|
- User-submitted greenwashing incidents
|
||||||
|
- Filter by category and view detailed analysis
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
GOOGLE_API_KEY=your_google_api_key
|
||||||
|
MONGO_URI=your_mongodb_connection_string
|
||||||
|
CHROMA_HOST=http://your-chromadb-host
|
||||||
|
OLLAMA_HOST=https://your-ollama-host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Backend Documentation](./backend/README.md)
|
||||||
|
- [Frontend Documentation](./frontend/README.md)
|
||||||
151
backend/README.md
Normal file
151
backend/README.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Ethix Backend
|
||||||
|
|
||||||
|
A Flask-based API server for the Ethix greenwashing detection platform. This backend provides AI-powered analysis of products and companies to identify misleading environmental claims.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|------------|
|
||||||
|
| Framework | Flask |
|
||||||
|
| AI/LLM | Google Gemini, Ollama |
|
||||||
|
| Vector Database | ChromaDB |
|
||||||
|
| Document Store | MongoDB |
|
||||||
|
| Embeddings | Ollama (nomic-embed-text) |
|
||||||
|
| Vision AI | Ollama (ministral-3) |
|
||||||
|
| Computer Vision | OpenCV, Ultralytics (YOLO) |
|
||||||
|
| Document Processing | PyPDF, openpyxl, pandas |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- MongoDB instance
|
||||||
|
- Access to ChromaDB server
|
||||||
|
- Access to Ollama server
|
||||||
|
- Google API Key (for Gemini)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file in the backend directory:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GOOGLE_API_KEY=your_google_api_key
|
||||||
|
MONGO_URI=your_mongodb_connection_string
|
||||||
|
CHROMA_HOST=http://your-chromadb-host
|
||||||
|
OLLAMA_HOST=https://your-ollama-host
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `GOOGLE_API_KEY` | Google Gemini API key | (required) |
|
||||||
|
| `MONGO_URI` | MongoDB connection string | (required) |
|
||||||
|
| `CHROMA_HOST` | ChromaDB server URL | `http://chroma.sirblob.co` |
|
||||||
|
| `OLLAMA_HOST` | Ollama server URL | `https://ollama.sirblob.co` |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Create and activate a virtual environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Server
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on `http://localhost:5000`.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Gemini AI
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/gemini/ask` | Chat with AI using RAG context |
|
||||||
|
| POST | `/api/gemini/rag` | Query with category filtering |
|
||||||
|
| POST | `/api/gemini/vision` | Vision analysis (not implemented) |
|
||||||
|
|
||||||
|
### Incidents
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/incidents/submit` | Submit a greenwashing report |
|
||||||
|
| GET | `/api/incidents/list` | Get all confirmed incidents |
|
||||||
|
| GET | `/api/incidents/<id>` | Get specific incident details |
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/reports/` | List all company reports |
|
||||||
|
| POST | `/api/reports/search` | Semantic search for reports |
|
||||||
|
| GET | `/api/reports/view/<filename>` | Download a report file |
|
||||||
|
|
||||||
|
### RAG
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/rag/ingest` | Ingest document chunks |
|
||||||
|
| POST | `/api/rag/search` | Search vector database |
|
||||||
|
|
||||||
|
## External Services
|
||||||
|
|
||||||
|
The backend integrates with the following external services:
|
||||||
|
|
||||||
|
| Service | URL | Purpose |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| ChromaDB | `http://chroma.sirblob.co` | Vector storage and similarity search |
|
||||||
|
| Ollama | `https://ollama.sirblob.co` | Embeddings and vision analysis |
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build and run using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t ethix-backend .
|
||||||
|
docker run -p 5000:5000 --env-file .env ethix-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use Docker Compose from the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### Greenwashing Detection
|
||||||
|
|
||||||
|
The incident submission pipeline:
|
||||||
|
|
||||||
|
1. User uploads product image or company PDF
|
||||||
|
2. Vision model detects brand logos (for products)
|
||||||
|
3. PDF text extraction (for company reports)
|
||||||
|
4. Embedding generation for semantic search
|
||||||
|
5. RAG context retrieval from ChromaDB
|
||||||
|
6. Gemini analysis with structured output
|
||||||
|
7. Results stored in MongoDB and ChromaDB
|
||||||
|
|
||||||
|
### RAG (Retrieval-Augmented Generation)
|
||||||
|
|
||||||
|
- Supports CSV, PDF, TXT, and XLSX file ingestion
|
||||||
|
- Documents are chunked and batched for embedding
|
||||||
|
- Prevents duplicate ingestion of processed files
|
||||||
|
- Semantic search using cosine similarity
|
||||||
@@ -12,7 +12,7 @@ from src .rag .ingest import process_file
|
|||||||
from src .rag .store import ingest_documents
|
from src .rag .store import ingest_documents
|
||||||
from src .mongo .metadata import is_file_processed ,log_processed_file
|
from src .mongo .metadata import is_file_processed ,log_processed_file
|
||||||
|
|
||||||
def populate_from_dataset (dataset_dir ,category =None ):
|
def populate_from_dataset (dataset_dir ,category =None ,force =False ):
|
||||||
dataset_path =Path (dataset_dir )
|
dataset_path =Path (dataset_dir )
|
||||||
if not dataset_path .exists ():
|
if not dataset_path .exists ():
|
||||||
print (f"Dataset directory not found: {dataset_dir }")
|
print (f"Dataset directory not found: {dataset_dir }")
|
||||||
@@ -27,7 +27,7 @@ def populate_from_dataset (dataset_dir ,category =None ):
|
|||||||
|
|
||||||
for file_path in dataset_path .glob ('*'):
|
for file_path in dataset_path .glob ('*'):
|
||||||
if file_path .is_file ()and file_path .suffix .lower ()in ['.csv','.pdf','.txt','.xlsx']:
|
if file_path .is_file ()and file_path .suffix .lower ()in ['.csv','.pdf','.txt','.xlsx']:
|
||||||
if is_file_processed (file_path .name ):
|
if not force and is_file_processed (file_path .name ):
|
||||||
print (f"Skipping {file_path .name } (already processed)")
|
print (f"Skipping {file_path .name } (already processed)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -48,15 +48,23 @@ def populate_from_dataset (dataset_dir ,category =None ):
|
|||||||
|
|
||||||
print (f"\nFinished! Processed {files_processed } files. Total chunks ingested: {total_chunks }")
|
print (f"\nFinished! Processed {files_processed } files. Total chunks ingested: {total_chunks }")
|
||||||
|
|
||||||
if __name__ =="__main__":
|
if __name__ == "__main__":
|
||||||
parser =argparse .ArgumentParser (description ="Populate vector database from dataset files")
|
parser = argparse.ArgumentParser(description="Populate vector database from dataset files")
|
||||||
parser .add_argument ("--category","-c",type =str ,help ="Category to assign to ingested documents")
|
parser.add_argument("--category", "-c", type=str, help="Category to assign to ingested documents")
|
||||||
parser .add_argument ("--dir","-d",type =str ,default =None ,help ="Dataset directory path")
|
parser.add_argument("--dir", "-d", type=str, default=None, help="Dataset directory path")
|
||||||
args =parser .parse_args ()
|
parser.add_argument("--force", "-f", action="store_true", help="Force re-processing of files even if marked as processed")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args .dir :
|
# Check vector store mode
|
||||||
dataset_dir =args .dir
|
use_atlas = os.environ.get("ATLAS_VECTORS", "false").lower() == "true"
|
||||||
else :
|
store_name = "MongoDB Atlas Vector Search" if use_atlas else "ChromaDB"
|
||||||
dataset_dir =os .path .join (os .path .dirname (__file__ ),'../dataset')
|
print(f"--- Vector Store Mode: {store_name} ---")
|
||||||
|
|
||||||
populate_from_dataset (dataset_dir ,category =args .category )
|
if args.dir:
|
||||||
|
dataset_dir = args.dir
|
||||||
|
else:
|
||||||
|
dataset_dir = os.path.join(os.path.dirname(__file__), '../dataset')
|
||||||
|
|
||||||
|
# Note: We need to pass force flag to populate_from_dataset ideally,
|
||||||
|
# but the function signature doesn't have it. I'll modify the function signature too.
|
||||||
|
populate_from_dataset(dataset_dir, category=args.category, force=args.force)
|
||||||
|
|||||||
81
backend/src/chroma/chroma_store.py
Normal file
81
backend/src/chroma/chroma_store.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import chromadb
|
||||||
|
import os
|
||||||
|
|
||||||
|
CHROMA_HOST = os.environ.get("CHROMA_HOST", "http://chroma.sirblob.co")
|
||||||
|
COLLECTION_NAME = "rag_documents"
|
||||||
|
|
||||||
|
_client =None
|
||||||
|
|
||||||
|
def get_chroma_client ():
|
||||||
|
global _client
|
||||||
|
if _client is None :
|
||||||
|
_client =chromadb .HttpClient (host =CHROMA_HOST )
|
||||||
|
return _client
|
||||||
|
|
||||||
|
def get_collection (collection_name =COLLECTION_NAME ):
|
||||||
|
client =get_chroma_client ()
|
||||||
|
return client .get_or_create_collection (name =collection_name )
|
||||||
|
|
||||||
|
def insert_documents (texts ,embeddings ,collection_name =COLLECTION_NAME ,metadata_list =None ):
|
||||||
|
collection =get_collection (collection_name )
|
||||||
|
|
||||||
|
ids =[f"doc_{i }_{hash (text )}"for i ,text in enumerate (texts )]
|
||||||
|
|
||||||
|
if metadata_list :
|
||||||
|
collection .add (
|
||||||
|
ids =ids ,
|
||||||
|
embeddings =embeddings ,
|
||||||
|
documents =texts ,
|
||||||
|
metadatas =metadata_list
|
||||||
|
)
|
||||||
|
else :
|
||||||
|
collection .add (
|
||||||
|
ids =ids ,
|
||||||
|
embeddings =embeddings ,
|
||||||
|
documents =texts
|
||||||
|
)
|
||||||
|
|
||||||
|
return len (texts )
|
||||||
|
|
||||||
|
def search_documents (query_embedding ,collection_name =COLLECTION_NAME ,num_results =5 ,filter_metadata =None ):
|
||||||
|
collection =get_collection (collection_name )
|
||||||
|
|
||||||
|
query_params ={
|
||||||
|
"query_embeddings":[query_embedding ],
|
||||||
|
"n_results":num_results
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter_metadata :
|
||||||
|
query_params ["where"]=filter_metadata
|
||||||
|
|
||||||
|
results =collection .query (**query_params )
|
||||||
|
|
||||||
|
output =[]
|
||||||
|
if results and results ["documents"]:
|
||||||
|
for i ,doc in enumerate (results ["documents"][0 ]):
|
||||||
|
score =results ["distances"][0 ][i ]if "distances"in results else None
|
||||||
|
meta =results ["metadatas"][0 ][i ]if "metadatas"in results else {}
|
||||||
|
output .append ({
|
||||||
|
"text":doc ,
|
||||||
|
"score":score ,
|
||||||
|
"metadata":meta
|
||||||
|
})
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
def delete_documents_by_source (source_file ,collection_name =COLLECTION_NAME ):
|
||||||
|
collection =get_collection (collection_name )
|
||||||
|
results =collection .get (where ={"source":source_file })
|
||||||
|
if results ["ids"]:
|
||||||
|
collection .delete (ids =results ["ids"])
|
||||||
|
return len (results ["ids"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_all_metadatas (collection_name =COLLECTION_NAME ,limit =None ):
|
||||||
|
collection =get_collection (collection_name )
|
||||||
|
|
||||||
|
if limit :
|
||||||
|
results =collection .get (include =["metadatas"],limit =limit )
|
||||||
|
else :
|
||||||
|
results =collection .get (include =["metadatas"])
|
||||||
|
return results ["metadatas"]if results and "metadatas"in results else []
|
||||||
@@ -1,80 +1,35 @@
|
|||||||
import chromadb
|
import os
|
||||||
|
from . import chroma_store
|
||||||
|
from ..mongo import vector_store as mongo_store
|
||||||
|
|
||||||
CHROMA_HOST ="http://chroma.sirblob.co"
|
def _use_atlas():
|
||||||
COLLECTION_NAME ="rag_documents"
|
return os.environ.get("ATLAS_VECTORS", "false").lower() == "true"
|
||||||
|
|
||||||
_client =None
|
def get_chroma_client():
|
||||||
|
# Only used by chroma-specific things if external
|
||||||
|
return chroma_store.get_chroma_client()
|
||||||
|
|
||||||
def get_chroma_client ():
|
def get_collection(collection_name=chroma_store.COLLECTION_NAME):
|
||||||
global _client
|
if _use_atlas():
|
||||||
if _client is None :
|
return mongo_store.get_collection(collection_name)
|
||||||
_client =chromadb .HttpClient (host =CHROMA_HOST )
|
return chroma_store.get_collection(collection_name)
|
||||||
return _client
|
|
||||||
|
|
||||||
def get_collection (collection_name =COLLECTION_NAME ):
|
def insert_documents(texts, embeddings, collection_name=chroma_store.COLLECTION_NAME, metadata_list=None):
|
||||||
client =get_chroma_client ()
|
if _use_atlas():
|
||||||
return client .get_or_create_collection (name =collection_name )
|
return mongo_store.insert_documents(texts, embeddings, collection_name, metadata_list)
|
||||||
|
return chroma_store.insert_documents(texts, embeddings, collection_name, metadata_list)
|
||||||
|
|
||||||
def insert_documents (texts ,embeddings ,collection_name =COLLECTION_NAME ,metadata_list =None ):
|
def search_documents(query_embedding, collection_name=chroma_store.COLLECTION_NAME, num_results=5, filter_metadata=None):
|
||||||
collection =get_collection (collection_name )
|
if _use_atlas():
|
||||||
|
return mongo_store.search_documents(query_embedding, collection_name, num_results, filter_metadata)
|
||||||
|
return chroma_store.search_documents(query_embedding, collection_name, num_results, filter_metadata)
|
||||||
|
|
||||||
ids =[f"doc_{i }_{hash (text )}"for i ,text in enumerate (texts )]
|
def delete_documents_by_source(source_file, collection_name=chroma_store.COLLECTION_NAME):
|
||||||
|
if _use_atlas():
|
||||||
|
return mongo_store.delete_documents_by_source(source_file, collection_name)
|
||||||
|
return chroma_store.delete_documents_by_source(source_file, collection_name)
|
||||||
|
|
||||||
if metadata_list :
|
def get_all_metadatas(collection_name=chroma_store.COLLECTION_NAME, limit=None):
|
||||||
collection .add (
|
if _use_atlas():
|
||||||
ids =ids ,
|
return mongo_store.get_all_metadatas(collection_name, limit)
|
||||||
embeddings =embeddings ,
|
return chroma_store.get_all_metadatas(collection_name, limit)
|
||||||
documents =texts ,
|
|
||||||
metadatas =metadata_list
|
|
||||||
)
|
|
||||||
else :
|
|
||||||
collection .add (
|
|
||||||
ids =ids ,
|
|
||||||
embeddings =embeddings ,
|
|
||||||
documents =texts
|
|
||||||
)
|
|
||||||
|
|
||||||
return len (texts )
|
|
||||||
|
|
||||||
def search_documents (query_embedding ,collection_name =COLLECTION_NAME ,num_results =5 ,filter_metadata =None ):
|
|
||||||
collection =get_collection (collection_name )
|
|
||||||
|
|
||||||
query_params ={
|
|
||||||
"query_embeddings":[query_embedding ],
|
|
||||||
"n_results":num_results
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter_metadata :
|
|
||||||
query_params ["where"]=filter_metadata
|
|
||||||
|
|
||||||
results =collection .query (**query_params )
|
|
||||||
|
|
||||||
output =[]
|
|
||||||
if results and results ["documents"]:
|
|
||||||
for i ,doc in enumerate (results ["documents"][0 ]):
|
|
||||||
score =results ["distances"][0 ][i ]if "distances"in results else None
|
|
||||||
meta =results ["metadatas"][0 ][i ]if "metadatas"in results else {}
|
|
||||||
output .append ({
|
|
||||||
"text":doc ,
|
|
||||||
"score":score ,
|
|
||||||
"metadata":meta
|
|
||||||
})
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
def delete_documents_by_source (source_file ,collection_name =COLLECTION_NAME ):
|
|
||||||
collection =get_collection (collection_name )
|
|
||||||
results =collection .get (where ={"source":source_file })
|
|
||||||
if results ["ids"]:
|
|
||||||
collection .delete (ids =results ["ids"])
|
|
||||||
return len (results ["ids"])
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def get_all_metadatas (collection_name =COLLECTION_NAME ,limit =None ):
|
|
||||||
collection =get_collection (collection_name )
|
|
||||||
|
|
||||||
if limit :
|
|
||||||
results =collection .get (include =["metadatas"],limit =limit )
|
|
||||||
else :
|
|
||||||
results =collection .get (include =["metadatas"])
|
|
||||||
return results ["metadatas"]if results and "metadatas"in results else []
|
|
||||||
|
|||||||
@@ -1,49 +1,142 @@
|
|||||||
|
import os
|
||||||
from .connection import get_mongo_client
|
from .connection import get_mongo_client
|
||||||
|
|
||||||
def insert_rag_documents (documents ,collection_name ="rag_documents",db_name ="vectors_db"):
|
DB_NAME = "ethix_vectors"
|
||||||
client =get_mongo_client ()
|
# Default collection name to match Chroma's default
|
||||||
db =client .get_database (db_name )
|
DEFAULT_COLLECTION_NAME = "rag_documents"
|
||||||
collection =db [collection_name ]
|
|
||||||
|
|
||||||
if documents :
|
def get_collection(collection_name=DEFAULT_COLLECTION_NAME):
|
||||||
result =collection .insert_many (documents )
|
client = get_mongo_client()
|
||||||
return len (result .inserted_ids )
|
db = client[DB_NAME]
|
||||||
|
return db[collection_name]
|
||||||
|
|
||||||
|
def insert_documents(texts, embeddings, collection_name=DEFAULT_COLLECTION_NAME, metadata_list=None):
|
||||||
|
collection = get_collection(collection_name)
|
||||||
|
|
||||||
|
docs = []
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
doc = {
|
||||||
|
"text": text,
|
||||||
|
"embedding": embeddings[i],
|
||||||
|
"metadata": metadata_list[i] if metadata_list else {}
|
||||||
|
}
|
||||||
|
# Flatten metadata for easier filtering if needed, or keep in 'metadata' subdoc
|
||||||
|
# Atlas Vector Search usually suggests keeping it cleaner, but 'metadata' field is fine
|
||||||
|
# provided index is on metadata.field
|
||||||
|
|
||||||
|
# Add source file to top level for easier deletion
|
||||||
|
if metadata_list and "source" in metadata_list[i]:
|
||||||
|
doc["source"] = metadata_list[i]["source"]
|
||||||
|
|
||||||
|
docs.append(doc)
|
||||||
|
|
||||||
|
if docs:
|
||||||
|
result = collection.insert_many(docs)
|
||||||
|
return len(result.inserted_ids)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def search_rag_documents (query_embedding ,collection_name ="rag_documents",db_name ="vectors_db",num_results =5 ):
|
def search_documents(query_embedding, collection_name=DEFAULT_COLLECTION_NAME, num_results=5, filter_metadata=None):
|
||||||
client =get_mongo_client ()
|
collection = get_collection(collection_name)
|
||||||
db =client .get_database (db_name )
|
|
||||||
collection =db [collection_name ]
|
|
||||||
|
|
||||||
pipeline =[
|
# Construct Atlas Search Aggregation
|
||||||
{
|
# Note: 'index' name depends on what user created. Default is often 'default' or 'vector_index'.
|
||||||
"$vectorSearch":{
|
# We'll use 'vector_index' as a convention.
|
||||||
"index":"vector_index",
|
|
||||||
"path":"embedding",
|
# Filter construction
|
||||||
"queryVector":query_embedding ,
|
pre_filter = None
|
||||||
"numCandidates":num_results *10 ,
|
if filter_metadata:
|
||||||
"limit":num_results
|
# Atlas Search filter syntax is different from simple match
|
||||||
|
# It requires MQL (Mongo Query Language) match or compound operators
|
||||||
|
# For simplicity in $vectorSearch, strict filters are passed in 'filter' field (if serverless/v2)
|
||||||
|
# or separate match stage.
|
||||||
|
|
||||||
|
# Modern Atlas Vector Search ($vectorSearch) supports 'filter' inside the operator
|
||||||
|
|
||||||
|
must_clauses = []
|
||||||
|
for k, v in filter_metadata.items():
|
||||||
|
# Assuming filter_metadata matches metadata fields inside 'metadata' object
|
||||||
|
# or we map specific known fields.
|
||||||
|
# In Chroma, filters are flat. In insert above, we put them in 'metadata'.
|
||||||
|
must_clauses.append({
|
||||||
|
"term": {
|
||||||
|
"path": f"metadata.{k}",
|
||||||
|
"value": v
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
|
|
||||||
|
if must_clauses:
|
||||||
|
if len(must_clauses) == 1:
|
||||||
|
pre_filter = must_clauses[0]
|
||||||
|
else:
|
||||||
|
pre_filter = {
|
||||||
|
"compound": {
|
||||||
|
"filter": must_clauses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline = [
|
||||||
{
|
{
|
||||||
"$project":{
|
"$vectorSearch": {
|
||||||
"_id":0 ,
|
"index": "vector_index",
|
||||||
"text":1 ,
|
"path": "embedding",
|
||||||
"score":{"$meta":"vectorSearchScore"}
|
"queryVector": query_embedding,
|
||||||
|
"numCandidates": num_results * 10,
|
||||||
|
"limit": num_results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
return list (collection .aggregate (pipeline ))
|
# Apply filter if exists
|
||||||
|
if pre_filter:
|
||||||
|
pipeline[0]["$vectorSearch"]["filter"] = pre_filter
|
||||||
|
|
||||||
def is_file_processed (filename ,log_collection ="ingested_files",db_name ="vectors_db"):
|
pipeline.append({
|
||||||
client =get_mongo_client ()
|
"$project": {
|
||||||
db =client .get_database (db_name )
|
"_id": 0,
|
||||||
collection =db [log_collection ]
|
"text": 1,
|
||||||
return collection .find_one ({"filename":filename })is not None
|
"metadata": 1,
|
||||||
|
"score": {"$meta": "vectorSearchScore"}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
def log_processed_file (filename ,log_collection ="ingested_files",db_name ="vectors_db"):
|
try:
|
||||||
client =get_mongo_client ()
|
results = list(collection.aggregate(pipeline))
|
||||||
db =client .get_database (db_name )
|
|
||||||
collection =db [log_collection ]
|
# Format to match Chroma output style
|
||||||
collection .insert_one ({"filename":filename ,"processed_at":1 })
|
# Chroma: [{'text': ..., 'score': ..., 'metadata': ...}]
|
||||||
|
output = []
|
||||||
|
for doc in results:
|
||||||
|
output.append({
|
||||||
|
"text": doc.get("text", ""),
|
||||||
|
"score": doc.get("score", 0),
|
||||||
|
"metadata": doc.get("metadata", {})
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Atlas Vector Search Error: {e}")
|
||||||
|
# Fallback to empty if index doesn't exist etc
|
||||||
|
return []
|
||||||
|
|
||||||
|
def delete_documents_by_source(source_file, collection_name=DEFAULT_COLLECTION_NAME):
|
||||||
|
collection = get_collection(collection_name)
|
||||||
|
# We added 'source' to top level in insert_documents for exactly this efficiency
|
||||||
|
result = collection.delete_many({"source": source_file})
|
||||||
|
return result.deleted_count
|
||||||
|
|
||||||
|
def get_all_metadatas(collection_name=DEFAULT_COLLECTION_NAME, limit=None):
|
||||||
|
collection = get_collection(collection_name)
|
||||||
|
|
||||||
|
cursor = collection.find({}, {"metadata": 1, "source": 1, "_id": 0})
|
||||||
|
if limit:
|
||||||
|
cursor = cursor.limit(limit)
|
||||||
|
|
||||||
|
metadatas = []
|
||||||
|
for doc in cursor:
|
||||||
|
meta = doc.get("metadata", {})
|
||||||
|
# Ensure 'source' is in metadata if we pulled it out
|
||||||
|
if "source" in doc and "source" not in meta:
|
||||||
|
meta["source"] = doc["source"]
|
||||||
|
metadatas.append(meta)
|
||||||
|
|
||||||
|
return metadatas
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict ,List ,Optional ,Union
|
from typing import Dict ,List ,Optional ,Union
|
||||||
@@ -11,8 +12,8 @@ except ImportError :
|
|||||||
OLLAMA_AVAILABLE =False
|
OLLAMA_AVAILABLE =False
|
||||||
print ("Ollama not installed. Run: pip install ollama")
|
print ("Ollama not installed. Run: pip install ollama")
|
||||||
|
|
||||||
DEFAULT_HOST ="https://ollama.sirblob.co"
|
DEFAULT_HOST = os.environ.get("OLLAMA_HOST", "https://ollama.sirblob.co")
|
||||||
DEFAULT_MODEL ="ministral-3:latest"
|
DEFAULT_MODEL = "ministral-3:latest"
|
||||||
|
|
||||||
DEFAULT_PROMPT ="""Analyze this image and identify ALL logos, brand names, and company names visible.
|
DEFAULT_PROMPT ="""Analyze this image and identify ALL logos, brand names, and company names visible.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import ollama
|
import ollama
|
||||||
import os
|
import os
|
||||||
|
|
||||||
client =ollama .Client (host ="https://ollama.sirblob.co")
|
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "https://ollama.sirblob.co")
|
||||||
DEFAULT_MODEL ="nomic-embed-text:latest"
|
client = ollama.Client(host=OLLAMA_HOST)
|
||||||
|
DEFAULT_MODEL = "nomic-embed-text:latest"
|
||||||
|
|
||||||
def get_embedding (text ,model =DEFAULT_MODEL ):
|
def get_embedding (text ,model =DEFAULT_MODEL ):
|
||||||
try :
|
try :
|
||||||
|
|||||||
@@ -1,37 +1,69 @@
|
|||||||
from flask import Blueprint ,request ,jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from src .rag .gemeni import GeminiClient
|
from src.rag.gemeni import GeminiClient
|
||||||
from src .gemini import ask_gemini_with_rag
|
from src.gemini import ask_gemini_with_rag
|
||||||
|
from src.chroma.vector_store import search_documents
|
||||||
|
from src.rag.embeddings import get_embedding
|
||||||
|
|
||||||
gemini_bp =Blueprint ('gemini',__name__ )
|
gemini_bp = Blueprint('gemini', __name__)
|
||||||
brain =None
|
brain = None
|
||||||
|
|
||||||
def get_brain ():
|
def get_brain():
|
||||||
global brain
|
global brain
|
||||||
if brain is None :
|
if brain is None:
|
||||||
brain =GeminiClient ()
|
brain = GeminiClient()
|
||||||
return brain
|
return brain
|
||||||
|
|
||||||
@gemini_bp .route ('/ask',methods =['POST'])
|
@gemini_bp.route('/ask', methods=['POST'])
|
||||||
def ask ():
|
def ask():
|
||||||
data =request .json
|
data = request.json
|
||||||
prompt =data .get ("prompt")
|
prompt = data.get("prompt")
|
||||||
context =data .get ("context","")
|
context = data.get("context", "")
|
||||||
|
|
||||||
if not prompt :
|
if not prompt:
|
||||||
return jsonify ({"error":"No prompt provided"}),400
|
return jsonify({"error": "No prompt provided"}), 400
|
||||||
|
|
||||||
try :
|
try:
|
||||||
client =get_brain ()
|
# Step 1: Retrieve relevant context from ChromaDB (RAG)
|
||||||
response =client .ask (prompt ,context )
|
print(f"Generating embedding for prompt: {prompt}")
|
||||||
return jsonify ({
|
query_embedding = get_embedding(prompt)
|
||||||
"status":"success",
|
|
||||||
"reply":response
|
print("Searching ChromaDB for context...")
|
||||||
|
search_results = search_documents(query_embedding, num_results=15)
|
||||||
|
|
||||||
|
retrieved_context = ""
|
||||||
|
if search_results:
|
||||||
|
print(f"Found {len(search_results)} documents.")
|
||||||
|
retrieved_context = "RELEVANT INFORMATION FROM DATABASE:\n"
|
||||||
|
for res in search_results:
|
||||||
|
# Include metadata if useful, e.g. brand name or date
|
||||||
|
meta = res.get('metadata', {})
|
||||||
|
source_info = f"[Source: {meta.get('type', 'doc')} - {meta.get('product_name', 'Unknown')}]"
|
||||||
|
retrieved_context += f"{source_info}\n{res['text']}\n\n"
|
||||||
|
else:
|
||||||
|
print("No relevant documents found.")
|
||||||
|
|
||||||
|
# Step 2: Combine manual context (if any) with retrieved context
|
||||||
|
full_context = context
|
||||||
|
if retrieved_context:
|
||||||
|
if full_context:
|
||||||
|
full_context += "\n\n" + retrieved_context
|
||||||
|
else:
|
||||||
|
full_context = retrieved_context
|
||||||
|
|
||||||
|
# Step 3: Ask Gemini
|
||||||
|
client = get_brain()
|
||||||
|
response = client.ask(prompt, full_context)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"status": "success",
|
||||||
|
"reply": response
|
||||||
})
|
})
|
||||||
except Exception as e :
|
except Exception as e:
|
||||||
return jsonify ({
|
print(f"Error in RAG flow: {e}")
|
||||||
"status":"error",
|
return jsonify({
|
||||||
"message":str (e )
|
"status": "error",
|
||||||
}),500
|
"message": str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
@gemini_bp .route ('/rag',methods =['POST'])
|
@gemini_bp .route ('/rag',methods =['POST'])
|
||||||
def rag ():
|
def rag ():
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ This incident has been documented for future reference and to help inform sustai
|
|||||||
metadata_list =[metadata ]
|
metadata_list =[metadata ]
|
||||||
)
|
)
|
||||||
|
|
||||||
print (f"✓ Incident #{incident_id } saved to ChromaDB for AI chat context")
|
print (f"✓ Incident #{incident_id } saved to Vector Store for AI chat context")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,220 @@
|
|||||||
from flask import Blueprint ,jsonify ,request
|
from flask import Blueprint ,jsonify ,request
|
||||||
from src .chroma .vector_store import get_all_metadatas ,search_documents
|
from src .chroma .vector_store import get_all_metadatas ,search_documents, insert_documents
|
||||||
from src .rag .embeddings import get_embedding
|
from src .rag .embeddings import get_embedding, get_embeddings_batch
|
||||||
|
from src.rag.ingest import load_pdf, chunk_text
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from google import genai
|
||||||
|
|
||||||
reports_bp =Blueprint ('reports',__name__ )
|
reports_bp =Blueprint ('reports',__name__ )
|
||||||
|
|
||||||
|
# Validation prompt for checking if PDF is a legitimate environmental report
|
||||||
|
REPORT_VALIDATION_PROMPT = """You are an expert document classifier. Analyze the following text extracted from a PDF and determine if it is a legitimate corporate environmental/sustainability report.
|
||||||
|
|
||||||
|
A legitimate report should contain:
|
||||||
|
1. Company name and branding
|
||||||
|
2. Environmental metrics (emissions, energy usage, waste, water)
|
||||||
|
3. Sustainability goals or targets
|
||||||
|
4. Time periods/years covered
|
||||||
|
5. Professional report structure
|
||||||
|
|
||||||
|
Analyze this text and respond in JSON format:
|
||||||
|
{
|
||||||
|
"is_valid_report": true/false,
|
||||||
|
"confidence": "high"/"medium"/"low",
|
||||||
|
"company_name": "extracted company name or null",
|
||||||
|
"report_year": "extracted year or null",
|
||||||
|
"report_type": "sustainability/environmental/impact/ESG/unknown",
|
||||||
|
"sector": "Tech/Energy/Automotive/Aerospace/Retail/Manufacturing/Other",
|
||||||
|
"key_topics": ["list of main topics covered"],
|
||||||
|
"reasoning": "brief explanation of your assessment"
|
||||||
|
}
|
||||||
|
|
||||||
|
TEXT TO ANALYZE:
|
||||||
|
"""
|
||||||
|
|
||||||
|
def validate_report_with_ai(text_content):
|
||||||
|
"""Use Gemini to validate if the PDF is a legitimate environmental report."""
|
||||||
|
try:
|
||||||
|
# Take first 15000 chars for validation (enough to assess legitimacy)
|
||||||
|
sample_text = text_content[:15000] if len(text_content) > 15000 else text_content
|
||||||
|
|
||||||
|
client = genai.Client(api_key=os.environ.get("GOOGLE_API_KEY"))
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model="gemini-2.0-flash",
|
||||||
|
contents=f"{REPORT_VALIDATION_PROMPT}\n{sample_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse the JSON response
|
||||||
|
response_text = response.text
|
||||||
|
|
||||||
|
# Extract JSON from response
|
||||||
|
import json
|
||||||
|
json_match = re.search(r'\{[\s\S]*\}', response_text)
|
||||||
|
if json_match:
|
||||||
|
return json.loads(json_match.group())
|
||||||
|
|
||||||
|
return {"is_valid_report": False, "reasoning": "Could not parse AI response"}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"AI validation error: {e}")
|
||||||
|
return {"is_valid_report": False, "reasoning": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@reports_bp.route('/upload', methods=['POST'])
|
||||||
|
def upload_report():
|
||||||
|
"""Upload and verify a company environmental report PDF."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
pdf_data = data.get('pdf_data')
|
||||||
|
company_name = data.get('company_name', '').strip()
|
||||||
|
|
||||||
|
if not pdf_data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'step': 'validation',
|
||||||
|
'message': 'No PDF data provided'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Step 1: Decode PDF
|
||||||
|
try:
|
||||||
|
# Remove data URL prefix if present
|
||||||
|
if ',' in pdf_data:
|
||||||
|
pdf_data = pdf_data.split(',')[1]
|
||||||
|
pdf_bytes = base64.b64decode(pdf_data)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'step': 'decode',
|
||||||
|
'message': f'Failed to decode PDF: {str(e)}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Step 2: Extract text from PDF
|
||||||
|
import io
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_file = io.BytesIO(pdf_bytes)
|
||||||
|
reader = PdfReader(pdf_file)
|
||||||
|
|
||||||
|
all_text = ""
|
||||||
|
page_count = len(reader.pages)
|
||||||
|
|
||||||
|
for page in reader.pages:
|
||||||
|
text = page.extract_text()
|
||||||
|
if text:
|
||||||
|
all_text += text + "\n\n"
|
||||||
|
|
||||||
|
if len(all_text.strip()) < 500:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'step': 'extraction',
|
||||||
|
'message': 'PDF contains insufficient text content. It may be image-based or corrupted.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'step': 'extraction',
|
||||||
|
'message': f'Failed to extract text from PDF: {str(e)}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Step 3: Validate with AI
|
||||||
|
validation = validate_report_with_ai(all_text)
|
||||||
|
|
||||||
|
if not validation.get('is_valid_report', False):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'rejected',
|
||||||
|
'step': 'validation',
|
||||||
|
'message': 'This does not appear to be a legitimate environmental/sustainability report.',
|
||||||
|
'validation': validation
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Step 4: Generate filename
|
||||||
|
detected_company = validation.get('company_name') or company_name or 'Unknown'
|
||||||
|
detected_year = validation.get('report_year') or '2024'
|
||||||
|
report_type = validation.get('report_type', 'sustainability')
|
||||||
|
sector = validation.get('sector', 'Other')
|
||||||
|
|
||||||
|
# Clean company name for filename
|
||||||
|
safe_company = re.sub(r'[^a-zA-Z0-9]', '-', detected_company).lower()
|
||||||
|
filename = f"{safe_company}-{detected_year}-{report_type}-report.pdf"
|
||||||
|
|
||||||
|
# Step 5: Save PDF to dataset folder
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
dataset_dir = os.path.join(current_dir, '..', '..', 'dataset')
|
||||||
|
os.makedirs(dataset_dir, exist_ok=True)
|
||||||
|
|
||||||
|
file_path = os.path.join(dataset_dir, filename)
|
||||||
|
|
||||||
|
# Check if file already exists
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
# Add timestamp to make unique
|
||||||
|
import time
|
||||||
|
timestamp = int(time.time())
|
||||||
|
filename = f"{safe_company}-{detected_year}-{report_type}-report-{timestamp}.pdf"
|
||||||
|
file_path = os.path.join(dataset_dir, filename)
|
||||||
|
|
||||||
|
with open(file_path, 'wb') as f:
|
||||||
|
f.write(pdf_bytes)
|
||||||
|
|
||||||
|
# Step 6: Chunk and embed for RAG
|
||||||
|
chunks = chunk_text(all_text, target_length=2000, overlap=100)
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
chunks = [all_text[:4000]]
|
||||||
|
|
||||||
|
# Get embeddings in batches
|
||||||
|
embeddings = get_embeddings_batch(chunks)
|
||||||
|
|
||||||
|
# Create metadata for each chunk
|
||||||
|
metadata_list = []
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
metadata_list.append({
|
||||||
|
'source': filename,
|
||||||
|
'company_name': detected_company,
|
||||||
|
'year': detected_year,
|
||||||
|
'sector': sector,
|
||||||
|
'report_type': report_type,
|
||||||
|
'chunk_index': i,
|
||||||
|
'total_chunks': len(chunks),
|
||||||
|
'page_count': page_count,
|
||||||
|
'type': 'company_report'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Insert into ChromaDB
|
||||||
|
insert_documents(chunks, embeddings, metadata_list=metadata_list)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Report verified and uploaded successfully',
|
||||||
|
'filename': filename,
|
||||||
|
'validation': validation,
|
||||||
|
'stats': {
|
||||||
|
'page_count': page_count,
|
||||||
|
'text_length': len(all_text),
|
||||||
|
'chunks_created': len(chunks),
|
||||||
|
'company_name': detected_company,
|
||||||
|
'year': detected_year,
|
||||||
|
'sector': sector,
|
||||||
|
'report_type': report_type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Upload error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'step': 'unknown',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@reports_bp .route ('/',methods =['GET'])
|
@reports_bp .route ('/',methods =['GET'])
|
||||||
def get_reports ():
|
def get_reports ():
|
||||||
try :
|
try :
|
||||||
|
|
||||||
|
|
||||||
metadatas =get_all_metadatas ()
|
metadatas =get_all_metadatas ()
|
||||||
|
|
||||||
unique_reports ={}
|
unique_reports ={}
|
||||||
@@ -24,12 +230,6 @@ def get_reports ():
|
|||||||
|
|
||||||
|
|
||||||
if filename not in unique_reports :
|
if filename not in unique_reports :
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
company_name ="Unknown"
|
company_name ="Unknown"
|
||||||
year ="N/A"
|
year ="N/A"
|
||||||
sector ="Other"
|
sector ="Other"
|
||||||
@@ -106,15 +306,10 @@ def search_reports ():
|
|||||||
|
|
||||||
try :
|
try :
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
query_embedding =get_embedding (query )
|
query_embedding =get_embedding (query )
|
||||||
|
results = search_documents (query_embedding ,num_results =50 )
|
||||||
|
|
||||||
|
query_lower = query .lower ()
|
||||||
results =search_documents (query_embedding ,num_results =50 )
|
|
||||||
|
|
||||||
query_lower =query .lower ()
|
|
||||||
|
|
||||||
|
|
||||||
def extract_company_info (filename ):
|
def extract_company_info (filename ):
|
||||||
company_name ="Unknown"
|
company_name ="Unknown"
|
||||||
@@ -224,10 +419,55 @@ def view_report_file (filename ):
|
|||||||
import os
|
import os
|
||||||
from flask import send_from_directory
|
from flask import send_from_directory
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
current_dir =os .path .dirname (os .path .abspath (__file__ ))
|
current_dir =os .path .dirname (os .path .abspath (__file__ ))
|
||||||
dataset_dir =os .path .join (current_dir ,'..','..','dataset')
|
dataset_dir =os .path .join (current_dir ,'..','..','dataset')
|
||||||
|
|
||||||
return send_from_directory (dataset_dir ,filename )
|
return send_from_directory (dataset_dir ,filename )
|
||||||
|
|
||||||
|
|
||||||
|
@reports_bp.route('/stats', methods=['GET'])
|
||||||
|
def get_stats():
|
||||||
|
"""Get real statistics for the landing page"""
|
||||||
|
try:
|
||||||
|
# 1. Count Verified Reports (Company Reports)
|
||||||
|
metadatas = get_all_metadatas()
|
||||||
|
unique_reports = set()
|
||||||
|
unique_sectors = set()
|
||||||
|
|
||||||
|
for meta in metadatas:
|
||||||
|
filename = meta.get('source') or meta.get('filename')
|
||||||
|
# Check if it's a company report (not incident)
|
||||||
|
if filename and not filename.startswith('incident_') and meta.get('type') != 'incident_report':
|
||||||
|
unique_reports.add(filename)
|
||||||
|
|
||||||
|
# Count sectors
|
||||||
|
sector = meta.get('sector')
|
||||||
|
if sector and sector != 'Other':
|
||||||
|
unique_sectors.add(sector)
|
||||||
|
|
||||||
|
verified_reports_count = len(unique_reports)
|
||||||
|
active_sectors_count = len(unique_sectors)
|
||||||
|
|
||||||
|
# Fallback if no specific sectors found but we have reports
|
||||||
|
if active_sectors_count == 0 and verified_reports_count > 0:
|
||||||
|
active_sectors_count = 1
|
||||||
|
|
||||||
|
# 2. Count Products Scanned / Incidents
|
||||||
|
from src.mongo.connection import get_mongo_client
|
||||||
|
client = get_mongo_client()
|
||||||
|
db = client["ethix"]
|
||||||
|
# Count all incidents (users scanning products)
|
||||||
|
incidents_count = db["incidents"].count_documents({})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"verified_reports": verified_reports_count,
|
||||||
|
"incidents_reported": incidents_count,
|
||||||
|
"active_sectors": active_sectors_count
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching stats: {e}")
|
||||||
|
return jsonify({
|
||||||
|
"verified_reports": 0,
|
||||||
|
"incidents_reported": 0,
|
||||||
|
"active_sectors": 0
|
||||||
|
})
|
||||||
|
|||||||
51
backend/src/scripts/sync_incidents.py
Normal file
51
backend/src/scripts/sync_incidents.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Add the backend directory to sys.path so we can import src modules
|
||||||
|
sys.path.append(os.path.join(os.path.dirname(__file__), '../../'))
|
||||||
|
|
||||||
|
from src.mongo.connection import get_mongo_client
|
||||||
|
from src.routes.incidents import save_to_chromadb
|
||||||
|
|
||||||
|
def sync_incidents():
|
||||||
|
print("Starting sync of incidents from MongoDB to ChromaDB...")
|
||||||
|
|
||||||
|
client = get_mongo_client()
|
||||||
|
db = client["ethix"]
|
||||||
|
collection = db["incidents"]
|
||||||
|
|
||||||
|
# Find all confirmed greenwashing incidents
|
||||||
|
cursor = collection.find({"is_greenwashing": True})
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
synced = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for incident in cursor:
|
||||||
|
count += 1
|
||||||
|
incident_id = str(incident["_id"])
|
||||||
|
product_name = incident.get("product_name", "Unknown")
|
||||||
|
print(f"Processing Incident #{incident_id} ({product_name})...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert ObjectId to string for the function if needed,
|
||||||
|
# but save_to_chromadb expects the dict and the id string.
|
||||||
|
# Convert _id in dict to string just in case
|
||||||
|
incident["_id"] = incident_id
|
||||||
|
|
||||||
|
save_to_chromadb(incident, incident_id)
|
||||||
|
synced += 1
|
||||||
|
print(f" -> Successfully synced.")
|
||||||
|
except Exception as e:
|
||||||
|
errors += 1
|
||||||
|
print(f" -> FAILED to sync: {e}")
|
||||||
|
|
||||||
|
print(f"\nSync Complete.")
|
||||||
|
print(f"Total processed: {count}")
|
||||||
|
print(f"Successfully synced: {synced}")
|
||||||
|
print(f"Errors: {errors}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sync_incidents()
|
||||||
@@ -1,7 +1,112 @@
|
|||||||
# Tauri + SvelteKit + TypeScript
|
# Ethix Frontend
|
||||||
|
|
||||||
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
|
A SvelteKit web application for the Ethix greenwashing detection platform. Scan products, report misleading environmental claims, and chat with an AI assistant about sustainability.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
## Technology Stack
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
| Component | Technology |
|
||||||
|
|-----------|------------|
|
||||||
|
| Framework | SvelteKit |
|
||||||
|
| UI Library | Svelte 5 |
|
||||||
|
| Language | TypeScript |
|
||||||
|
| Styling | TailwindCSS 4 |
|
||||||
|
| Build Tool | Vite |
|
||||||
|
| Desktop App | Tauri |
|
||||||
|
| Icons | Iconify |
|
||||||
|
| 3D Graphics | Three.js |
|
||||||
|
| Markdown | marked |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+ or Bun
|
||||||
|
- Backend server running on `http://localhost:5000`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Using Bun (recommended):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
# or
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:5173`.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Web Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
# or
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview the production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run preview
|
||||||
|
# or
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### Desktop Build (Tauri)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run tauri build
|
||||||
|
# or
|
||||||
|
npm run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Home Page
|
||||||
|
|
||||||
|
- Responsive design with separate mobile and web layouts
|
||||||
|
- Animated parallax landscape background
|
||||||
|
- Quick access to all main features
|
||||||
|
|
||||||
|
### AI Chat Assistant
|
||||||
|
|
||||||
|
- Powered by Google Gemini with RAG context
|
||||||
|
- Real-time conversation interface
|
||||||
|
- Sustainability and greenwashing expertise
|
||||||
|
- Message history within session
|
||||||
|
|
||||||
|
### Greenwashing Report Submission
|
||||||
|
|
||||||
|
- Two report types: Product Incident or Company Report
|
||||||
|
- Image upload for product evidence
|
||||||
|
- PDF upload for company sustainability reports
|
||||||
|
- Real-time analysis progress indicator
|
||||||
|
- Structured verdict display with confidence levels
|
||||||
|
|
||||||
|
### Catalogue Browser
|
||||||
|
|
||||||
|
- Browse company sustainability reports
|
||||||
|
- View user-submitted incidents
|
||||||
|
- Category filtering (Tech, Energy, Automotive, etc.)
|
||||||
|
- Semantic search functionality
|
||||||
|
- Pagination for large datasets
|
||||||
|
- Detailed modal views for reports and incidents
|
||||||
|
|
||||||
|
### Product Scanner
|
||||||
|
|
||||||
|
- Camera integration for scanning product labels
|
||||||
|
- Brand/logo detection via AI vision
|
||||||
|
- Direct report submission from scan results
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
name: "Goals",
|
name: "Goals",
|
||||||
route: "/community",
|
route: "/goal",
|
||||||
icon: "ri:flag-fill",
|
icon: "ri:flag-fill",
|
||||||
activeColor: "#34d399",
|
activeColor: "#34d399",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,7 +43,11 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<div class="logo-icon">
|
<div class="logo-icon">
|
||||||
<Icon icon="ri:leaf-fill" width="24" />
|
<img
|
||||||
|
src="/ethix-logo.png"
|
||||||
|
alt="Ethix Logo"
|
||||||
|
class="logo-img"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="app-name">Ethix</h1>
|
<h1 class="app-name">Ethix</h1>
|
||||||
@@ -213,13 +217,17 @@
|
|||||||
.logo-icon {
|
.logo-icon {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
background: linear-gradient(135deg, #4ade80, #22c55e);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #052e16;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 16px rgba(74, 222, 128, 0.4);
|
}
|
||||||
|
|
||||||
|
.logo-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-name {
|
.app-name {
|
||||||
|
|||||||
@@ -71,10 +71,10 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<Icon
|
<img
|
||||||
icon="ri:seedling-fill"
|
src="/ethix-logo.png"
|
||||||
width="24"
|
alt="Ethix Logo"
|
||||||
style="color: #34d399;"
|
class="avatar-logo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
@@ -222,6 +222,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-logo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-text {
|
.header-text {
|
||||||
|
|||||||
@@ -1,202 +1,132 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { fly, fade } from "svelte/transition";
|
|
||||||
import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte";
|
import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte";
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onProgressChange?: (progress: number) => void;
|
onProgressChange?: (progress: number) => void;
|
||||||
}
|
}
|
||||||
let { onProgressChange }: Props = $props();
|
let { onProgressChange }: Props = $props();
|
||||||
|
|
||||||
const news = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: "Ocean Cleanup Hits 500 Tons",
|
|
||||||
desc: "Major milestone reached in the Pacific cleanup initiative.",
|
|
||||||
date: "2h ago",
|
|
||||||
icon: "ri:ship-line",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Plastic Ban Starts Today",
|
|
||||||
desc: "Big cities are saying no to single-use plastics.",
|
|
||||||
date: "5h ago",
|
|
||||||
icon: "ri:prohibited-line",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Ethix Launches Globally",
|
|
||||||
desc: "Our platform is now available worldwide.",
|
|
||||||
date: "1d ago",
|
|
||||||
icon: "ri:global-line",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
title: "Scan It",
|
|
||||||
desc: "Point your camera at any product.",
|
|
||||||
icon: "ri:scan-2-line",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Get Real Info",
|
|
||||||
desc: "AI breaks down the real impact.",
|
|
||||||
icon: "ri:information-line",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Find Better",
|
|
||||||
desc: "See eco-friendly swaps instantly.",
|
|
||||||
icon: "ri:leaf-line",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Call It Out",
|
|
||||||
desc: "Report misleading green claims.",
|
|
||||||
icon: "ri:megaphone-line",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{ value: "50K+", label: "Scans" },
|
|
||||||
{ value: "12K", label: "Users" },
|
|
||||||
{ value: "98%", label: "Accuracy" },
|
|
||||||
{ value: "24/7", label: "Support" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let sceneProgress = $state(0);
|
let sceneProgress = $state(0);
|
||||||
let isEcoTheme = $derived(sceneProgress < 0.5);
|
let statsData = $state({
|
||||||
|
verified_reports: 0,
|
||||||
|
incidents_reported: 0,
|
||||||
|
active_sectors: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("http://localhost:5000/api/reports/stats");
|
||||||
|
const data = await res.json();
|
||||||
|
statsData = data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch stats", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchStats();
|
||||||
|
});
|
||||||
|
|
||||||
function handleProgressChange(progress: number) {
|
function handleProgressChange(progress: number) {
|
||||||
sceneProgress = progress;
|
sceneProgress = progress;
|
||||||
onProgressChange?.(progress);
|
onProgressChange?.(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
let scoreIndex = $state(0);
|
const features = [
|
||||||
const scores = [
|
|
||||||
{
|
{
|
||||||
label: "Fiji Water",
|
icon: "ri:camera-lens-line",
|
||||||
score: "94/100",
|
title: "Scan Products",
|
||||||
color: "#1ed760",
|
desc: "Point your camera at any product to analyze environmental claims.",
|
||||||
image: "/water-bottle.png",
|
|
||||||
scale: 0.7,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Plastic Bag",
|
icon: "ri:chat-3-line",
|
||||||
score: "12/100",
|
title: "Ask Questions",
|
||||||
color: "#e91429",
|
desc: "Chat with our AI about sustainability and eco-friendly alternatives.",
|
||||||
image: "/plastic-bag.png",
|
|
||||||
scale: 0.75,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Starbucks",
|
icon: "ri:alarm-warning-line",
|
||||||
score: "65/100",
|
title: "Report Issues",
|
||||||
color: "#f59b23",
|
desc: "Submit products or companies you suspect of greenwashing.",
|
||||||
image: "/coffee-cup.png",
|
},
|
||||||
scale: 1,
|
{
|
||||||
|
icon: "ri:folder-open-line",
|
||||||
|
title: "Browse Reports",
|
||||||
|
desc: "Explore verified sustainability reports and community findings.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
onMount(() => {
|
let stats = $derived([
|
||||||
const interval = setInterval(() => {
|
{
|
||||||
scoreIndex = (scoreIndex + 1) % scores.length;
|
value: statsData.incidents_reported.toString(),
|
||||||
}, 4000);
|
label: "Products Scanned",
|
||||||
return () => clearInterval(interval);
|
},
|
||||||
});
|
{
|
||||||
|
value: statsData.verified_reports.toString(),
|
||||||
|
label: "Verified Reports",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: statsData.active_sectors.toString(),
|
||||||
|
label: "Data Sectors",
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="web-home" class:industrial-theme={!isEcoTheme}>
|
<div class="web-home">
|
||||||
<ParallaxLandscape onProgressChange={handleProgressChange} />
|
<ParallaxLandscape onProgressChange={handleProgressChange} />
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="glass-card hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-badge">
|
<span class="hero-badge">
|
||||||
<iconify-icon icon="ri:eye-line" width="16"></iconify-icon>
|
<Icon icon="ri:leaf-line" width="16" />
|
||||||
<span>See the real impact</span>
|
Sustainability Made Simple
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<h1 class="hero-title">
|
<h1 class="hero-title">
|
||||||
Know What <br /> You Buy.
|
Know What<br />You're Buying
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="hero-desc">
|
<p class="hero-desc">
|
||||||
Scan a product. See if it's actually good for the planet. Find
|
Scan products, detect greenwashing, and make informed choices.
|
||||||
better alternatives if it's not. Simple as that.
|
Ethix helps you see through misleading environmental claims.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a href="/catalogue" class="cta-primary">
|
<a href="/catalogue" class="btn-primary">
|
||||||
<iconify-icon icon="ri:store-2-fill" width="20"
|
<Icon icon="ri:search-line" width="18" />
|
||||||
></iconify-icon>
|
Browse Catalogue
|
||||||
<span>Browse Database</span>
|
</a>
|
||||||
|
<a href="/chat" class="btn-secondary">
|
||||||
|
<Icon icon="ri:chat-3-line" width="18" />
|
||||||
|
Chat with AI
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-visual">
|
|
||||||
<div class="visual-container">
|
|
||||||
<div class="hero-image">
|
|
||||||
{#key scoreIndex}
|
|
||||||
<div
|
|
||||||
class="product-wrapper"
|
|
||||||
in:fly={{ x: 100, duration: 500, opacity: 0 }}
|
|
||||||
out:fly={{ x: -100, duration: 500, opacity: 0 }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={scores[scoreIndex].image}
|
|
||||||
alt={scores[scoreIndex].label}
|
|
||||||
class="product-image"
|
|
||||||
style="transform: scale({scores[scoreIndex]
|
|
||||||
.scale});"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
<div class="orbit orbit-1"></div>
|
|
||||||
<div class="orbit orbit-2"></div>
|
|
||||||
|
|
||||||
<div class="floating-card glass-card">
|
|
||||||
{#key scoreIndex}
|
|
||||||
<div class="score-content" in:fade={{ duration: 300 }}>
|
|
||||||
<iconify-icon
|
|
||||||
icon="ri:checkbox-circle-fill"
|
|
||||||
width="24"
|
|
||||||
style="color: {scores[scoreIndex].color};"
|
|
||||||
></iconify-icon>
|
|
||||||
<div>
|
|
||||||
<div class="card-label">
|
|
||||||
{scores[scoreIndex].label}
|
|
||||||
</div>
|
|
||||||
<div class="card-value">
|
|
||||||
{scores[scoreIndex].score}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
|
||||||
<section class="content-section">
|
<section class="stats-section">
|
||||||
<div class="glass-card stats-grid">
|
<div class="stats-card">
|
||||||
{#each stats as stat}
|
{#each stats as stat}
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<div class="stat-value">{stat.value}</div>
|
<span class="stat-value">{stat.value}</span>
|
||||||
<div class="stat-label">{stat.label}</div>
|
<span class="stat-label">{stat.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-section">
|
<section class="features-section">
|
||||||
<div class="section-header">
|
|
||||||
<h2 class="section-title">How It Works</h2>
|
<h2 class="section-title">How It Works</h2>
|
||||||
<p class="section-desc">Tools to help you shop smarter.</p>
|
<p class="section-desc">Simple tools to help you shop smarter</p>
|
||||||
</div>
|
|
||||||
<div class="features-grid">
|
<div class="features-grid">
|
||||||
{#each features as feature}
|
{#each features as feature}
|
||||||
<div class="glass-card feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<iconify-icon icon={feature.icon} width="24"
|
<Icon icon={feature.icon} width="24" />
|
||||||
></iconify-icon>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="feature-title">{feature.title}</h3>
|
<h3 class="feature-title">{feature.title}</h3>
|
||||||
<p class="feature-desc">{feature.desc}</p>
|
<p class="feature-desc">{feature.desc}</p>
|
||||||
@@ -205,30 +135,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-section">
|
<section class="info-section">
|
||||||
<div class="section-header">
|
<div class="info-card">
|
||||||
<h2 class="section-title">Latest News</h2>
|
<div class="info-header">
|
||||||
<p class="section-desc">
|
<Icon
|
||||||
Updates from the world of sustainability.
|
icon="ri:error-warning-line"
|
||||||
|
width="20"
|
||||||
|
class="info-icon"
|
||||||
|
/>
|
||||||
|
<span>What is Greenwashing?</span>
|
||||||
|
</div>
|
||||||
|
<p class="info-text">
|
||||||
|
Greenwashing is when companies make misleading claims about how
|
||||||
|
environmentally friendly their products are. Common tactics
|
||||||
|
include vague terms like "eco-friendly" without proof,
|
||||||
|
highlighting minor green features while hiding major harms, and
|
||||||
|
using green imagery without substance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<a href="/report" class="info-link">
|
||||||
<div class="news-grid">
|
Report suspicious claims
|
||||||
{#each news as item (item.id)}
|
<Icon icon="ri:arrow-right-line" width="16" />
|
||||||
<article class="glass-card news-card">
|
|
||||||
<div class="news-icon">
|
|
||||||
<iconify-icon icon={item.icon} width="24"
|
|
||||||
></iconify-icon>
|
|
||||||
</div>
|
|
||||||
<div class="news-meta">{item.date}</div>
|
|
||||||
<h3 class="news-title">{item.title}</h3>
|
|
||||||
<p class="news-desc">{item.desc}</p>
|
|
||||||
<a href="/news/{item.id}" class="news-link">
|
|
||||||
Read more
|
|
||||||
<iconify-icon icon="ri:arrow-right-line" width="16"
|
|
||||||
></iconify-icon>
|
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</div>
|
||||||
{/each}
|
</section>
|
||||||
|
|
||||||
|
<section class="cta-section">
|
||||||
|
<h2 class="cta-title">Ready to start?</h2>
|
||||||
|
<p class="cta-desc">
|
||||||
|
Every scan helps build a more transparent marketplace.
|
||||||
|
</p>
|
||||||
|
<div class="cta-actions">
|
||||||
|
<a href="/report" class="btn-primary">
|
||||||
|
<Icon icon="ri:shield-check-line" width="18" />
|
||||||
|
Submit a Report
|
||||||
|
</a>
|
||||||
|
<a href="/catalogue" class="btn-secondary"> View All Reports </a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -243,255 +184,121 @@
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 24px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
|
||||||
transition: all 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
border-color: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 60px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 120px 60px;
|
padding: 120px 60px 100px;
|
||||||
max-width: 1400px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-content {
|
.hero-content {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 24px;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
max-width: 600px;
|
max-width: 550px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-badge {
|
.hero-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: rgba(16, 185, 129, 0.1);
|
background: rgba(34, 197, 94, 0.1);
|
||||||
color: #34d399;
|
color: #4ade80;
|
||||||
padding: 10px 20px;
|
padding: 8px 16px;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 72px;
|
font-size: 56px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.05;
|
line-height: 1.1;
|
||||||
color: white;
|
color: white;
|
||||||
margin: 0 0 28px 0;
|
margin: 0 0 20px 0;
|
||||||
letter-spacing: -2px;
|
letter-spacing: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
margin: 0 0 36px 0;
|
margin: 0 0 32px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cta-primary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 18px 36px;
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 28px;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
background: #10b981;
|
|
||||||
color: white;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
text-decoration: none;
|
|
||||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.25);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cta-primary:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 16px 40px rgba(16, 185, 129, 0.35);
|
|
||||||
background: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-visual {
|
|
||||||
position: relative;
|
|
||||||
height: 500px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-container {
|
|
||||||
position: relative;
|
|
||||||
width: 420px;
|
|
||||||
height: 420px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-image {
|
|
||||||
width: 360px;
|
|
||||||
height: 360px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-image {
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
object-fit: contain;
|
|
||||||
filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.3));
|
|
||||||
}
|
|
||||||
|
|
||||||
.orbit {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: 1;
|
|
||||||
animation: spin 25s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.orbit-1 {
|
|
||||||
width: 340px;
|
|
||||||
height: 340px;
|
|
||||||
}
|
|
||||||
.orbit-2 {
|
|
||||||
width: 480px;
|
|
||||||
height: 480px;
|
|
||||||
opacity: 0.4;
|
|
||||||
animation-duration: 45s;
|
|
||||||
animation-direction: reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: translate(-50%, -50%) rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translate(-50%, -50%) rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-card {
|
|
||||||
position: absolute;
|
|
||||||
right: -20px;
|
|
||||||
bottom: 80px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
min-width: 200px;
|
|
||||||
z-index: 10;
|
|
||||||
animation: float 4s ease-in-out infinite;
|
|
||||||
background: rgba(5, 31, 24, 0.8);
|
|
||||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-12px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #9ca3af;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
background: #22c55e;
|
||||||
letter-spacing: 1px;
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-value {
|
.btn-primary:hover {
|
||||||
font-size: 22px;
|
background: #16a34a;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 800;
|
text-decoration: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
height: 25vh;
|
height: 20vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-spacer {
|
.footer-spacer {
|
||||||
height: 15vh;
|
height: 10vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
.stats-section {
|
||||||
max-width: 1200px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 60px 40px;
|
padding: 0 40px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.stats-card {
|
||||||
text-align: center;
|
background: rgba(0, 0, 0, 0.5);
|
||||||
margin-bottom: 48px;
|
backdrop-filter: blur(16px);
|
||||||
}
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
.section-title {
|
|
||||||
font-size: 42px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: white;
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-desc {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #d1d5db;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 32px;
|
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
@@ -499,152 +306,184 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 48px;
|
display: block;
|
||||||
font-weight: 900;
|
font-size: 42px;
|
||||||
color: #34d399;
|
font-weight: 800;
|
||||||
margin-bottom: 4px;
|
color: #4ade80;
|
||||||
letter-spacing: -1px;
|
letter-spacing: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
}
|
||||||
letter-spacing: 1px;
|
|
||||||
|
.features-section {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 60px 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0 0 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
.features-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 24px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
padding: 32px 24px;
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
width: 56px;
|
width: 48px;
|
||||||
height: 56px;
|
height: 48px;
|
||||||
background: rgba(16, 185, 129, 0.1);
|
background: rgba(34, 197, 94, 0.1);
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #34d399;
|
color: #4ade80;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-title {
|
.feature-title {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-desc {
|
.feature-desc {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
line-height: 1.6;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-grid {
|
.info-section {
|
||||||
display: grid;
|
max-width: 800px;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
margin: 0 auto;
|
||||||
gap: 28px;
|
padding: 40px 40px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-card {
|
.info-card {
|
||||||
padding: 28px;
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
backdrop-filter: blur(16px);
|
||||||
flex-direction: column;
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-icon {
|
.info-header {
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
background: rgba(16, 185, 129, 0.1);
|
|
||||||
border-radius: 14px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 10px;
|
||||||
color: #34d399;
|
font-size: 18px;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #34d399;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.news-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
margin: 0 0 10px 0;
|
margin-bottom: 16px;
|
||||||
line-height: 1.3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-desc {
|
.info-text {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
flex-grow: 1;
|
|
||||||
margin: 0 0 20px 0;
|
margin: 0 0 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-link {
|
.info-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
font-size: 13px;
|
color: #4ade80;
|
||||||
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #34d399;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.2s ease;
|
transition: gap 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-link:hover {
|
.info-link:hover {
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
color: #6ee7b7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
.cta-section {
|
||||||
.hero {
|
max-width: 600px;
|
||||||
grid-template-columns: 1fr;
|
margin: 0 auto;
|
||||||
gap: 40px;
|
padding: 60px 40px;
|
||||||
padding: 100px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-content {
|
|
||||||
max-width: 100%;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.cta-title {
|
||||||
font-size: 52px;
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
}
|
}
|
||||||
.hero-actions {
|
|
||||||
|
.cta-desc {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0 0 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-actions {
|
||||||
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.hero-visual {
|
|
||||||
height: 400px;
|
@media (max-width: 900px) {
|
||||||
}
|
|
||||||
.features-grid {
|
.features-grid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
.news-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
}
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
@media (max-width: 768px) {
|
||||||
|
.hero {
|
||||||
|
padding: 80px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,16 +24,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-black/60 backdrop-blur-2xl border border-white/15 rounded-4xl p-12 lg:p-14 mb-12 shadow-2xl shadow-black/50"
|
class="bg-black/40 backdrop-blur-2xl border border-white/10 rounded-4xl p-12 lg:p-14 mb-12 shadow-2xl shadow-black/30"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="mb-10 px-4 text-center">
|
<div class="mb-10 px-4 text-center">
|
||||||
<h1
|
<h1
|
||||||
class="text-white text-[42px] lg:text-[48px] font-black m-0 tracking-[-2px]"
|
class="text-white text-[42px] lg:text-[48px] font-black m-0 tracking-[-2px]"
|
||||||
>
|
>
|
||||||
Sustainability Database
|
Sustainability Database
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-white/80 text-base lg:text-lg mt-3 font-medium">
|
<p class="text-white/90 text-base lg:text-lg mt-3 font-medium">
|
||||||
{#if viewMode === "company"}
|
{#if viewMode === "company"}
|
||||||
Search within verified company reports and impact assessments
|
Search within verified company reports and impact assessments
|
||||||
{:else}
|
{:else}
|
||||||
@@ -42,19 +41,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-5 my-8">
|
<div class="flex items-center justify-center gap-5 my-8">
|
||||||
<span
|
<span
|
||||||
class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode ===
|
class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode ===
|
||||||
'company'
|
'company'
|
||||||
? 'text-emerald-400'
|
? 'text-emerald-400'
|
||||||
: 'text-white/50'}"
|
: 'text-white/60'}"
|
||||||
>
|
>
|
||||||
<Icon icon="ri:building-2-line" width="16" />
|
<Icon icon="ri:building-2-line" width="16" />
|
||||||
Company
|
Company
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="relative w-16 h-8 bg-white/10 border-none rounded-full cursor-pointer transition-all duration-300 p-0 hover:bg-white/15 shadow-inner"
|
class="relative w-16 h-8 bg-white/10 border-none rounded-full cursor-pointer transition-all duration-300 p-0 hover:bg-white/20 shadow-inner"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
(viewMode = viewMode === "company" ? "user" : "company")}
|
(viewMode = viewMode === "company" ? "user" : "company")}
|
||||||
aria-label="Toggle between company and user reports"
|
aria-label="Toggle between company and user reports"
|
||||||
@@ -70,7 +68,7 @@
|
|||||||
class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode ===
|
class="flex items-center gap-2 text-sm lg:text-base font-semibold transition-all duration-300 {viewMode ===
|
||||||
'user'
|
'user'
|
||||||
? 'text-emerald-400'
|
? 'text-emerald-400'
|
||||||
: 'text-white/50'}"
|
: 'text-white/60'}"
|
||||||
>
|
>
|
||||||
<Icon icon="ri:user-voice-line" width="16" />
|
<Icon icon="ri:user-voice-line" width="16" />
|
||||||
User Reports
|
User Reports
|
||||||
@@ -86,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full bg-black/30 border border-white/15 rounded-full py-4 lg:py-5 pl-14 pr-6 text-white text-base lg:text-lg font-medium outline-none transition-all duration-200 focus:bg-black/40 focus:border-emerald-400 placeholder:text-white/50 shadow-inner"
|
class="w-full bg-white/10 border border-white/10 rounded-full py-4 lg:py-5 pl-14 pr-6 text-white text-base lg:text-lg font-medium outline-none transition-all duration-200 focus:bg-white/15 focus:border-emerald-400 placeholder:text-white/40 shadow-inner"
|
||||||
placeholder="Search for companies, topics (e.g., 'emissions')..."
|
placeholder="Search for companies, topics (e.g., 'emissions')..."
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
oninput={onSearchInput}
|
oninput={onSearchInput}
|
||||||
@@ -99,7 +97,7 @@
|
|||||||
class="px-7 py-3 rounded-full text-sm lg:text-base font-semibold cursor-pointer transition-all duration-200 border {selectedCategory ===
|
class="px-7 py-3 rounded-full text-sm lg:text-base font-semibold cursor-pointer transition-all duration-200 border {selectedCategory ===
|
||||||
category
|
category
|
||||||
? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_20px_rgba(34,197,94,0.4)]'
|
? 'bg-emerald-500 border-emerald-500 text-emerald-950 shadow-[0_4px_20px_rgba(34,197,94,0.4)]'
|
||||||
: 'bg-black/30 backdrop-blur-sm border-white/15 text-white/70 hover:bg-black/40 hover:text-white hover:-translate-y-0.5 hover:shadow-lg'}"
|
: 'bg-white/10 backdrop-blur-sm border-white/10 text-white/80 hover:bg-white/20 hover:text-white hover:-translate-y-0.5 hover:shadow-lg'}"
|
||||||
onclick={() => (selectedCategory = category)}
|
onclick={() => (selectedCategory = category)}
|
||||||
>
|
>
|
||||||
{category}
|
{category}
|
||||||
|
|||||||
@@ -43,57 +43,63 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="group flex items-center gap-6 lg:gap-8 bg-black/60 backdrop-blur-2xl border border-white/15 rounded-3xl p-7 lg:p-8 w-full text-left cursor-pointer transition-all duration-300 hover:bg-black/70 hover:border-emerald-500/50 hover:-translate-y-1 hover:scale-[1.01] hover:shadow-2xl hover:shadow-black/50 relative overflow-hidden outline-none"
|
class="group flex items-center gap-6 lg:gap-8 bg-black/40 backdrop-blur-2xl border border-white/10 rounded-3xl p-7 lg:p-8 w-full text-left cursor-pointer transition-all duration-300 hover:bg-black/50 hover:border-emerald-500/40 hover:-translate-y-1 hover:scale-[1.005] hover:shadow-xl hover:shadow-black/30 relative overflow-hidden outline-none"
|
||||||
onclick={() => openReport(report)}
|
onclick={() => openReport(report)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-16 h-16 lg:w-[4.375rem] lg:h-[4.375rem] bg-emerald-500/15 backdrop-blur-sm rounded-[1.125rem] flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-emerald-500 group-hover:-rotate-6 group-hover:scale-110"
|
class="w-16 h-16 lg:w-[4.375rem] lg:h-[4.375rem] bg-emerald-500/10 backdrop-blur-sm rounded-[1.125rem] flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-emerald-500 group-hover:-rotate-3 group-hover:scale-105 border border-white/5 group-hover:border-emerald-400/50"
|
||||||
>
|
>
|
||||||
<Icon icon={fileDetails.icon} width="32" class="text-white" />
|
<Icon
|
||||||
|
icon={fileDetails.icon}
|
||||||
|
width="32"
|
||||||
|
class="text-emerald-100 group-hover:text-white transition-colors"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-3 mb-2.5">
|
<div class="flex items-center gap-3 mb-2.5">
|
||||||
<h3 class="text-white text-xl lg:text-[22px] font-extrabold m-0">
|
<h3
|
||||||
|
class="text-white text-xl lg:text-[22px] font-bold m-0 tracking-tight"
|
||||||
|
>
|
||||||
{report.company_name}
|
{report.company_name}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<span
|
||||||
class="text-emerald-300 bg-emerald-500/15 backdrop-blur-sm px-3 py-1 rounded-lg text-[12px] lg:text-[13px] font-bold"
|
class="text-emerald-300 bg-emerald-500/15 backdrop-blur-sm px-3 py-1 rounded-lg text-[12px] lg:text-[13px] font-bold border border-emerald-500/10"
|
||||||
>{report.year}</span
|
>{report.year}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if report.snippet}
|
{#if report.snippet}
|
||||||
<p
|
<p
|
||||||
class="text-white/70 text-sm lg:text-base leading-relaxed mb-4 line-clamp-2 m-0"
|
class="text-white/80 text-sm lg:text-base leading-relaxed mb-4 line-clamp-2 m-0 font-medium"
|
||||||
>
|
>
|
||||||
{@html report.snippet.replace(
|
{@html report.snippet.replace(
|
||||||
new RegExp(searchQuery || "", "gi"),
|
new RegExp(searchQuery || "", "gi"),
|
||||||
(match) =>
|
(match) =>
|
||||||
`<span class="text-emerald-300 bg-emerald-500/25 px-1 rounded font-semibold">${match}</span>`,
|
`<span class="text-emerald-200 bg-emerald-500/30 px-1 rounded font-bold">${match}</span>`,
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-white/50 text-sm lg:text-base mb-4 m-0">
|
<p class="text-white/60 text-sm lg:text-base mb-4 m-0 font-medium">
|
||||||
{report.sector} Sector • Impact Report
|
{report.sector} Sector • Impact Report
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2.5">
|
<div class="flex flex-wrap gap-2.5">
|
||||||
<span
|
<span
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-white/10 backdrop-blur-sm text-white/70 max-w-xs truncate"
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-white/5 backdrop-blur-sm text-white/70 max-w-xs truncate border border-white/5"
|
||||||
title={report.filename}
|
title={report.filename}
|
||||||
>
|
>
|
||||||
<Icon icon={fileDetails.icon} width="15" />
|
<Icon icon={fileDetails.icon} width="15" />
|
||||||
{fileDetails.type}
|
{fileDetails.type}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-emerald-500/15 backdrop-blur-sm text-emerald-300"
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] lg:text-[12px] font-bold tracking-tight bg-emerald-500/10 backdrop-blur-sm text-emerald-300 border border-emerald-500/10"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon="ri:checkbox-circle-fill"
|
icon="ri:checkbox-circle-fill"
|
||||||
width="14"
|
width="14"
|
||||||
class="text-emerald-500"
|
class="text-emerald-400"
|
||||||
/>
|
/>
|
||||||
Analyzed
|
Analyzed
|
||||||
</span>
|
</span>
|
||||||
@@ -101,18 +107,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if report.greenwashing_score}
|
{#if report.greenwashing_score}
|
||||||
<div class="text-center ml-5 lg:ml-6">
|
|
||||||
<div
|
<div
|
||||||
class="w-14 h-14 lg:w-[3.875rem] lg:h-[3.875rem] rounded-xe flex items-center justify-center mb-2 rounded-2xl shadow-lg transition-transform group-hover:scale-110 {getScoreColor(
|
class="text-center ml-5 lg:ml-6 group-hover:translate-x-1 transition-transform duration-300"
|
||||||
report.greenwashing_score,
|
|
||||||
)}"
|
|
||||||
>
|
>
|
||||||
<span class="text-emerald-950 text-[19px] lg:text-[21px] font-black"
|
<div
|
||||||
|
class="w-14 h-14 lg:w-[3.875rem] lg:h-[3.875rem] rounded-xe flex items-center justify-center mb-2 rounded-2xl shadow-lg {getScoreColor(
|
||||||
|
report.greenwashing_score,
|
||||||
|
)} ring-4 ring-black/20"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-emerald-950 text-[19px] lg:text-[21px] font-black"
|
||||||
>{Math.round(Number(report.greenwashing_score))}</span
|
>{Math.round(Number(report.greenwashing_score))}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="text-white/50 text-[10px] lg:text-[11px] font-extrabold uppercase tracking-widest"
|
class="text-white/60 text-[10px] lg:text-[11px] font-bold uppercase tracking-widest"
|
||||||
>Trust Score</span
|
>Trust Score</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,16 +49,42 @@ export function drawLandscape(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sceneType === 'transition') {
|
if (sceneType === 'transition') {
|
||||||
const ecoOpacity = 1 - state.progress;
|
const p = state.progress;
|
||||||
const industrialOpacity = state.progress;
|
|
||||||
|
|
||||||
if (ecoOpacity > 0.1) {
|
// Eco fades out quickly: 1.0 -> 0.0 from progress 0.0 to 0.45
|
||||||
|
const ecoOpacity = Math.max(0, 1 - (p / 0.45));
|
||||||
|
|
||||||
|
// Industrial fades in later: 0.0 -> 1.0 from progress 0.55 to 1.0
|
||||||
|
const industrialOpacity = Math.max(0, (p - 0.55) / 0.45);
|
||||||
|
|
||||||
|
if (ecoOpacity > 0.01) {
|
||||||
|
ctx.save();
|
||||||
ctx.globalAlpha = ecoOpacity;
|
ctx.globalAlpha = ecoOpacity;
|
||||||
drawEcoScene(dc);
|
drawEcoScene(dc);
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
if (industrialOpacity > 0.1) {
|
|
||||||
|
// Transition Fog: Peaks at progress 0.5 to bridge the gap between scenes
|
||||||
|
// Increased max opacity to 0.9 to fully hide the "swap"
|
||||||
|
const fogOpacity = Math.sin(p * Math.PI) * 0.9;
|
||||||
|
|
||||||
|
if (fogOpacity > 0.01) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = fogOpacity;
|
||||||
|
const gradient = ctx.createLinearGradient(0, height * 0.2, 0, height);
|
||||||
|
gradient.addColorStop(0, 'rgba(230, 230, 230, 0)'); // Transparent top
|
||||||
|
gradient.addColorStop(0.3, 'rgba(200, 200, 200, 0.6)'); // Hazy middle
|
||||||
|
gradient.addColorStop(1, 'rgba(160, 160, 160, 1.0)'); // Thick bottom
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (industrialOpacity > 0.01) {
|
||||||
|
ctx.save();
|
||||||
ctx.globalAlpha = industrialOpacity;
|
ctx.globalAlpha = industrialOpacity;
|
||||||
drawIndustrialScene(dc);
|
drawIndustrialScene(dc);
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
} else if (blendToScene && blendProgress !== undefined && blendToScene !== 'transition') {
|
} else if (blendToScene && blendProgress !== undefined && blendToScene !== 'transition') {
|
||||||
|
|||||||
@@ -123,8 +123,32 @@ export function drawDeadTrees(dc: DrawContext): void {
|
|||||||
drawDeadTree(width * 0.88, height * 0.76, 1.2);
|
drawDeadTree(width * 0.88, height * 0.76, 1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawFog(dc: DrawContext): void {
|
||||||
|
const { ctx, width, height, state } = dc;
|
||||||
|
const time = Date.now() * 0.001;
|
||||||
|
|
||||||
|
// Drifting fog banks
|
||||||
|
const drawFogLayer = (y: number, speed: number, opacity: number) => {
|
||||||
|
const drift = (time * speed) % width;
|
||||||
|
const xOffset = drift > 0 ? drift - width : drift;
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(0, y, 0, y + 150);
|
||||||
|
gradient.addColorStop(0, 'rgba(120, 120, 120, 0)');
|
||||||
|
gradient.addColorStop(0.5, `rgba(120, 120, 120, ${opacity})`);
|
||||||
|
gradient.addColorStop(1, 'rgba(120, 120, 120, 0)');
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(xOffset, y, width * 2, 150); // Draw wide to handle looping
|
||||||
|
};
|
||||||
|
|
||||||
|
drawFogLayer(height * 0.65, 20, 0.15);
|
||||||
|
drawFogLayer(height * 0.75, -15, 0.2);
|
||||||
|
drawFogLayer(height * 0.55, 10, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
export function drawIndustrialScene(dc: DrawContext): void {
|
export function drawIndustrialScene(dc: DrawContext): void {
|
||||||
drawPowerLines(dc);
|
drawPowerLines(dc);
|
||||||
drawFactories(dc);
|
drawFactories(dc);
|
||||||
drawDeadTrees(dc);
|
drawDeadTrees(dc);
|
||||||
|
drawFog(dc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ name: "Home", route: "/", icon: "ri:home-4-line" },
|
{ name: "Home", route: "/", icon: "ri:home-4-line" },
|
||||||
{ name: "Goal", route: "/community", icon: "ri:flag-2-line" },
|
{ name: "Goal", route: "/goal", icon: "ri:flag-2-line" },
|
||||||
{ name: "Chat", route: "/chat", icon: "ri:chat-3-line" },
|
{ name: "Chat", route: "/chat", icon: "ri:chat-3-line" },
|
||||||
{ name: "Report", route: "/report", icon: "ri:alarm-warning-line" },
|
{ name: "Report", route: "/report", icon: "ri:alarm-warning-line" },
|
||||||
];
|
];
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Scan products to reveal their true environmental impact. Join the community of eco-conscious shoppers making a difference."
|
content="Scan products to reveal their true environmental impact. Join the community of eco-conscious shoppers making a difference."
|
||||||
/>
|
/>
|
||||||
|
<link rel="icon" href="/ethix-logo.png" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if isMobile}
|
{#if isMobile}
|
||||||
@@ -181,7 +182,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desktop-nav {
|
.desktop-nav {
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
||||||
messages;
|
messages;
|
||||||
isLoading;
|
isLoading;
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -101,12 +100,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative z-10 max-w-6xl mx-auto h-full flex flex-col pt-24 pb-6 px-0 md:px-6 md:pt-20 md:pb-10"
|
class="relative z-10 max-w-6xl mx-auto h-full flex flex-col pt-0 pb-20 px-0 md:px-6 md:pt-20 md:pb-10"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col h-full bg-[#0d2e25] md:bg-black/40 md:backdrop-blur-xl border-x md:border border-[#1f473b] md:border-white/10 md:rounded-[32px] overflow-hidden shadow-2xl"
|
class="flex flex-col h-full bg-[#0d2e25] md:bg-black/40 md:backdrop-blur-xl border-x md:border border-[#1f473b] md:border-white/10 md:rounded-[32px] overflow-hidden shadow-2xl"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="p-3 px-5 border-b border-[#1f473b] md:border-white/10 bg-[#051f18] md:bg-transparent flex items-center gap-3 shrink-0"
|
class="p-3 px-5 border-b border-[#1f473b] md:border-white/10 bg-[#051f18] md:bg-transparent flex items-center gap-3 shrink-0"
|
||||||
>
|
>
|
||||||
@@ -126,7 +124,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-1 flex flex-col overflow-hidden bg-[#0d2e25] md:bg-transparent"
|
class="flex-1 flex flex-col overflow-hidden bg-[#0d2e25] md:bg-transparent"
|
||||||
>
|
>
|
||||||
@@ -150,7 +147,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
.scrollbar-none::-webkit-scrollbar {
|
.scrollbar-none::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let analysisResult = $state<any>(null);
|
let analysisResult = $state<any>(null);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let uploadSuccess = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const initialName = new URLSearchParams(window.location.search).get(
|
const initialName = new URLSearchParams(window.location.search).get(
|
||||||
@@ -22,7 +23,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let isValid = $derived(
|
let isValid = $derived(
|
||||||
productName.trim().length > 0 && description.trim().length > 0,
|
reportType === "product"
|
||||||
|
? productName.trim().length > 0 && description.trim().length > 0
|
||||||
|
: productName.trim().length > 0 && pdfData !== null,
|
||||||
);
|
);
|
||||||
|
|
||||||
async function pickImage() {
|
async function pickImage() {
|
||||||
@@ -62,7 +65,8 @@
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressSteps = [
|
// Progress steps for product incident
|
||||||
|
const productProgressSteps = [
|
||||||
{ id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" },
|
{ id: 1, label: "Scanning image...", icon: "ri:camera-lens-line" },
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -78,6 +82,31 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Progress steps for company report upload
|
||||||
|
const companyProgressSteps = [
|
||||||
|
{ id: 1, label: "Decoding PDF...", icon: "ri:file-pdf-2-line" },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
label: "Extracting text content...",
|
||||||
|
icon: "ri:file-text-line",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
label: "Validating report authenticity...",
|
||||||
|
icon: "ri:shield-check-line",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
label: "Analyzing environmental claims...",
|
||||||
|
icon: "ri:robot-2-line",
|
||||||
|
},
|
||||||
|
{ id: 5, label: "Saving to database...", icon: "ri:database-2-line" },
|
||||||
|
{ id: 6, label: "Indexing for search...", icon: "ri:search-line" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let progressSteps = $derived(
|
||||||
|
reportType === "product" ? productProgressSteps : companyProgressSteps,
|
||||||
|
);
|
||||||
let currentStep = $state(0);
|
let currentStep = $state(0);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -86,15 +115,60 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
currentStep = 1;
|
currentStep = 1;
|
||||||
|
uploadSuccess = false;
|
||||||
|
|
||||||
|
const stepInterval = setInterval(
|
||||||
const stepInterval = setInterval(() => {
|
() => {
|
||||||
if (currentStep < progressSteps.length) {
|
if (currentStep < progressSteps.length) {
|
||||||
currentStep++;
|
currentStep++;
|
||||||
}
|
}
|
||||||
}, 1500);
|
},
|
||||||
|
reportType === "company" ? 2000 : 1500,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (reportType === "company") {
|
||||||
|
// Use the new upload endpoint for company reports
|
||||||
|
const response = await fetch(
|
||||||
|
"http://localhost:5000/api/reports/upload",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
company_name: productName,
|
||||||
|
pdf_data: pdfData,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
clearInterval(stepInterval);
|
||||||
|
currentStep = progressSteps.length;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === "success") {
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
analysisResult = {
|
||||||
|
...data,
|
||||||
|
is_company_report: true,
|
||||||
|
};
|
||||||
|
uploadSuccess = true;
|
||||||
|
submitted = true;
|
||||||
|
} else if (data.status === "rejected") {
|
||||||
|
error =
|
||||||
|
data.message ||
|
||||||
|
"Report was rejected - not a valid environmental report";
|
||||||
|
analysisResult = {
|
||||||
|
rejected: true,
|
||||||
|
validation: data.validation,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
error = data.message || "Failed to upload report";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Original product incident flow
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"http://localhost:5000/api/incidents/submit",
|
"http://localhost:5000/api/incidents/submit",
|
||||||
{
|
{
|
||||||
@@ -106,8 +180,8 @@
|
|||||||
product_name: productName,
|
product_name: productName,
|
||||||
description: description,
|
description: description,
|
||||||
report_type: reportType,
|
report_type: reportType,
|
||||||
image: reportType === "product" ? image : null,
|
image: image,
|
||||||
pdf_data: reportType === "company" ? pdfData : null,
|
pdf_data: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -118,13 +192,13 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === "success") {
|
if (data.status === "success") {
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
analysisResult = data;
|
analysisResult = data;
|
||||||
submitted = true;
|
submitted = true;
|
||||||
} else {
|
} else {
|
||||||
error = data.message || "Failed to submit report";
|
error = data.message || "Failed to submit report";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearInterval(stepInterval);
|
clearInterval(stepInterval);
|
||||||
error = "Failed to connect to server. Please try again.";
|
error = "Failed to connect to server. Please try again.";
|
||||||
@@ -134,6 +208,18 @@
|
|||||||
currentStep = 0;
|
currentStep = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
submitted = false;
|
||||||
|
uploadSuccess = false;
|
||||||
|
analysisResult = null;
|
||||||
|
error = null;
|
||||||
|
productName = "";
|
||||||
|
description = "";
|
||||||
|
image = null;
|
||||||
|
pdfData = null;
|
||||||
|
pdfName = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
@@ -144,7 +230,91 @@
|
|||||||
<div class="content-container">
|
<div class="content-container">
|
||||||
{#if submitted && analysisResult}
|
{#if submitted && analysisResult}
|
||||||
<div class="glass-card success-card">
|
<div class="glass-card success-card">
|
||||||
{#if analysisResult.is_greenwashing}
|
{#if analysisResult.is_company_report}
|
||||||
|
<!-- Company Report Upload Success -->
|
||||||
|
<div class="icon-circle success">
|
||||||
|
<iconify-icon
|
||||||
|
icon="ri:file-check-fill"
|
||||||
|
width="60"
|
||||||
|
style="color: #4ade80;"
|
||||||
|
></iconify-icon>
|
||||||
|
</div>
|
||||||
|
<h2 class="success-title">Report Verified & Uploaded</h2>
|
||||||
|
|
||||||
|
<div class="analysis-result">
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Company:</span>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.stats?.company_name ||
|
||||||
|
"Unknown"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Year:</span>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.stats?.year || "N/A"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Sector:</span>
|
||||||
|
<span class="result-value badge"
|
||||||
|
>{analysisResult.stats?.sector || "Other"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Report Type:</span>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.stats?.report_type ||
|
||||||
|
"Unknown"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Pages:</span>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.stats?.page_count || 0}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Chunks Created:</span>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.stats?.chunks_created ||
|
||||||
|
0}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if analysisResult.validation?.key_topics}
|
||||||
|
<div class="result-item full-width">
|
||||||
|
<span class="result-label">Key Topics:</span>
|
||||||
|
<div class="topics-list">
|
||||||
|
{#each analysisResult.validation.key_topics.slice(0, 5) as topic}
|
||||||
|
<span class="topic-tag">{topic}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if analysisResult.validation?.reasoning}
|
||||||
|
<div class="result-item full-width">
|
||||||
|
<span class="result-label">AI Assessment:</span>
|
||||||
|
<p class="result-text">
|
||||||
|
{analysisResult.validation.reasoning}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a href="/catalogue" class="btn-primary">
|
||||||
|
<iconify-icon icon="ri:folder-open-line" width="18"
|
||||||
|
></iconify-icon>
|
||||||
|
View in Catalogue
|
||||||
|
</a>
|
||||||
|
<button class="back-btn" onclick={resetForm}>
|
||||||
|
<iconify-icon icon="ri:add-line" width="20"
|
||||||
|
></iconify-icon>
|
||||||
|
Upload Another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if analysisResult.is_greenwashing}
|
||||||
|
<!-- Greenwashing Detected -->
|
||||||
<div class="icon-circle warning">
|
<div class="icon-circle warning">
|
||||||
<iconify-icon
|
<iconify-icon
|
||||||
icon="ri:alert-fill"
|
icon="ri:alert-fill"
|
||||||
@@ -155,34 +325,25 @@
|
|||||||
<h2 class="success-title warning-text">
|
<h2 class="success-title warning-text">
|
||||||
Greenwashing Detected!
|
Greenwashing Detected!
|
||||||
</h2>
|
</h2>
|
||||||
{:else}
|
|
||||||
<div class="icon-circle success">
|
|
||||||
<iconify-icon
|
|
||||||
icon="ri:checkbox-circle-fill"
|
|
||||||
width="60"
|
|
||||||
style="color: #4ade80;"
|
|
||||||
></iconify-icon>
|
|
||||||
</div>
|
|
||||||
<h2 class="success-title">Report Analyzed</h2>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="analysis-result">
|
<div class="analysis-result">
|
||||||
<div class="result-item">
|
<div class="result-item">
|
||||||
<span class="result-label">Verdict:</span>
|
<span class="result-label">Verdict:</span>
|
||||||
<span class="result-value"
|
<span class="result-value"
|
||||||
>{analysisResult.analysis?.verdict || "N/A"}</span
|
>{analysisResult.analysis?.verdict ||
|
||||||
|
"N/A"}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-item">
|
<div class="result-item">
|
||||||
<span class="result-label">Confidence:</span>
|
<span class="result-label">Confidence:</span>
|
||||||
<span
|
<span
|
||||||
class="result-value badge {analysisResult.analysis
|
class="result-value badge {analysisResult
|
||||||
?.confidence}"
|
.analysis?.confidence}"
|
||||||
>
|
>
|
||||||
{analysisResult.analysis?.confidence || "unknown"}
|
{analysisResult.analysis?.confidence ||
|
||||||
|
"unknown"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if analysisResult.is_greenwashing}
|
|
||||||
<div class="result-item">
|
<div class="result-item">
|
||||||
<span class="result-label">Severity:</span>
|
<span class="result-label">Severity:</span>
|
||||||
<span
|
<span
|
||||||
@@ -192,7 +353,6 @@
|
|||||||
{analysisResult.analysis?.severity || "unknown"}
|
{analysisResult.analysis?.severity || "unknown"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
<div class="result-item full-width">
|
<div class="result-item full-width">
|
||||||
<span class="result-label">Analysis:</span>
|
<span class="result-label">Analysis:</span>
|
||||||
<p class="result-text">
|
<p class="result-text">
|
||||||
@@ -202,7 +362,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if analysisResult.detected_brand && analysisResult.detected_brand !== "Unknown"}
|
{#if analysisResult.detected_brand && analysisResult.detected_brand !== "Unknown"}
|
||||||
<div class="result-item">
|
<div class="result-item">
|
||||||
<span class="result-label">Detected Brand:</span>
|
<span class="result-label">Detected Brand:</span
|
||||||
|
>
|
||||||
<span class="result-value"
|
<span class="result-value"
|
||||||
>{analysisResult.detected_brand}</span
|
>{analysisResult.detected_brand}</span
|
||||||
>
|
>
|
||||||
@@ -210,11 +371,70 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="back-btn" onclick={() => window.history.back()}>
|
<button
|
||||||
|
class="back-btn"
|
||||||
|
onclick={() => window.history.back()}
|
||||||
|
>
|
||||||
<iconify-icon icon="ri:arrow-left-line" width="20"
|
<iconify-icon icon="ri:arrow-left-line" width="20"
|
||||||
></iconify-icon>
|
></iconify-icon>
|
||||||
Go Back
|
Go Back
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<!-- Report Analyzed (No Greenwashing) -->
|
||||||
|
<div class="icon-circle success">
|
||||||
|
<iconify-icon
|
||||||
|
icon="ri:checkbox-circle-fill"
|
||||||
|
width="60"
|
||||||
|
style="color: #4ade80;"
|
||||||
|
></iconify-icon>
|
||||||
|
</div>
|
||||||
|
<h2 class="success-title">Report Analyzed</h2>
|
||||||
|
|
||||||
|
<div class="analysis-result">
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Verdict:</span>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.analysis?.verdict ||
|
||||||
|
"N/A"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Confidence:</span>
|
||||||
|
<span
|
||||||
|
class="result-value badge {analysisResult
|
||||||
|
.analysis?.confidence}"
|
||||||
|
>
|
||||||
|
{analysisResult.analysis?.confidence ||
|
||||||
|
"unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item full-width">
|
||||||
|
<span class="result-label">Analysis:</span>
|
||||||
|
<p class="result-text">
|
||||||
|
{analysisResult.analysis?.reasoning ||
|
||||||
|
"No details available"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if analysisResult.detected_brand && analysisResult.detected_brand !== "Unknown"}
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="result-label">Detected Brand:</span
|
||||||
|
>
|
||||||
|
<span class="result-value"
|
||||||
|
>{analysisResult.detected_brand}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="back-btn"
|
||||||
|
onclick={() => window.history.back()}
|
||||||
|
>
|
||||||
|
<iconify-icon icon="ri:arrow-left-line" width="20"
|
||||||
|
></iconify-icon>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -263,6 +483,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if reportType === "product"}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="label" for="description"
|
<label class="label" for="description"
|
||||||
>Why is it misleading?</label
|
>Why is it misleading?</label
|
||||||
@@ -280,6 +501,7 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
@@ -366,7 +588,11 @@
|
|||||||
width="24"
|
width="24"
|
||||||
class="pulse"
|
class="pulse"
|
||||||
></iconify-icon>
|
></iconify-icon>
|
||||||
<span>Analyzing Report</span>
|
<span>
|
||||||
|
{reportType === "product"
|
||||||
|
? "Analyzing Report"
|
||||||
|
: "Verifying & Uploading Report"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-steps">
|
<div class="progress-steps">
|
||||||
{#each progressSteps as step}
|
{#each progressSteps as step}
|
||||||
@@ -417,9 +643,19 @@
|
|||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<iconify-icon icon="ri:shield-flash-line" width="20"
|
{#if reportType === "product"}
|
||||||
|
<iconify-icon
|
||||||
|
icon="ri:shield-flash-line"
|
||||||
|
width="20"
|
||||||
></iconify-icon>
|
></iconify-icon>
|
||||||
Analyze for Greenwashing
|
Analyze for Greenwashing
|
||||||
|
{:else}
|
||||||
|
<iconify-icon
|
||||||
|
icon="ri:upload-cloud-2-line"
|
||||||
|
width="20"
|
||||||
|
></iconify-icon>
|
||||||
|
Upload & Verify Report
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -443,7 +679,7 @@
|
|||||||
.content-container {
|
.content-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 80px 20px 40px;
|
padding: 120px 20px 40px;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -821,6 +1057,51 @@
|
|||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #052e16;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #16a34a;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topics-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-tag {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: #4ade80;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -11,9 +11,4 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler"
|
||||||
}
|
}
|
||||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
|
||||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
|
||||||
//
|
|
||||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
|
||||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user