diff --git a/frontend/src/components/Dialog/VoiceCommand.tsx b/frontend/src/components/Dialog/VoiceCommand.tsx new file mode 100644 index 000000000..82b9da6cd --- /dev/null +++ b/frontend/src/components/Dialog/VoiceCommand.tsx @@ -0,0 +1,56 @@ +import React, { useState } from "react"; +import { Mic } from "lucide-react"; + +interface Props { + onCommand: (cmd: string) => void; +} + +export function VoiceCommand({ onCommand }: Props) { + const [listening, setListening] = useState(false); + + const startListening = () => { + const SpeechRecognition = + (window as any).SpeechRecognition || + (window as any).webkitSpeechRecognition; + + if (!SpeechRecognition) { + alert("Speech recognition not supported in this browser."); + return; + } + + const recognition = new SpeechRecognition(); + recognition.continuous = false; + recognition.interimResults = false; + recognition.lang = "en-US"; + + recognition.start(); + setListening(true); + + recognition.onresult = (event: any) => { + const spokenText = event.results[0][0].transcript.toLowerCase().trim(); + setListening(false); + + console.log("Recognized:", spokenText); + onCommand(spokenText); // send recognized speech to navbar + }; + + recognition.onerror = () => { + setListening(false); + alert("Try again..."); + }; + }; + + return ( + + ); +} diff --git a/frontend/src/components/Navigation/Navbar/Navbar.tsx b/frontend/src/components/Navigation/Navbar/Navbar.tsx index c565b7f6d..bc97d539c 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -1,96 +1,221 @@ -import { Input } from '@/components/ui/input'; -import { ThemeSelector } from '@/components/ThemeToggle'; -import { Search } from 'lucide-react'; -import { useDispatch, useSelector } from 'react-redux'; -import { selectAvatar, selectName } from '@/features/onboardingSelectors'; -import { clearSearch } from '@/features/searchSlice'; -import { convertFileSrc } from '@tauri-apps/api/core'; -import { FaceSearchDialog } from '@/components/Dialog/FaceSearchDialog'; +import React, { useState, useEffect, useRef } from "react"; +import { Input } from "@/components/ui/input"; +import { ThemeSelector } from "@/components/ThemeToggle"; +import { Search, Mic } from "lucide-react"; +import { useDispatch, useSelector } from "react-redux"; +import { selectAvatar, selectName } from "@/features/onboardingSelectors"; +import { clearSearch } from "@/features/searchSlice"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { FaceSearchDialog } from "@/components/Dialog/FaceSearchDialog"; +import { VoiceCommand } from "@/components/Dialog/VoiceCommand"; +/* ------------------------------------------------------- + ERROR DIALOG +------------------------------------------------------- */ +const ErrorDialog = ({ message, onClose }: { message: string; onClose: () => void }) => ( +
+
+ +

Error

+

{message}

+
+
+); +/* ------------------------------------------------------- + NAVBAR +------------------------------------------------------- */ export function Navbar() { const userName = useSelector(selectName); const userAvatar = useSelector(selectAvatar); - const searchState = useSelector((state: any) => state.search); + const dispatch = useDispatch(); + const isSearchActive = searchState.active; const queryImage = searchState.queryImage; - const dispatch = useDispatch(); + const recognitionRef = useRef(null); + + const routeMap: Record = { + home: "/", + albums: "/albums", + videos: "/videos", + settings: "/settings", + "ai-tagging": "/ai-tagging", + memories: "/memories", + favourites: "/favourites", + }; + + const suggestionKeys = Object.keys(routeMap); + + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(-1); + const [voiceOpen, setVoiceOpen] = useState(false); + const [voiceText, setVoiceText] = useState("Listening..."); + const [error, setError] = useState(null); + + const filtered = + query.length > 0 + ? suggestionKeys.filter((s) => + s.toLowerCase().includes(query.toLowerCase().replace(/\s+/g, "-")) + ) + : []; + + const goToPage = (label: string) => { + dispatch(clearSearch()); + const key = label.trim().toLowerCase().replace(/\s+/g, "-"); + + if (routeMap[key]) { + window.location.href = routeMap[key]; + } else { + setError(`The page "${label}" does not exist.`); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!filtered.length) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => (prev + 1) % filtered.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => (prev <= 0 ? filtered.length - 1 : prev - 1)); + } else if (e.key === "Enter" && activeIndex >= 0) { + goToPage(filtered[activeIndex]); + setActiveIndex(-1); + } + }; + + useEffect(() => setActiveIndex(-1), [query]); + + const startListening = () => { + setVoiceOpen(true); + setQuery(""); // HIDE suggestions + setActiveIndex(-1); // RESET dropdown selection + + const SR = + (window as any).webkitSpeechRecognition || + (window as any).SpeechRecognition; + + if (!SR) { + setError("Speech recognition is not supported in your browser."); + setVoiceOpen(false); + return; + } + + const recog = new SR(); + recognitionRef.current = recog; + recog.lang = "en-US"; + recog.interimResults = false; + + recog.onresult = (e: any) => { + const spoken = e.results[0][0].transcript.toLowerCase(); + setVoiceText(spoken); + + if ( + spoken.includes("favorite") || + spoken.includes("favourite") || + spoken.includes("favorites") || + spoken.includes("favourites") + ) { + goToPage("favourites"); + setTimeout(() => setVoiceOpen(false), 800); + return; + } + + const found = suggestionKeys.find((k) => + spoken.replace(/\s+/g, "-").includes(k) + ); + + found ? goToPage(found) : setError(`No matching page found for "${spoken}".`); + setTimeout(() => setVoiceOpen(false), 1000); + }; + + recog.onerror = () => { + setVoiceText("Try again"); + setTimeout(() => setVoiceOpen(false), 800); + }; + + recog.start(); + }; + return ( -
- {/* Logo */} - +
+ + + PictoPy + + +
+
+ - {/* Search Bar */} -
-
- {/* Query Image */} - {queryImage && ( -
- Query - {isSearchActive && ( - - )} -
- )} - - {/* Input */} setQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 border-0 bg-transparent focus-visible:ring-0" /> - {/* FaceSearch Dialog */} -
+ + {/* Suggestions appear ONLY if voice dialog is closed */} + {!voiceOpen && filtered.length > 0 && ( +
+ {filtered.map((key, idx) => ( +
goToPage(key)} + className={`px-4 py-2 cursor-pointer capitalize ${ + idx === activeIndex + ? "bg-purple-200 dark:bg-purple-700" + : "hover:bg-neutral-200 dark:hover:bg-neutral-700" + }`} + > + {key} +
+ ))} +
+ )}
- {/* Right Side */} -
+
-
- - Welcome {userName} - - - User avatar - -
+ + Welcome {userName} + + + +
+ + {error && setError(null)} />} + + {voiceOpen && ( +
+
+ + +

{voiceText}

+
+
+ )}
); } + +export default Navbar;