Reports Update

This commit is contained in:
2026-01-25 00:16:30 +00:00
parent 87df89fb32
commit d37d925150
11 changed files with 1153 additions and 254 deletions

View File

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

View File

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

View File

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

View File

@@ -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;
}
}
function selectCategory(category: string) {
selectedCategory = category;
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;
}
}
// 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">
<Icon
icon={product.image}
width="56"
style="color: {product.color};"
/>
</div>
<div class="product-info">
<h3 class="product-name">{product.name}</h3>
<p class="product-brand">{product.brand}</p>
</div>
<div
class="score-badge"
style="background-color: {product.color};"
{#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)}
>
<span class="score-text">{product.score}</span>
</div>
</div>
{/each}
</div>
<div class="card-icon">
<Icon
icon={fileDetails.icon}
width="32"
color="#ffffff"
/>
</div>
<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: {getScoreColor(
report.greenwashing_score,
)};"
>
<span class="score-text"
>{Math.round(
Number(report.greenwashing_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>

View File

@@ -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;
const userMsg = {
id: Date.now(),
text: inputText,
sender: "user",
};
const aiResponse = generateResponse(inputText);
messages = [...messages, userMsg];
inputText = "";
setTimeout(() => {
const aiMsg = {
id: Date.now() + 1,
text: aiResponse,
sender: "ai",
};
messages = [...messages, aiMsg];
}, 1000);
function scrollToBottom() {
if (chatWindowFn) {
setTimeout(() => {
chatWindowFn!.scrollTop = chatWindowFn!.scrollHeight;
}, 0);
}
}
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.";
$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: userText,
sender: "user",
};
messages = [...messages, userMsg];
inputText = "";
isLoading = true;
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: data.reply,
sender: "ai",
};
messages = [...messages, aiMsg];
} 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 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>
<h1 class="page-title">Ethix Assistant</h1>
<div class="powered-by">
<Icon icon="ri:shining-fill" width="12" />
<span>Powered by Gemini</span>
<div class="header-text-center">
<h1 class="page-title">Ethix Assistant</h1>
<div class="powered-by">
<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>