diff --git a/frontend/public/test-images/example1.jpg b/frontend/public/test-images/example1.jpg new file mode 100644 index 000000000..dbc7fd703 Binary files /dev/null and b/frontend/public/test-images/example1.jpg differ diff --git a/frontend/public/test-images/example2.jpg b/frontend/public/test-images/example2.jpg new file mode 100644 index 000000000..27042da39 Binary files /dev/null and b/frontend/public/test-images/example2.jpg differ diff --git a/frontend/public/test-images/example3.jpg b/frontend/public/test-images/example3.jpg new file mode 100644 index 000000000..64400c4bd Binary files /dev/null and b/frontend/public/test-images/example3.jpg differ diff --git a/frontend/public/test-images/example4.jpg b/frontend/public/test-images/example4.jpg new file mode 100644 index 000000000..a51d69df6 Binary files /dev/null and b/frontend/public/test-images/example4.jpg differ diff --git a/frontend/public/test-images/example5.jpg b/frontend/public/test-images/example5.jpg new file mode 100644 index 000000000..0f0df3898 Binary files /dev/null and b/frontend/public/test-images/example5.jpg differ diff --git a/frontend/public/test-images/example6.jpg b/frontend/public/test-images/example6.jpg new file mode 100644 index 000000000..3f6fb15aa Binary files /dev/null and b/frontend/public/test-images/example6.jpg differ diff --git a/frontend/public/test-images/example7.jpg b/frontend/public/test-images/example7.jpg new file mode 100644 index 000000000..636433d19 Binary files /dev/null and b/frontend/public/test-images/example7.jpg differ diff --git a/frontend/public/test-images/example8.jpg b/frontend/public/test-images/example8.jpg new file mode 100644 index 000000000..0ff46d3c5 Binary files /dev/null and b/frontend/public/test-images/example8.jpg differ diff --git a/frontend/src/components/Collage/CollageMaker.tsx b/frontend/src/components/Collage/CollageMaker.tsx new file mode 100644 index 000000000..927610e4a --- /dev/null +++ b/frontend/src/components/Collage/CollageMaker.tsx @@ -0,0 +1,192 @@ +import React, { useMemo, useRef, useState, useEffect } from "react"; +import CollagePreview from "./CollagePreview"; +import { LayoutType, getLayout } from "./layouts"; +import { Image as PictoImage } from "@/types/Media"; + +const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +export function CollageMaker({ + images, + initialLayout = "grid2x2", + maxFiles = 5, +}: { + images?: PictoImage[]; + initialLayout?: LayoutType; + maxFiles?: number; +}) { + const [uploadedImages, setUploadedImages] = useState([]); + const [layout, setLayout] = useState(initialLayout); + const [error, setError] = useState(null); + const [showDropdown, setShowDropdown] = useState(false); + + const previewRef = useRef(null); + + // Normalize images from props + const normalizedFromProps = useMemo(() => { + if (!images?.length) return []; + return images + .map((img: any) => + img?.thumbnailPath + ? `${API_BASE_URL}/uploads/${img.thumbnailPath.split(/[\\/]/).pop()}` + : "" + ) + .filter(Boolean); + }, [images]); + + const finalImages = uploadedImages.length > 0 ? uploadedImages : normalizedFromProps; + + // Handle file uploads using persistent Blob URLs + const handleFiles = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (!files.length) return; + + if (files.length > maxFiles) { + setError(`Please select up to ${maxFiles} images`); + return; + } + + const blobUrls = files.map((file) => URL.createObjectURL(file)); + setUploadedImages(blobUrls); + setError(null); + }; + + // Cleanup Blob URLs to avoid memory leaks + useEffect(() => { + return () => { + uploadedImages.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [uploadedImages]); + + // Download canvas logic + const downloadImage = async (format: "image/png" | "image/jpeg") => { + if (!previewRef.current) return; + + const rect = previewRef.current.getBoundingClientRect(); + const canvas = document.createElement("canvas"); + // Use standardized dimensions for consistent output + const outputWidth = 1200; // or make configurable + const outputHeight = 1200; + canvas.width = outputWidth; + canvas.height = outputHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const config = getLayout(layout); + + const imgs = await Promise.all( + finalImages.slice(0, config.maxImages).map( + (src) => + new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); + img.src = src; + }) + ) + ).catch((err) => { + setError(err.message || "Failed to load one or more images for download"); + throw err; + }); + + + config.placements.forEach((p, i) => { + if (!imgs[i]) return; + const x = (p.colStart - 1) * (canvas.width / config.cols); + const y = (p.rowStart - 1) * (canvas.height / config.rows); + const w = (p.colEnd - p.colStart) * (canvas.width / config.cols); + const h = (p.rowEnd - p.rowStart) * (canvas.height / config.rows); + ctx.drawImage(imgs[i], x, y, w, h); + }); + + const a = document.createElement("a"); + a.href = canvas.toDataURL(format); + a.download = format === "image/png" ? "collage.png" : "collage.jpg"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + return ( +
+

+ 📸 Instant Collage Generator +

+ +
+ + + + {finalImages.length} image(s) + + + + + {/* Clickable Download Dropdown */} + {finalImages.length > 0 && ( +
+ + + {showDropdown && ( +
+ + +
+ )} +
+ )} +
+ + {error &&

{error}

} + + {/* Preview stays intact */} +
+ +
+
+ ); +} + +export default CollageMaker; diff --git a/frontend/src/components/Collage/CollagePreview.tsx b/frontend/src/components/Collage/CollagePreview.tsx new file mode 100644 index 000000000..74c3bd0d3 --- /dev/null +++ b/frontend/src/components/Collage/CollagePreview.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { LayoutType, getLayout } from "./layouts"; + +interface CollagePreviewProps { + images: string[]; + layout: LayoutType; +} + +const CollagePreview: React.FC = ({ images, layout }) => { + const config = getLayout(layout); + const showImages = images.slice(0, Math.min(config.maxImages, config.placements.length)); + + return ( +
+ +
+ {showImages.map((img, index) => { + const p = config.placements[index]; + return ( +
+ +
+ ); + })} +
+
+ ); +}; + +export default CollagePreview; diff --git a/frontend/src/components/Collage/layouts.tsx b/frontend/src/components/Collage/layouts.tsx new file mode 100644 index 000000000..fa67091ad --- /dev/null +++ b/frontend/src/components/Collage/layouts.tsx @@ -0,0 +1,61 @@ +// layouts.ts +export type LayoutType = "sideBySide" | "grid2x2" | "onePlusThreeSplit"; + +export interface Placement { + colStart: number; + colEnd: number; + rowStart: number; + rowEnd: number; +} + +export interface LayoutConfig { + cols: number; + rows: number; + placements: Placement[]; + maxImages: number; +} + +export const getLayout = (layout: LayoutType): LayoutConfig => { + switch (layout) { + case "sideBySide": + return { + cols: 2, + rows: 1, + maxImages: 2, + placements: [ + { colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 }, + { colStart: 2, colEnd: 3, rowStart: 1, rowEnd: 2 }, + ], + }; + + case "grid2x2": + return { + cols: 2, + rows: 2, + maxImages: 4, + placements: [ + { colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 }, + { colStart: 2, colEnd: 3, rowStart: 1, rowEnd: 2 }, + { colStart: 1, colEnd: 2, rowStart: 2, rowEnd: 3 }, + { colStart: 2, colEnd: 3, rowStart: 2, rowEnd: 3 }, + ], + }; + + case "onePlusThreeSplit": + return { + cols: 3, + rows: 2, + maxImages: 4, + placements: [ + // big top + { colStart: 1, colEnd: 4, rowStart: 1, rowEnd: 2 }, + { colStart: 1, colEnd: 2, rowStart: 2, rowEnd: 3 }, + { colStart: 2, colEnd: 3, rowStart: 2, rowEnd: 3 }, + { colStart: 3, colEnd: 4, rowStart: 2, rowEnd: 3 }, + ], + }; + + default: + return { cols: 1, rows: 1, maxImages: 1, placements: [] }; + } +}; \ No newline at end of file diff --git a/frontend/src/components/Navigation/Navbar/Navbar.tsx b/frontend/src/components/Navigation/Navbar/Navbar.tsx index c565b7f6d..cb62bf2fa 100644 --- a/frontend/src/components/Navigation/Navbar/Navbar.tsx +++ b/frontend/src/components/Navigation/Navbar/Navbar.tsx @@ -1,52 +1,182 @@ -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 } 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"; + +// Error Dialog +const ErrorDialog = ({ message, onClose }: { message: string; onClose: () => void }) => ( +
+
+

Something went wrong

+

{message}

+ +
+
+); 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 routeMap: Record = { + home: "/", + albums: "/albums", + videos: "/videos", + settings: "/settings", + "ai-tagging": "/ai-tagging", + memories: "/memories", + }; + + const suggestionKeys = ["home", "albums", "videos", "settings", "ai-tagging", "memories"]; + + 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 + ? suggestionKeys.filter((s) => + s.toLowerCase().includes(query.toLowerCase().replace(/\s+/g, "-")) + ) + : []; + + const goToPage = (label: string) => { + const key = label.trim().toLowerCase().replace(/\s+/g, "-"); + + if (routeMap[key]) { + window.location.assign(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") { + if (activeIndex >= 0 && activeIndex < filtered.length) { + goToPage(filtered[activeIndex]); + setActiveIndex(-1); + } + } + }; + + useEffect(() => { + setActiveIndex(-1); + }, [query]); + + + const startListening = () => { + const SR = + (window as any).webkitSpeechRecognition || + (window as any).SpeechRecognition; + + if (!SR) { + setError("Speech recognition is not supported in your browser."); + return; + } + + const recog = new SR(); + recog.lang = "en-US"; + recog.interimResults = false; + + recog.onstart = () => { + setVoiceOpen(true); + setVoiceText("Listening..."); + }; + + recog.onresult = (e: any) => { + const spoken = e.results[0][0].transcript; + setVoiceText(spoken); + + const found = suggestionKeys.find((k) => + spoken.toLowerCase().replace(/\s+/g, "-").includes(k) + ); + + if (found) { + goToPage(found); + } else { + setError(`No matching page found for "${spoken}".`); + } + + setTimeout(() => setVoiceOpen(false), 1200); + }; + + recog.onerror = (event: any) => { + const msg = + event.error === "no-speech" + ? "No speech detected. Try again." + : "Couldn't understand. Try again."; + setVoiceText(msg); + setTimeout(() => setVoiceOpen(false), 1200); + }; + + recog.start(); + + return () => { + recog.stop(); + }; + }; + return ( -
- {/* Logo */} -
- - PictoPy Logo - PictoPy - +
+ {/* LEFT */} +
+ PictoPy Logo + PictoPy
- {/* Search Bar */} -
-
- {/* Query Image */} + {/* CENTER */} +
+
+ + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 border-0 bg-transparent" + /> + {queryImage && ( -
+
Query {isSearchActive && ( @@ -54,43 +184,86 @@ export function Navbar() {
)} - {/* Input */} - - - {/* FaceSearch Dialog */} - + {/* Voice Button */} + + {filtered.length > 0 && ( +
+ {filtered.map((key, idx) => ( +
goToPage(key)} + > + {key} +
+ ))} +
+ )}
- {/* Right Side */} -
+ {/* RIGHT */} +
-
- - Welcome {userName} - - - User avatar - -
+ + Welcome {userName} + + + User Picture +
+ + {/* Error Dialog */} + {error && setError(null)} />} + + {/* Voice Modal */} + {voiceOpen && ( +
+
+ + + +
+
+
+ +
+
+ +

+ {voiceText} +

+

+ Speak now… +

+
+
+ )}
); } + +export default Navbar; diff --git a/frontend/src/pages/AITagging/AITagging.tsx b/frontend/src/pages/AITagging/AITagging.tsx index 187bda3df..796bb0cb8 100644 --- a/frontend/src/pages/AITagging/AITagging.tsx +++ b/frontend/src/pages/AITagging/AITagging.tsx @@ -1,37 +1,40 @@ -import { useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { FaceCollections } from '@/components/FaceCollections'; -import { Image } from '@/types/Media'; -import { setImages } from '@/features/imageSlice'; -import { showLoader, hideLoader } from '@/features/loaderSlice'; -import { selectImages } from '@/features/imageSelectors'; -import { usePictoQuery } from '@/hooks/useQueryExtension'; -import { fetchAllImages } from '@/api/api-functions'; +import { useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { FaceCollections } from "@/components/FaceCollections"; +import { Image } from "@/types/Media"; +import { setImages } from "@/features/imageSlice"; +import { showLoader, hideLoader } from "@/features/loaderSlice"; +import { selectImages } from "@/features/imageSelectors"; +import { usePictoQuery } from "@/hooks/useQueryExtension"; +import { fetchAllImages } from "@/api/api-functions"; import { ChronologicalGallery, MonthMarker, -} from '@/components/Media/ChronologicalGallery'; -import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; -import { EmptyAITaggingState } from '@/components/EmptyStates/EmptyAITaggingState'; +} from "@/components/Media/ChronologicalGallery"; +import TimelineScrollbar from "@/components/Timeline/TimelineScrollbar"; +import { EmptyAITaggingState } from "@/components/EmptyStates/EmptyAITaggingState"; +import { CollageMaker } from "@/components/Collage/CollageMaker"; export const AITagging = () => { + const [openCollage, setOpenCollage] = useState(false); const dispatch = useDispatch(); const scrollableRef = useRef(null); const [monthMarkers, setMonthMarkers] = useState([]); const taggedImages = useSelector(selectImages); + const { data: imagesData, isLoading: imagesLoading, isSuccess: imagesSuccess, isError: imagesError, } = usePictoQuery({ - queryKey: ['images', { tagged: true }], + queryKey: ["images", { tagged: true }], queryFn: () => fetchAllImages(true), }); useEffect(() => { if (imagesLoading) { - dispatch(showLoader('Loading AI tagging data')); + dispatch(showLoader("Loading AI tagging data")); } else if (imagesError) { dispatch(hideLoader()); } else if (imagesSuccess) { @@ -47,9 +50,18 @@ export const AITagging = () => { ref={scrollableRef} className="hide-scrollbar flex-1 overflow-x-hidden overflow-y-auto" > -

AI Tagging

+ {/* Header and Button Container */} +
+

AI Tagging

+ +
- {/* Face Collections Section */} + {/* Face Collections */}
@@ -69,6 +81,7 @@ export const AITagging = () => { )}
+ {monthMarkers.length > 0 && ( { className="absolute top-0 right-0 h-full w-4" /> )} + + {/* Collage Modal */} + {openCollage && ( +
+
+ + + +
+
+ )}
); };