mirror of
https://github.com/SirBlobby/Hoya26.git
synced 2026-02-04 03:34:34 -05:00
Reports Update
This commit is contained in:
@@ -4,4 +4,4 @@ from src import create_app
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, port=5000)
|
||||
app.run(debug=True, port=5000, host="0.0.0.0")
|
||||
@@ -11,5 +11,7 @@ def create_app():
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(rag_bp, url_prefix='/api/rag')
|
||||
app.register_blueprint(gemini_bp, url_prefix='/api/gemini')
|
||||
from .routes.reports import reports_bp
|
||||
app.register_blueprint(reports_bp, url_prefix='/api/reports')
|
||||
|
||||
return app
|
||||
|
||||
@@ -53,9 +53,11 @@ def search_documents(query_embedding, collection_name=COLLECTION_NAME, num_resul
|
||||
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
|
||||
"score": score,
|
||||
"metadata": meta
|
||||
})
|
||||
|
||||
return output
|
||||
@@ -67,3 +69,12 @@ def delete_documents_by_source(source_file, collection_name=COLLECTION_NAME):
|
||||
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)
|
||||
# Only fetch metadatas to be lightweight
|
||||
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 []
|
||||
|
||||
@@ -3,9 +3,15 @@ import os
|
||||
|
||||
def generate_content(prompt, model_name="gemini-2.0-flash-exp"):
|
||||
api_key = os.environ.get("GOOGLE_API_KEY")
|
||||
if not api_key:
|
||||
return "Error: GOOGLE_API_KEY not found."
|
||||
|
||||
try:
|
||||
client = genai.Client(api_key=api_key)
|
||||
response = client.models.generate_content(
|
||||
model=model_name,
|
||||
contents=prompt,
|
||||
)
|
||||
return response.text
|
||||
except Exception as e:
|
||||
return f"Error interacting with Gemini API: {str(e)}"
|
||||
|
||||
@@ -24,7 +24,10 @@ class GeminiClient:
|
||||
|
||||
response = self.client.models.generate_content(
|
||||
model=self.model_name,
|
||||
contents=full_message
|
||||
contents=full_message,
|
||||
config={
|
||||
'system_instruction': 'You are a concise sustainability assistant. Your responses must be a single short paragraph, maximum 6 sentences long. Do not use bullet points or multiple sections.'
|
||||
}
|
||||
)
|
||||
return response.text
|
||||
|
||||
|
||||
228
backend/src/routes/reports.py
Normal file
228
backend/src/routes/reports.py
Normal file
@@ -0,0 +1,228 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from src.chroma.vector_store import get_all_metadatas, search_documents
|
||||
from src.rag.embeddings import get_embedding
|
||||
|
||||
reports_bp = Blueprint('reports', __name__)
|
||||
|
||||
@reports_bp.route('/', methods=['GET'])
|
||||
def get_reports():
|
||||
try:
|
||||
# Fetch all metadatas to ensure we get diversity.
|
||||
# 60k items is manageable for metadata-only fetch.
|
||||
metadatas = get_all_metadatas()
|
||||
|
||||
unique_reports = {}
|
||||
|
||||
for meta in metadatas:
|
||||
filename = meta.get('source') or meta.get('filename')
|
||||
if not filename:
|
||||
continue
|
||||
|
||||
if filename not in unique_reports:
|
||||
# Attempt to extract info from filename
|
||||
# Common patterns:
|
||||
# 2020-tesla-impact-report.pdf
|
||||
# google-2023-environmental-report.pdf
|
||||
# ghgp_data_2021.xlsx
|
||||
|
||||
company_name = "Unknown"
|
||||
year = "N/A"
|
||||
sector = "Other"
|
||||
|
||||
lower_name = filename.lower()
|
||||
|
||||
# Extract Year
|
||||
import re
|
||||
year_match = re.search(r'20\d{2}', lower_name)
|
||||
if year_match:
|
||||
year = year_match.group(0)
|
||||
|
||||
# Extract Company (heuristics)
|
||||
if 'tesla' in lower_name:
|
||||
company_name = "Tesla"
|
||||
sector = "Automotive"
|
||||
elif 'google' in lower_name:
|
||||
company_name = "Google"
|
||||
sector = "Tech"
|
||||
elif 'apple' in lower_name:
|
||||
company_name = "Apple"
|
||||
sector = "Tech"
|
||||
elif 'microsoft' in lower_name:
|
||||
company_name = "Microsoft"
|
||||
sector = "Tech"
|
||||
elif 'amazon' in lower_name:
|
||||
company_name = "Amazon"
|
||||
sector = "Tech"
|
||||
elif 'boeing' in lower_name:
|
||||
company_name = "Boeing"
|
||||
sector = "Aerospace"
|
||||
elif 'ghgp' in lower_name:
|
||||
company_name = "GHGP Data"
|
||||
sector = "Data"
|
||||
elif 'salesforce' in lower_name:
|
||||
company_name = "Salesforce"
|
||||
sector = "Tech"
|
||||
elif 'hp ' in lower_name or 'hp-' in lower_name:
|
||||
company_name = "HP"
|
||||
sector = "Tech"
|
||||
else:
|
||||
# Fallback: capitalize first word of filename
|
||||
parts = re.split(r'[-_.]', filename)
|
||||
if parts:
|
||||
company_name = parts[0].capitalize()
|
||||
if company_name.isdigit(): # If starts with year
|
||||
company_name = parts[1].capitalize() if len(parts) > 1 else "Unknown"
|
||||
|
||||
unique_reports[filename] = {
|
||||
'company_name': company_name,
|
||||
'year': year,
|
||||
'sector': sector,
|
||||
'greenwashing_score': meta.get('greenwashing_score', 0), # Likely 0
|
||||
'filename': filename,
|
||||
'title': f"{company_name} {year} Report"
|
||||
}
|
||||
|
||||
reports_list = list(unique_reports.values())
|
||||
return jsonify(reports_list)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching reports: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@reports_bp.route('/search', methods=['POST'])
|
||||
def search_reports():
|
||||
data = request.json
|
||||
query = data.get('query', '')
|
||||
|
||||
if not query:
|
||||
return jsonify([])
|
||||
|
||||
try:
|
||||
import re
|
||||
|
||||
# Get embedding for the query
|
||||
query_embedding = get_embedding(query)
|
||||
|
||||
# Search in Chroma - get more results to filter
|
||||
results = search_documents(query_embedding, num_results=50)
|
||||
|
||||
query_lower = query.lower()
|
||||
|
||||
# Helper function to extract company info
|
||||
def extract_company_info(filename):
|
||||
company_name = "Unknown"
|
||||
year = "N/A"
|
||||
sector = "Other"
|
||||
|
||||
lower_name = filename.lower()
|
||||
|
||||
# Extract Year
|
||||
year_match = re.search(r'20\d{2}', lower_name)
|
||||
if year_match:
|
||||
year = year_match.group(0)
|
||||
|
||||
# Extract Company (heuristics)
|
||||
if 'tesla' in lower_name:
|
||||
company_name = "Tesla"
|
||||
sector = "Automotive"
|
||||
elif 'google' in lower_name:
|
||||
company_name = "Google"
|
||||
sector = "Tech"
|
||||
elif 'apple' in lower_name:
|
||||
company_name = "Apple"
|
||||
sector = "Tech"
|
||||
elif 'microsoft' in lower_name:
|
||||
company_name = "Microsoft"
|
||||
sector = "Tech"
|
||||
elif 'amazon' in lower_name:
|
||||
company_name = "Amazon"
|
||||
sector = "Tech"
|
||||
elif 'boeing' in lower_name:
|
||||
company_name = "Boeing"
|
||||
sector = "Aerospace"
|
||||
elif 'ghgp' in lower_name:
|
||||
company_name = "GHGP Data"
|
||||
sector = "Data"
|
||||
elif 'salesforce' in lower_name:
|
||||
company_name = "Salesforce"
|
||||
sector = "Tech"
|
||||
elif 'hp ' in lower_name or 'hp-' in lower_name or lower_name.startswith('hp'):
|
||||
company_name = "HP"
|
||||
sector = "Tech"
|
||||
else:
|
||||
parts = re.split(r'[-_.]', filename)
|
||||
if parts:
|
||||
company_name = parts[0].capitalize()
|
||||
if company_name.isdigit():
|
||||
company_name = parts[1].capitalize() if len(parts) > 1 else "Unknown"
|
||||
|
||||
return company_name, year, sector
|
||||
|
||||
output = []
|
||||
seen_filenames = set()
|
||||
|
||||
for item in results:
|
||||
meta = item.get('metadata', {})
|
||||
text = item.get('text', '')
|
||||
|
||||
filename = meta.get('source') or meta.get('filename', 'Unknown')
|
||||
|
||||
# Skip duplicates
|
||||
if filename in seen_filenames:
|
||||
continue
|
||||
seen_filenames.add(filename)
|
||||
|
||||
company_name, year, sector = extract_company_info(filename)
|
||||
|
||||
# Calculate match score - boost if query matches company/filename
|
||||
match_boost = 0
|
||||
if query_lower in filename.lower():
|
||||
match_boost = 1000 # Strong boost for filename match
|
||||
if query_lower in company_name.lower():
|
||||
match_boost = 1000 # Strong boost for company match
|
||||
|
||||
# Semantic score (inverted distance, higher = better)
|
||||
semantic_score = 1 / (item.get('score', 1) + 0.001) if item.get('score') else 0
|
||||
|
||||
combined_score = match_boost + semantic_score
|
||||
|
||||
# Format snippet
|
||||
snippet = text[:300] + "..." if len(text) > 300 else text
|
||||
|
||||
output.append({
|
||||
'company_name': company_name,
|
||||
'year': year,
|
||||
'filename': filename,
|
||||
'sector': sector,
|
||||
'greenwashing_score': meta.get('greenwashing_score', 0),
|
||||
'snippet': snippet,
|
||||
'relevance_score': item.get('score'),
|
||||
'_combined_score': combined_score
|
||||
})
|
||||
|
||||
# Sort by combined score (descending - higher is better)
|
||||
output.sort(key=lambda x: x.get('_combined_score', 0), reverse=True)
|
||||
|
||||
# Remove internal score field and limit results
|
||||
for item in output:
|
||||
item.pop('_combined_score', None)
|
||||
|
||||
return jsonify(output[:20])
|
||||
except Exception as e:
|
||||
print(f"Error searching reports: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@reports_bp.route('/view/<path:filename>', methods=['GET'])
|
||||
def view_report_file(filename):
|
||||
import os
|
||||
from flask import send_from_directory
|
||||
|
||||
# Dataset path relative to this file
|
||||
# src/routes/reports.py -> src/routes -> src -> backend -> dataset
|
||||
# So ../../../dataset
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
dataset_dir = os.path.join(current_dir, '..', '..', 'dataset')
|
||||
|
||||
return send_from_directory(dataset_dir, filename)
|
||||
@@ -21,6 +21,7 @@
|
||||
"@types/three": "^0.182.0",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.2.1",
|
||||
"marked": "^17.0.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
|
||||
@@ -201,19 +201,8 @@
|
||||
const config = activeConfig();
|
||||
|
||||
if (!config.staticScene || config.scenes) {
|
||||
scrollContainer = document.querySelector(
|
||||
".app-container",
|
||||
) as HTMLElement;
|
||||
if (!scrollContainer) {
|
||||
scrollContainer = document.querySelector(
|
||||
".content-wrapper",
|
||||
) as HTMLElement;
|
||||
}
|
||||
if (!scrollContainer) {
|
||||
scrollContainer = document.querySelector(
|
||||
"main",
|
||||
) as HTMLElement;
|
||||
}
|
||||
// Always use window scroll now
|
||||
scrollContainer = null;
|
||||
updateMeasurements();
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,8 @@
|
||||
sans-serif;
|
||||
background-color: #0c0c0c;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -159,8 +160,7 @@
|
||||
}
|
||||
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
min-height: 100vh;
|
||||
padding: 10px;
|
||||
padding-bottom: 70px;
|
||||
box-sizing: border-box;
|
||||
@@ -181,9 +181,7 @@
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
@@ -193,7 +191,6 @@
|
||||
justify-content: flex-start;
|
||||
box-shadow: none;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
|
||||
@@ -1,104 +1,321 @@
|
||||
<script lang="ts">
|
||||
import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const categories = ["All", "Food", "Fashion", "Tech", "Home"];
|
||||
let selectedCategory = $state("All");
|
||||
// Data Types
|
||||
interface Report {
|
||||
company_name: string;
|
||||
year: string | number;
|
||||
sector: string;
|
||||
greenwashing_score: number | string;
|
||||
filename: string;
|
||||
title?: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
let reports = $state<Report[]>([]);
|
||||
let searchQuery = $state("");
|
||||
let isLoading = $state(false);
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Eco-Water Bottle",
|
||||
brand: "PureLife",
|
||||
category: "Home",
|
||||
score: 92,
|
||||
image: "ri:cup-line",
|
||||
color: "#1ed760",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Fast Fashion Tee",
|
||||
brand: "TrendZ",
|
||||
category: "Fashion",
|
||||
score: 35,
|
||||
image: "ri:t-shirt-2-line",
|
||||
color: "#e91429",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Organic Coffee",
|
||||
brand: "BeanGreen",
|
||||
category: "Food",
|
||||
score: 88,
|
||||
image: "ri:cup-fill",
|
||||
color: "#b49bc8",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Smartphone X",
|
||||
brand: "TechGiant",
|
||||
category: "Tech",
|
||||
score: 45,
|
||||
image: "ri:smartphone-line",
|
||||
color: "#f59b23",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Bamboo Toothbrush",
|
||||
brand: "SmileEco",
|
||||
category: "Home",
|
||||
score: 98,
|
||||
image: "ri:brush-line",
|
||||
color: "#1db954",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Plastic Straws",
|
||||
brand: "SingleUse Inc",
|
||||
category: "Home",
|
||||
score: 12,
|
||||
image: "ri:forbid-2-line",
|
||||
color: "#e91429",
|
||||
},
|
||||
// Predefined categories for filtering (could be dynamic, but static is fine for now)
|
||||
const categories = [
|
||||
"All",
|
||||
"Tech",
|
||||
"Energy",
|
||||
"Automotive",
|
||||
"Aerospace",
|
||||
"Data",
|
||||
"Retail",
|
||||
"Other",
|
||||
];
|
||||
let selectedCategory = $state("All");
|
||||
|
||||
let filteredProducts = $derived(
|
||||
products.filter((p) => {
|
||||
const matchesCategory =
|
||||
selectedCategory === "All" || p.category === selectedCategory;
|
||||
const matchesSearch =
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.brand.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
}),
|
||||
// Initial fetch
|
||||
async function fetchReports() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await fetch("http://localhost:5000/api/reports/");
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
reports = data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch reports", e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchReports();
|
||||
});
|
||||
|
||||
// Handle search
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.trim()) {
|
||||
fetchReports(); // Reset if empty
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
"http://localhost:5000/api/reports/search",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: searchQuery }),
|
||||
},
|
||||
);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
reports = data; // These will have 'snippet'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Search failed", e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectCategory(category: string) {
|
||||
selectedCategory = category;
|
||||
// Filter computed property
|
||||
// If the user searches (backend search), 'reports' is already filtered by the backend.
|
||||
// But if they just select a category, we filter client-side on the *initial* list.
|
||||
// To support both properly, complex logic is needed.
|
||||
// Simple approach:
|
||||
// 1. Search Bar -> Backend Search (ignores category dropdown or resets it)
|
||||
// 2. Category Dropdown -> Client-side filter of *currently loaded* reports OR fetch all and filter.
|
||||
// Let's make "Search" dominant. If query is empty, allow category filtering.
|
||||
|
||||
// Pagination
|
||||
let currentPage = $state(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// Filtered list (all items that match criteria)
|
||||
let filteredReports = $derived.by(() => {
|
||||
if (searchQuery.length > 0) {
|
||||
return reports;
|
||||
} else {
|
||||
if (selectedCategory === "All") return reports;
|
||||
return reports.filter((r) => {
|
||||
const sector = r.sector?.toLowerCase() || "other";
|
||||
return sector.includes(selectedCategory.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Paginated slice
|
||||
let paginatedReports = $derived.by(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
return filteredReports.slice(start, end);
|
||||
});
|
||||
|
||||
let totalPages = $derived(Math.ceil(filteredReports.length / itemsPerPage));
|
||||
|
||||
// Reset page when filter changes
|
||||
$effect(() => {
|
||||
// subtle dependency tracking
|
||||
const _ = searchQuery;
|
||||
const __ = selectedCategory;
|
||||
currentPage = 1;
|
||||
});
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce search
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
function onSearchInput() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
handleSearch();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function getScoreColor(score: any) {
|
||||
const s = Number(score);
|
||||
if (isNaN(s)) return "#9ca3af"; // grey
|
||||
if (s < 30) return "#ef4444"; // red
|
||||
if (s < 70) return "#f59b23"; // orange
|
||||
return "#22c55e"; // green
|
||||
}
|
||||
|
||||
function getFileDetails(filename: string) {
|
||||
const ext = filename.split(".").pop()?.toUpperCase() || "FILE";
|
||||
let icon = "ri:file-line";
|
||||
if (ext === "PDF") icon = "ri:file-pdf-line";
|
||||
else if (["XLS", "XLSX", "CSV"].includes(ext))
|
||||
icon = "ri:file-excel-line";
|
||||
else if (["TXT", "MD"].includes(ext)) icon = "ri:file-text-line";
|
||||
|
||||
return { type: ext, icon };
|
||||
}
|
||||
|
||||
// Modal State
|
||||
let selectedReport = $state<Report | null>(null);
|
||||
let isModalOpen = $state(false);
|
||||
|
||||
function openReport(report: Report) {
|
||||
selectedReport = report;
|
||||
isModalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isModalOpen = false;
|
||||
selectedReport = null;
|
||||
}
|
||||
|
||||
// Close on Escape key
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && isModalOpen) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Ethix - Product Catalogue</title>
|
||||
<title>Ethix - Sustainability Reports</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Browse our extensive database of sustainable products, verified eco-scores, and green alternatives."
|
||||
content="Search and browse verified sustainability reports and greenwashing analysis."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="page-wrapper">
|
||||
{#if isModalOpen && selectedReport}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onclick={closeModal}
|
||||
onkeydown={(e) => e.key === "Escape" && closeModal()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<!-- Stop propagation to prevent closing when clicking content -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="modal-content"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<div class="modal-title-group">
|
||||
<h2>{selectedReport.company_name}</h2>
|
||||
<span class="modal-subtitle"
|
||||
>{selectedReport.title ||
|
||||
selectedReport.filename}</span
|
||||
>
|
||||
</div>
|
||||
<button class="close-button" onclick={closeModal}>
|
||||
<Icon icon="ri:close-line" width="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{#if selectedReport.filename
|
||||
.toLowerCase()
|
||||
.endsWith(".pdf") || selectedReport.filename
|
||||
.toLowerCase()
|
||||
.endsWith(".txt")}
|
||||
<iframe
|
||||
src="http://localhost:5000/api/reports/view/{selectedReport.filename}"
|
||||
class="file-viewer"
|
||||
title="Report Viewer"
|
||||
></iframe>
|
||||
{:else}
|
||||
<div class="no-preview">
|
||||
<Icon
|
||||
icon="ri:file-warning-line"
|
||||
width="64"
|
||||
color="#94a3b8"
|
||||
/>
|
||||
<p>Preview not available for this file type.</p>
|
||||
<a
|
||||
href="http://localhost:5000/api/reports/view/{selectedReport.filename}"
|
||||
download
|
||||
class="download-btn"
|
||||
>
|
||||
Download File
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="desktop-bg">
|
||||
<ParallaxLandscape />
|
||||
<div class="bg-overlay"></div>
|
||||
</div>
|
||||
|
||||
{#snippet paginationControls()}
|
||||
{#if totalPages > 1}
|
||||
<div class="pagination">
|
||||
<button
|
||||
class="page-btn nav"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
<Icon
|
||||
icon="ri:arrow-left-s-line"
|
||||
width="24"
|
||||
color="#ffffff"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="page-numbers">
|
||||
{#if totalPages <= 7}
|
||||
{#each Array(totalPages) as _, i}
|
||||
<button
|
||||
class="page-btn number"
|
||||
class:active={currentPage === i + 1}
|
||||
onclick={() => goToPage(i + 1)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="page-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="page-btn nav"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
<Icon
|
||||
icon="ri:arrow-right-s-line"
|
||||
width="24"
|
||||
color="#ffffff"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="content-container">
|
||||
<div class="glass-header">
|
||||
<!-- Header content -->
|
||||
<div class="header">
|
||||
<h1 class="page-title">Product Database</h1>
|
||||
<h1 class="page-title">Sustainability Database</h1>
|
||||
<p class="subtitle">
|
||||
Search our verified sustainability ratings
|
||||
Search within verified company reports and impact
|
||||
assessments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -109,8 +326,9 @@
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search for products, brands..."
|
||||
placeholder="Search for companies, topics (e.g., 'emissions')..."
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -119,39 +337,108 @@
|
||||
<button
|
||||
class="filter-chip"
|
||||
class:active={selectedCategory === category}
|
||||
onclick={() => selectCategory(category)}
|
||||
onclick={() => (selectedCategory = category)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination inside header -->
|
||||
{@render paginationControls()}
|
||||
</div>
|
||||
|
||||
<div class="product-grid">
|
||||
{#each filteredProducts as product}
|
||||
<div class="product-card">
|
||||
<div class="card-image-placeholder">
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<Icon icon="eos-icons:loading" width="40" color="#34d399" />
|
||||
<p>Syncing with database...</p>
|
||||
</div>
|
||||
{:else if filteredReports.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No reports found matching your criteria.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="report-list">
|
||||
{#each paginatedReports as report}
|
||||
{@const fileDetails = getFileDetails(report.filename)}
|
||||
<button
|
||||
class="report-card"
|
||||
onclick={() => openReport(report)}
|
||||
onkeydown={(e) =>
|
||||
e.key === "Enter" && openReport(report)}
|
||||
>
|
||||
<div class="card-icon">
|
||||
<Icon
|
||||
icon={product.image}
|
||||
width="56"
|
||||
style="color: {product.color};"
|
||||
icon={fileDetails.icon}
|
||||
width="32"
|
||||
color="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="product-info">
|
||||
<h3 class="product-name">{product.name}</h3>
|
||||
<p class="product-brand">{product.brand}</p>
|
||||
<div class="report-info">
|
||||
<div class="report-header">
|
||||
<h3 class="company-name">
|
||||
{report.company_name}
|
||||
</h3>
|
||||
<span class="report-year">{report.year}</span>
|
||||
</div>
|
||||
|
||||
{#if report.snippet}
|
||||
<p class="report-snippet">
|
||||
{@html report.snippet.replace(
|
||||
new RegExp(searchQuery, "gi"),
|
||||
(match) =>
|
||||
`<span class="highlight">${match}</span>`,
|
||||
)}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="report-filename">
|
||||
{report.sector} Sector • Impact Report
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="report-tags">
|
||||
<span
|
||||
class="tag filename"
|
||||
title={report.filename}
|
||||
>
|
||||
<Icon icon={fileDetails.icon} width="14" />
|
||||
{fileDetails.type}
|
||||
</span>
|
||||
<!-- Mock verified tag -->
|
||||
<span class="tag verified">
|
||||
<Icon
|
||||
icon="ri:checkbox-circle-fill"
|
||||
width="14"
|
||||
color="#22c55e"
|
||||
/>
|
||||
Analyzed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Badge -->
|
||||
{#if report.greenwashing_score}
|
||||
<div class="score-container">
|
||||
<div
|
||||
class="score-badge"
|
||||
style="background-color: {product.color};"
|
||||
style="background-color: {getScoreColor(
|
||||
report.greenwashing_score,
|
||||
)};"
|
||||
>
|
||||
<span class="score-text"
|
||||
>{Math.round(
|
||||
Number(report.greenwashing_score),
|
||||
)}</span
|
||||
>
|
||||
<span class="score-text">{product.score}</span>
|
||||
</div>
|
||||
<span class="score-label">Trust Score</span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,7 +462,7 @@
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 100px 24px 120px;
|
||||
max-width: 1200px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -281,81 +568,154 @@
|
||||
box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 0 12px;
|
||||
/* Report List Styles */
|
||||
/* Report List Styles */
|
||||
.report-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px; /* Reduced gap */
|
||||
}
|
||||
|
||||
.product-card {
|
||||
.report-card {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
border-radius: 16px; /* Slightly reduced radius */
|
||||
padding: 16px; /* Compact padding */
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center; /* Center vertically */
|
||||
gap: 16px; /* Reduced gap */
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
.report-card:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-image-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
/* ... skipped some styles ... */
|
||||
|
||||
.card-icon {
|
||||
width: 42px; /* Smaller icon */
|
||||
height: 42px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex-grow: 1;
|
||||
.report-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
.report-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px; /* Tighter margin */
|
||||
}
|
||||
|
||||
.company-name {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-size: 18px; /* Smaller title */
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.product-brand {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-year {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #94a3b8;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-filename {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
margin: 0 0 8px 0; /* Tighter margin */
|
||||
}
|
||||
|
||||
.report-snippet {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 8px 0;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #34d399;
|
||||
}
|
||||
|
||||
.report-snippet :global(.highlight) {
|
||||
background: rgba(52, 211, 153, 0.3);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.report-tags {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.score-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 28px;
|
||||
width: 36px;
|
||||
width: 36px; /* Smaller badge */
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.score-text {
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -379,9 +739,182 @@
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
.report-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.score-container {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 16px;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pagination Styles */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px; /* Reduced from 40px for header placement */
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-btn.nav {
|
||||
width: 32px; /* Smaller nav buttons */
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.page-btn.number {
|
||||
width: 32px; /* Smaller number buttons */
|
||||
height: 32px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: #34d399;
|
||||
color: #051f18;
|
||||
border-color: #34d399;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-numbers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px;
|
||||
background: #1e293b;
|
||||
border-bottom: 1px solid #334155;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-title-group h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.no-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #34d399;
|
||||
color: #0f172a;
|
||||
padding: 12px 24px;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { marked } from "marked";
|
||||
|
||||
let messages = $state([
|
||||
{
|
||||
@@ -12,38 +13,74 @@
|
||||
]);
|
||||
let inputText = $state("");
|
||||
let canvasElement = $state<HTMLCanvasElement>();
|
||||
let isLoading = $state(false);
|
||||
let chatWindowFn: HTMLDivElement | undefined = $state();
|
||||
|
||||
function sendMessage() {
|
||||
if (!inputText.trim()) return;
|
||||
function scrollToBottom() {
|
||||
if (chatWindowFn) {
|
||||
setTimeout(() => {
|
||||
chatWindowFn!.scrollTop = chatWindowFn!.scrollHeight;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Dependencies to trigger scroll
|
||||
messages;
|
||||
isLoading;
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
async function sendMessage() {
|
||||
if (!inputText.trim() || isLoading) return;
|
||||
|
||||
const userText = inputText;
|
||||
const userMsg = {
|
||||
id: Date.now(),
|
||||
text: inputText,
|
||||
text: userText,
|
||||
sender: "user",
|
||||
};
|
||||
const aiResponse = generateResponse(inputText);
|
||||
messages = [...messages, userMsg];
|
||||
inputText = "";
|
||||
isLoading = true;
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"http://localhost:5000/api/gemini/ask",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: userText,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
const aiMsg = {
|
||||
id: Date.now() + 1,
|
||||
text: aiResponse,
|
||||
text: data.reply,
|
||||
sender: "ai",
|
||||
};
|
||||
messages = [...messages, aiMsg];
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to get response");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = {
|
||||
id: Date.now() + 1,
|
||||
text: "Sorry, I'm having trouble connecting to my brain right now. Please try again later.",
|
||||
sender: "ai",
|
||||
};
|
||||
messages = [...messages, errorMsg];
|
||||
console.error("Chat Error:", error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function generateResponse(text: string): string {
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes("plastic"))
|
||||
return "Plastic takes 450 years to decompose. Check the resin code (triangle number) to see if you can recycle it.";
|
||||
if (lower.includes("glass"))
|
||||
return "Glass is 100% recyclable. You can recycle it forever without losing quality.";
|
||||
if (lower.includes("aluminum"))
|
||||
return "Aluminum is sustainable gold. Infinite recycling, low energy cost to reuse.";
|
||||
return "Great question! Sustainable living starts with buying less, then reusing, then recycling.";
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -62,35 +99,40 @@
|
||||
function animate() {
|
||||
frame++;
|
||||
if (!ctx || !canvasElement) return;
|
||||
ctx.clearRect(0, 0, 100, 100);
|
||||
ctx.clearRect(0, 0, 40, 40);
|
||||
|
||||
const yOffset = Math.sin(frame * 0.05) * 5;
|
||||
const yOffset = Math.sin(frame * 0.05) * 3; // Reduced amplitude
|
||||
|
||||
// Head
|
||||
ctx.fillStyle = "#e0e0e0";
|
||||
ctx.beginPath();
|
||||
ctx.arc(50, 50, 45, 0, Math.PI * 2);
|
||||
ctx.arc(20, 20, 18, 0, Math.PI * 2); // Center at 20,20, Radius 18
|
||||
ctx.fill();
|
||||
|
||||
// Face/Visor
|
||||
ctx.fillStyle = "#22c55e";
|
||||
ctx.beginPath();
|
||||
ctx.arc(50, 50 + yOffset, 35, 0, Math.PI * 2);
|
||||
ctx.arc(20, 20 + yOffset, 14, 0, Math.PI * 2); // Center 20,20
|
||||
ctx.fill();
|
||||
|
||||
// Reflection/Detail
|
||||
ctx.fillStyle = "#16a34a";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(50, 15 + yOffset);
|
||||
ctx.quadraticCurveTo(70, 5 + yOffset, 60, 35 + yOffset);
|
||||
ctx.moveTo(20, 8 + yOffset);
|
||||
ctx.quadraticCurveTo(28, 4 + yOffset, 24, 16 + yOffset);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Eyes
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(35, 40 + yOffset, 8, 12);
|
||||
ctx.fillRect(57, 40 + yOffset, 8, 12);
|
||||
ctx.fillRect(14, 16 + yOffset, 3, 5);
|
||||
ctx.fillRect(23, 16 + yOffset, 3, 5);
|
||||
|
||||
// Smile
|
||||
ctx.strokeStyle = "white";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(50, 65 + yOffset, 10, 0.2, Math.PI - 0.2);
|
||||
ctx.arc(20, 26 + yOffset, 4, 0.2, Math.PI - 0.2);
|
||||
ctx.stroke();
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
@@ -118,30 +160,44 @@
|
||||
<div class="mascot-container">
|
||||
<canvas
|
||||
bind:this={canvasElement}
|
||||
width="100"
|
||||
height="100"
|
||||
width="40"
|
||||
height="40"
|
||||
class="mascot-canvas"
|
||||
></canvas>
|
||||
<div class="mascot-status-dot"></div>
|
||||
</div>
|
||||
<div class="header-text-center">
|
||||
<h1 class="page-title">Ethix Assistant</h1>
|
||||
<div class="powered-by">
|
||||
<Icon icon="ri:shining-fill" width="12" />
|
||||
<Icon icon="ri:shining-fill" width="10" />
|
||||
<span>Powered by Gemini</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-window">
|
||||
<div class="messages-container">
|
||||
<div class="messages-container" bind:this={chatWindowFn}>
|
||||
{#each messages as msg (msg.id)}
|
||||
<div
|
||||
class="message"
|
||||
class:user-message={msg.sender === "user"}
|
||||
class:ai-message={msg.sender === "ai"}
|
||||
>
|
||||
<p>{msg.text}</p>
|
||||
<div class="message-content">
|
||||
{@html marked.parse(msg.text)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="message ai-message loading-bubble">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="input-container">
|
||||
@@ -168,8 +224,9 @@
|
||||
<style>
|
||||
.page-wrapper {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.desktop-bg {
|
||||
@@ -183,6 +240,9 @@
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-card {
|
||||
@@ -193,63 +253,72 @@
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 700px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 32px;
|
||||
padding-bottom: 24px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #1f473b;
|
||||
text-align: center;
|
||||
background: #051f18;
|
||||
display: flex;
|
||||
flex-direction: row; /* Horizontal Layout for Compactness */
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-text-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start; /* Left Align Text */
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mascot-container {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.mascot-canvas {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
filter: drop-shadow(0 4px 12px rgba(16, 185, 129, 0.4));
|
||||
}
|
||||
|
||||
.mascot-status-dot {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #34d399;
|
||||
border: 3px solid #051f18;
|
||||
border: 2px solid #051f18;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 12px rgba(52, 211, 153, 0.6);
|
||||
box-shadow: 0 0 8px rgba(52, 211, 153, 0.6);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.powered-by {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #34d399;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
@@ -264,20 +333,48 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
padding-bottom: 100px;
|
||||
padding-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 16px 20px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 20px;
|
||||
max-width: 85%;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Message Content Markdown Styles */
|
||||
.message-content :global(p) {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.message-content :global(p:last-child) {
|
||||
margin: 0;
|
||||
}
|
||||
.message-content :global(strong) {
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
}
|
||||
.message-content :global(ul),
|
||||
.message-content :global(ol) {
|
||||
margin: 4px 0 8px 20px;
|
||||
padding: 0;
|
||||
}
|
||||
.message-content :global(li) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.message-content :global(h1),
|
||||
.message-content :global(h2),
|
||||
.message-content :global(h3) {
|
||||
font-weight: 700;
|
||||
font-size: 1.1em;
|
||||
margin: 8px 0 4px 0;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
@@ -289,14 +386,50 @@
|
||||
|
||||
.ai-message {
|
||||
align-self: flex-start;
|
||||
background: #051f18;
|
||||
background: #134e4a; /* Teal-900 for better visibility */
|
||||
border-bottom-left-radius: 6px;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #1f473b;
|
||||
color: white; /* White text for contrast */
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.message p {
|
||||
margin: 0;
|
||||
/* Loading Bubble */
|
||||
.loading-bubble {
|
||||
padding: 12px 16px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #34d399;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
@@ -426,12 +559,8 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px;
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
padding: 12px 16px;
|
||||
padding-top: 50px; /* Safe area for some mobile devices */
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
@@ -439,7 +568,7 @@
|
||||
}
|
||||
|
||||
.input-container {
|
||||
padding-bottom: 90px;
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user