mirror of
https://github.com/SirBlobby/Hoya26.git
synced 2026-02-04 03:34:34 -05:00
568 lines
11 KiB
Svelte
568 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import Icon from "@iconify/svelte";
|
|
|
|
interface Props {
|
|
onClose: () => void;
|
|
onScanComplete: (item: any) => void;
|
|
}
|
|
|
|
let { onClose, onScanComplete }: Props = $props();
|
|
|
|
let videoElement = $state<HTMLVideoElement>();
|
|
let stream: MediaStream | null = null;
|
|
let analyzing = $state(false);
|
|
let showResult = $state(false);
|
|
let capturedImage = $state<string | null>(null);
|
|
let displayTitle = $state("");
|
|
let resultTranslateY = $state(100);
|
|
let useCamera = $state(true);
|
|
let fileInputElement = $state<HTMLInputElement>();
|
|
|
|
onMount(() => {
|
|
const initCamera = async () => {
|
|
if (useCamera) {
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: "environment" },
|
|
});
|
|
if (videoElement) {
|
|
videoElement.srcObject = stream;
|
|
}
|
|
} catch (err) {
|
|
console.error("Camera access denied:", err);
|
|
}
|
|
}
|
|
};
|
|
initCamera();
|
|
|
|
return () => {
|
|
if (stream) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
}
|
|
};
|
|
});
|
|
|
|
function playSuccessSound() {
|
|
const audio = new Audio("/report completed.mp3");
|
|
audio.volume = 0.5;
|
|
audio.play().catch((e) => console.error("Error playing sound:", e));
|
|
}
|
|
|
|
function handleFileUpload(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const imageData = e.target?.result as string;
|
|
capturedImage = imageData;
|
|
analyzing = true;
|
|
|
|
setTimeout(() => {
|
|
const newItem = {
|
|
id: Date.now(),
|
|
title: "Plastic Bottle",
|
|
date: new Date().toLocaleString(),
|
|
impact: "High",
|
|
imageUri: capturedImage,
|
|
};
|
|
|
|
onScanComplete(newItem);
|
|
analyzing = false;
|
|
showResult = true;
|
|
resultTranslateY = 0;
|
|
playSuccessSound();
|
|
typeText();
|
|
}, 1200);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
async function takePicture() {
|
|
if (!videoElement) return;
|
|
analyzing = true;
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = videoElement.videoWidth;
|
|
canvas.height = videoElement.videoHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
if (ctx) {
|
|
ctx.drawImage(videoElement, 0, 0);
|
|
capturedImage = canvas.toDataURL("image/png");
|
|
}
|
|
|
|
setTimeout(() => {
|
|
const newItem = {
|
|
id: Date.now(),
|
|
title: "Plastic Bottle",
|
|
date: new Date().toLocaleString(),
|
|
impact: "High",
|
|
imageUri: capturedImage,
|
|
};
|
|
|
|
onScanComplete(newItem);
|
|
analyzing = false;
|
|
showResult = true;
|
|
resultTranslateY = 0;
|
|
playSuccessSound();
|
|
typeText();
|
|
}, 1200);
|
|
}
|
|
|
|
function typeText() {
|
|
let i = 0;
|
|
const txt = "Detected: Plastic Bottle";
|
|
const interval = setInterval(() => {
|
|
displayTitle = txt.substring(0, i);
|
|
i++;
|
|
if (i > txt.length) clearInterval(interval);
|
|
}, 50);
|
|
}
|
|
|
|
function handleClose() {
|
|
if (stream) {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
}
|
|
onClose();
|
|
}
|
|
</script>
|
|
|
|
<div class="camera-container">
|
|
{#if capturedImage}
|
|
<img src={capturedImage} alt="Captured" class="captured-image" />
|
|
{:else if useCamera}
|
|
<video
|
|
bind:this={videoElement}
|
|
autoplay
|
|
playsinline
|
|
class="video-element"
|
|
></video>
|
|
{:else}
|
|
<div class="upload-placeholder">
|
|
<div class="upload-icon">
|
|
<Icon
|
|
icon="ri:image-add-line"
|
|
width="80"
|
|
style="color: #4ade80;"
|
|
/>
|
|
</div>
|
|
<p class="upload-text">Select an image to scan</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="camera-overlay">
|
|
{#if !showResult}
|
|
<button
|
|
class="close-btn"
|
|
onclick={handleClose}
|
|
aria-label="Close camera"
|
|
>
|
|
<Icon icon="ri:close-fill" width="32" style="color: white;" />
|
|
</button>
|
|
|
|
<button
|
|
class="mode-toggle-btn"
|
|
onclick={() => (useCamera = !useCamera)}
|
|
>
|
|
{#if useCamera}
|
|
<Icon
|
|
icon="ri:image-line"
|
|
width="20"
|
|
style="color: white;"
|
|
/>
|
|
<span>Upload File</span>
|
|
{:else}
|
|
<Icon
|
|
icon="ri:camera-line"
|
|
width="20"
|
|
style="color: white;"
|
|
/>
|
|
<span>Use Camera</span>
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
|
|
{#if analyzing}
|
|
<div class="loading-container">
|
|
<div class="spinner"></div>
|
|
<p class="analyzing-text">Analyzing...</p>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !analyzing && !showResult}
|
|
<div class="shutter-container">
|
|
{#if useCamera}
|
|
<button
|
|
class="shutter-button"
|
|
onclick={takePicture}
|
|
aria-label="Take picture"
|
|
></button>
|
|
{:else}
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
bind:this={fileInputElement}
|
|
onchange={handleFileUpload}
|
|
style="display: none;"
|
|
/>
|
|
<button
|
|
class="shutter-button upload-button"
|
|
onclick={() => fileInputElement?.click()}
|
|
aria-label="Upload image"
|
|
>
|
|
<Icon
|
|
icon="ri:upload-cloud-line"
|
|
width="32"
|
|
style="color: #0f172a;"
|
|
/>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showResult}
|
|
<div
|
|
class="result-sheet"
|
|
style="transform: translateY({resultTranslateY}%);"
|
|
>
|
|
<button
|
|
class="sheet-close-btn"
|
|
onclick={handleClose}
|
|
aria-label="Close result"
|
|
>
|
|
<Icon
|
|
icon="ri:close-circle-fill"
|
|
width="28"
|
|
style="color: #94a3b8;"
|
|
/>
|
|
</button>
|
|
|
|
<h2 class="sheet-title">{displayTitle}</h2>
|
|
|
|
<p class="alternatives-label">Top Sustainable Alternatives</p>
|
|
|
|
<div class="alternatives-scroll">
|
|
<button class="alternative-card glass-bottle">
|
|
<div class="alt-header">
|
|
<Icon
|
|
icon="ri:cup-fill"
|
|
width="24"
|
|
style="color: #60a5fa;"
|
|
/>
|
|
<span class="rating">★ 4.9</span>
|
|
</div>
|
|
<h3 class="alt-name">Glass Bottle</h3>
|
|
<p class="alt-price">$2.49</p>
|
|
</button>
|
|
|
|
<button class="alternative-card boxed-water">
|
|
<div class="alt-header">
|
|
<Icon
|
|
icon="ri:box-3-fill"
|
|
width="24"
|
|
style="color: #a78bfa;"
|
|
/>
|
|
<span class="rating">★ 4.7</span>
|
|
</div>
|
|
<h3 class="alt-name">Boxed Water</h3>
|
|
<p class="alt-price">$1.89</p>
|
|
</button>
|
|
|
|
<button class="alternative-card aluminum">
|
|
<div class="alt-header">
|
|
<Icon
|
|
icon="ri:beer-fill"
|
|
width="24"
|
|
style="color: #9ca3af;"
|
|
/>
|
|
<span class="rating">★ 4.5</span>
|
|
</div>
|
|
<h3 class="alt-name">Aluminum</h3>
|
|
<p class="alt-price">$1.29</p>
|
|
</button>
|
|
</div>
|
|
|
|
<button class="report-btn" onclick={handleClose}>
|
|
<Icon icon="ri:alarm-warning-fill" width="20" />
|
|
<span>Report Greenwashing</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.camera-container {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100vh;
|
|
background-color: #000;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.video-element,
|
|
.captured-image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.camera-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.close-btn {
|
|
position: absolute;
|
|
top: 50px;
|
|
left: 20px;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
backdrop-filter: blur(4px);
|
|
border-radius: 50%;
|
|
width: 44px;
|
|
height: 44px;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
z-index: 10;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.loading-container {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.analyzing-text {
|
|
color: white;
|
|
font-weight: 600;
|
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.spinner {
|
|
width: 60px;
|
|
height: 60px;
|
|
border: 4px solid rgba(255, 255, 255, 0.2);
|
|
border-top-color: #4ade80;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.shutter-container {
|
|
position: absolute;
|
|
bottom: 60px;
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.shutter-button {
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 50%;
|
|
background-color: white;
|
|
border: 6px solid rgba(255, 255, 255, 0.3);
|
|
cursor: pointer;
|
|
transition: transform 0.2s;
|
|
background-clip: padding-box;
|
|
}
|
|
|
|
.shutter-button:active {
|
|
transform: scale(0.9);
|
|
background-color: #e2e8f0;
|
|
}
|
|
|
|
.result-sheet {
|
|
position: absolute;
|
|
bottom: 0;
|
|
width: 100%;
|
|
height: 60vh;
|
|
background-color: #0d2e25;
|
|
border-top-left-radius: 32px;
|
|
border-top-right-radius: 32px;
|
|
padding: 24px;
|
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
overflow-y: auto;
|
|
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.3);
|
|
border-top: 1px solid #1f473b;
|
|
}
|
|
|
|
.sheet-close-btn {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
align-self: flex-end;
|
|
display: block;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.sheet-title {
|
|
font-size: 24px;
|
|
font-weight: 800;
|
|
margin-top: 4px;
|
|
margin-bottom: 24px;
|
|
color: white;
|
|
}
|
|
|
|
.alternatives-label {
|
|
color: #6ee7b7;
|
|
margin-bottom: 16px;
|
|
font-weight: 700;
|
|
font-size: 13px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.alternatives-scroll {
|
|
display: flex;
|
|
gap: 12px;
|
|
overflow-x: auto;
|
|
padding-bottom: 16px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.alternative-card {
|
|
min-width: 160px;
|
|
background-color: #051f18;
|
|
padding: 16px;
|
|
border-radius: 20px;
|
|
border: 1px solid #1f473b;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02);
|
|
}
|
|
|
|
.alternative-card:active {
|
|
background-color: #0d2e25;
|
|
}
|
|
|
|
.alt-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.rating {
|
|
font-weight: bold;
|
|
color: #f59e0b;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.alt-name {
|
|
font-weight: 700;
|
|
color: white;
|
|
font-size: 15px;
|
|
margin: 4px 0;
|
|
}
|
|
|
|
.alt-price {
|
|
color: #9ca3af;
|
|
margin: 0;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.report-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
margin: 32px auto 0;
|
|
padding: 14px 24px;
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
border-radius: 16px;
|
|
cursor: pointer;
|
|
color: #f87171;
|
|
font-weight: 700;
|
|
font-size: 15px;
|
|
width: 100%;
|
|
}
|
|
|
|
.report-btn:active {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
}
|
|
|
|
.upload-placeholder {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
background-color: #051f18;
|
|
}
|
|
|
|
.upload-icon {
|
|
margin-bottom: 24px;
|
|
opacity: 1;
|
|
background: rgba(16, 185, 129, 0.1);
|
|
width: 120px;
|
|
height: 120px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #34d399;
|
|
}
|
|
|
|
.upload-text {
|
|
color: #9ca3af;
|
|
font-size: 16px;
|
|
text-align: center;
|
|
padding: 0 32px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.mode-toggle-btn {
|
|
position: absolute;
|
|
top: 50px;
|
|
right: 20px;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
border-radius: 20px;
|
|
padding: 8px 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
z-index: 10;
|
|
}
|
|
|
|
.mode-toggle-btn span {
|
|
color: white;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.upload-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-color: white;
|
|
}
|
|
</style>
|