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 (
+ <>
+