initial commit

This commit is contained in:
2025-08-27 14:12:38 -06:00
parent 7b28148b02
commit 0ca7d8353d
40 changed files with 2122 additions and 126 deletions

View File

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

View File

@@ -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`}
>

View File

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