Files
Hoya26/frontend/src/lib/components/CameraScreen.svelte
2026-01-25 12:33:19 -05:00

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>