diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..23c9c8c --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,44 @@ +# GEMINI.md + +## Project Overview + +This is a Next.js application built with TypeScript that classifies shoes using a machine learning model. The application uses the device's camera to capture images of shoes and then uses a pre-trained model to classify them. The classification results are displayed to the user and can be saved in a history for later review. + +The project uses `pnpm` as the package manager and is structured as a typical Next.js application with the `app` directory for routing. It uses `tailwindcss` for styling and `lucide-react` for icons. The UI is built with a combination of custom components and components from the `shadcn/ui` library. + +## Building and Running + +To build and run this project, you will need to have Node.js and `pnpm` installed. + +1. **Install dependencies:** + ```bash + pnpm install + ``` + +2. **Run the development server:** + ```bash + pnpm run dev + ``` + +3. **Build the project:** + ```bash + pnpm run build + ``` + +4. **Start the production server:** + ```bash + pnpm run start + ``` + +5. **Lint the project:** + ```bash + pnpm run lint + ``` + +## Development Conventions + +* **Styling:** The project uses `tailwindcss` for styling. Utility classes are preferred over custom CSS. +* **Components:** The project uses a combination of custom components and components from the `shadcn/ui` library. Custom components are located in the `components` directory. +* **State Management:** The project uses React's built-in state management (`useState`, `useRef`, `useEffect`, `useCallback`) for managing component state. +* **Linting:** The project uses ESLint for linting. The configuration is in the `eslint.config.mjs` file. +* **TypeScript:** The project is written in TypeScript. The configuration is in the `tsconfig.json` file. diff --git a/app/globals.css b/app/globals.css index a2dc41e..ede4396 100644 --- a/app/globals.css +++ b/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; -} diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..0f2c6d3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -23,7 +23,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/app/page.tsx b/app/page.tsx index 88f0cc9..2e8cac0 100644 --- a/app/page.tsx +++ b/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(null); + const [stream, setStream] = useState(null); + const [cameraStatus, setCameraStatus] = useState('idle'); + + const [videoDevices, setVideoDevices] = useState([]); + const [selectedDeviceId, setSelectedDeviceId] = useState(''); + + const [activeShoe, setActiveShoe] = useState(null); + const [isPopupOpen, setPopupOpen] = useState(false); + const [isHistoryOpen, setHistoryOpen] = useState(false); + const [history, setHistory] = useState([]); + + // 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 ( + <> +