initial commit
This commit is contained in:
127
app/globals.css
127
app/globals.css
@@ -1,26 +1,125 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: oklch(1 0 0); /* #ffffff - pure white */
|
||||
--foreground: oklch(0.278 0.013 257.57); /* #374151 - dark gray */
|
||||
--card: oklch(0.985 0.024 102.37); /* #fefce8 - light cream */
|
||||
--card-foreground: oklch(0.278 0.013 257.57); /* #374151 - dark gray */
|
||||
--popover: oklch(1 0 0); /* #ffffff - white for popups */
|
||||
--popover-foreground: oklch(0.278 0.013 257.57); /* #374151 - dark gray */
|
||||
--primary: oklch(0.647 0.167 70.67); /* #d97706 - amber-600 */
|
||||
--primary-foreground: oklch(1 0 0); /* #ffffff - white text */
|
||||
--secondary: oklch(0.627 0.265 303.9); /* #6366f1 - bright blue */
|
||||
--secondary-foreground: oklch(1 0 0); /* #ffffff - white text */
|
||||
--muted: oklch(0.98 0.005 106.42); /* #f8fafc - light gray */
|
||||
--muted-foreground: oklch(0.278 0.013 257.57); /* #374151 - dark gray */
|
||||
--accent: oklch(0.627 0.265 303.9); /* #6366f1 - bright blue */
|
||||
--accent-foreground: oklch(1 0 0); /* #ffffff - white text */
|
||||
--destructive: oklch(0.576 0.232 35.75); /* #ea580c - orange-red */
|
||||
--destructive-foreground: oklch(1 0 0); /* #ffffff - white text */
|
||||
--border: oklch(0.647 0.167 70.67); /* #d97706 - amber border */
|
||||
--input: oklch(0.985 0.024 102.37); /* #fefce8 - light cream input */
|
||||
--ring: oklch(0.647 0.167 70.67 / 0.5); /* amber focus ring */
|
||||
--chart-1: oklch(0.647 0.167 70.67); /* #d97706 - amber */
|
||||
--chart-2: oklch(0.627 0.265 303.9); /* #6366f1 - blue */
|
||||
--chart-3: oklch(0.576 0.232 35.75); /* #ea580c - orange */
|
||||
--chart-4: oklch(0.368 0.014 257.29); /* #4b5563 - gray */
|
||||
--chart-5: oklch(0.278 0.013 257.57); /* #374151 - dark gray */
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.98 0.005 106.42); /* #f8fafc - light gray */
|
||||
--sidebar-foreground: oklch(0.278 0.013 257.57); /* #374151 - dark gray */
|
||||
--sidebar-primary: oklch(0.647 0.167 70.67); /* #d97706 - amber */
|
||||
--sidebar-primary-foreground: oklch(1 0 0); /* #ffffff - white */
|
||||
--sidebar-accent: oklch(0.627 0.265 303.9); /* #6366f1 - blue */
|
||||
--sidebar-accent-foreground: oklch(1 0 0); /* #ffffff - white */
|
||||
--sidebar-border: oklch(0.647 0.167 70.67); /* #d97706 - amber border */
|
||||
--sidebar-ring: oklch(0.647 0.167 70.67 / 0.5); /* amber focus ring */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.647 0.167 70.67); /* Keep amber in dark mode */
|
||||
--primary-foreground: oklch(0.145 0 0);
|
||||
--secondary: oklch(0.627 0.265 303.9); /* Keep blue in dark mode */
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.627 0.265 303.9); /* Keep blue accent */
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.576 0.232 35.75);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.647 0.167 70.67 / 0.5);
|
||||
--chart-1: oklch(0.647 0.167 70.67);
|
||||
--chart-2: oklch(0.627 0.265 303.9);
|
||||
--chart-3: oklch(0.576 0.232 35.75);
|
||||
--chart-4: oklch(0.368 0.014 257.29);
|
||||
--chart-5: oklch(0.278 0.013 257.57);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.647 0.167 70.67);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.627 0.265 303.9);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.647 0.167 70.67 / 0.5);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="dark">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
|
||||
262
app/page.tsx
262
app/page.tsx
@@ -1,103 +1,165 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Camera, History, VideoOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Assuming shadcn/ui has Select
|
||||
import { SHOE_DATABASE, type Shoe } from '@/lib/shoe-database';
|
||||
import { detectShoe } from '@/lib/ml-classification';
|
||||
import { addToHistory, getHistory } from '@/lib/history-storage';
|
||||
import ShoeResultsPopup from '@/components/shoe-results-popup';
|
||||
import HistorySidebar from '@/components/history-sidebar';
|
||||
|
||||
type CameraStatus = 'idle' | 'active' | 'denied' | 'no_devices';
|
||||
|
||||
export default function HomePage() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [cameraStatus, setCameraStatus] = useState<CameraStatus>('idle');
|
||||
|
||||
const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
|
||||
|
||||
const [activeShoe, setActiveShoe] = useState<Shoe | null>(null);
|
||||
const [isPopupOpen, setPopupOpen] = useState(false);
|
||||
const [isHistoryOpen, setHistoryOpen] = useState(false);
|
||||
const [history, setHistory] = useState<Shoe[]>([]);
|
||||
|
||||
// Effect to clean up the stream when component unmounts or stream changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
}, [stream]);
|
||||
|
||||
const startStream = async (deviceId: string) => {
|
||||
// Stop previous stream if it exists
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
|
||||
try {
|
||||
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: { exact: deviceId } },
|
||||
});
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = newStream;
|
||||
}
|
||||
setStream(newStream);
|
||||
setCameraStatus('active');
|
||||
} catch (err) {
|
||||
console.error("Error starting stream: ", err);
|
||||
setCameraStatus('denied');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCamera = async () => {
|
||||
setHistory(getHistory());
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoInputs = devices.filter((d) => d.kind === 'videoinput');
|
||||
if (videoInputs.length === 0) {
|
||||
setCameraStatus('no_devices');
|
||||
return;
|
||||
}
|
||||
setVideoDevices(videoInputs);
|
||||
const firstDeviceId = videoInputs[0].deviceId;
|
||||
setSelectedDeviceId(firstDeviceId);
|
||||
await startStream(firstDeviceId);
|
||||
} catch (err) {
|
||||
console.error("Error enumerating devices: ", err);
|
||||
setCameraStatus('denied');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCameraChange = (deviceId: string) => {
|
||||
setSelectedDeviceId(deviceId);
|
||||
startStream(deviceId);
|
||||
};
|
||||
|
||||
const handleScan = () => {
|
||||
const detected = detectShoe(SHOE_DATABASE);
|
||||
if (detected) {
|
||||
setActiveShoe(detected);
|
||||
const updatedHistory = addToHistory(detected);
|
||||
setHistory(updatedHistory);
|
||||
setPopupOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHistoryItemClick = (shoe: Shoe) => {
|
||||
setActiveShoe(shoe);
|
||||
setHistoryOpen(false);
|
||||
setPopupOpen(true);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (cameraStatus) {
|
||||
case 'active':
|
||||
return (
|
||||
<>
|
||||
<video ref={videoRef} autoPlay playsInline muted onCanPlay={() => videoRef.current?.play()} className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-between p-6">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="w-64">
|
||||
<Select value={selectedDeviceId} onValueChange={handleCameraChange}>
|
||||
<SelectTrigger className="w-full bg-black/50 text-white border-white/30">
|
||||
<SelectValue placeholder="Seleccionar cámara..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{videoDevices.map((device) => (
|
||||
<SelectItem key={device.deviceId} value={device.deviceId}>
|
||||
{device.label || `Cámara ${videoDevices.indexOf(device) + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="text-white hover:bg-white/20 hover:text-white" onClick={() => setHistoryOpen(true)}>
|
||||
<History size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<Button size="lg" className="h-16 w-16 rounded-full border-4 border-white bg-transparent text-white shadow-lg hover:bg-white/20" onClick={handleScan}>
|
||||
<Camera size={32} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'no_devices':
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-gray-900 text-white">
|
||||
<VideoOff size={48} className="mb-4 text-red-500" />
|
||||
<h1 className="text-xl font-semibold">No se encontraron cámaras</h1>
|
||||
<p className="text-gray-400">Asegúrate de que tu cámara esté conectada.</p>
|
||||
</div>
|
||||
);
|
||||
case 'denied':
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-gray-900 text-white">
|
||||
<Camera size={48} className="mb-4 text-red-500" />
|
||||
<h1 className="text-xl font-semibold">Acceso a la cámara denegado</h1>
|
||||
<p className="text-gray-400">Por favor, habilita el permiso en tu navegador.</p>
|
||||
</div>
|
||||
);
|
||||
case 'idle':
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-gray-900 text-white">
|
||||
<h1 className="mb-4 text-3xl font-bold">Detector de Zapatos</h1>
|
||||
<p className="mb-8 text-gray-400">Haz clic para iniciar la cámara y escanear</p>
|
||||
<Button size="lg" onClick={handleOpenCamera}>
|
||||
<Camera className="mr-2 h-4 w-4" /> Abrir Cámara
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<main className="relative h-screen w-screen bg-black overflow-hidden">
|
||||
{renderContent()}
|
||||
<ShoeResultsPopup isOpen={isPopupOpen} onOpenChange={setPopupOpen} shoe={activeShoe} />
|
||||
<HistorySidebar isOpen={isHistoryOpen} onOpenChange={setHistoryOpen} history={history} onItemClick={handleHistoryItemClick} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user