diff --git a/backend/.gitignore b/backend/.gitignore index 2cb1d8440..7714e655a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -5,6 +5,13 @@ __pycache__/ *.py[cod] *$py.class + + +.sync-env/ +venv/ +.env/ + + # Distribution / packaging .Python build/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1e1ddd5f..ab218ecaf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14476,20 +14476,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 7252274a6..293167259 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -7,6 +7,8 @@ import faceClustersReducer from '@/features/faceClustersSlice'; import infoDialogReducer from '@/features/infoDialogSlice'; import folderReducer from '@/features/folderSlice'; +import viewModeReducer from "@/features/viewModeSlice"; + export const store = configureStore({ reducer: { loader: loaderReducer, @@ -16,6 +18,8 @@ export const store = configureStore({ infoDialog: infoDialogReducer, folders: folderReducer, search: searchReducer, + + viewMode: viewModeReducer }, }); // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/frontend/src/components/GalleryView.tsx b/frontend/src/components/GalleryView.tsx new file mode 100644 index 000000000..d3290a642 --- /dev/null +++ b/frontend/src/components/GalleryView.tsx @@ -0,0 +1,312 @@ +// src/components/GalleryView.tsx + +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { RootState } from '@/app/store'; +import { ChronologicalGallery, MonthMarker } from '@/components/Media/ChronologicalGallery'; +import type { Image as MediaImage } from '@/types/Media'; +import { setCurrentViewIndex } from '@/features/imageSlice'; +import { selectIsImageViewOpen } from '@/features/imageSelectors'; +import { MediaView } from '@/components/Media/MediaView'; +import { Button } from '@/components/ui/button'; +import { Heart, Share2 } from 'lucide-react'; +import { useToggleFav } from '@/hooks/useToggleFav'; + +/** + * This component is tolerant of different Image shapes: + * - offline/tauri style: thumbnailPath, path, name, metadata + * - online style: url, filename, date + * + * It will try to prefer thumbnailPath -> url -> path. + */ + +type AnyImage = Partial & { + id: string | number; + thumbnailPath?: string; + path?: string; + url?: string; + filename?: string; + name?: string; + metadata?: Record; + date?: string; +}; + +interface Props { + images: AnyImage[]; + title: string; + scrollableRef: React.RefObject; + onMonthOffsetsChange: (v: MonthMarker[] | any) => void; +} + +const toTauriUrl = (p?: string) => { + if (!p) return undefined; + try { + const fixed = p.replace(/\\/g, '/'); + return convertFileSrc(fixed); + } catch { + // convertFileSrc may throw if not running in Tauri; fallback to raw path + return p; + } +}; + +const getImageSrc = (img: AnyImage) => { + // Order of preference: + // 1. url (if exists) + if (img.url && typeof img.url === 'string') return img.url; + // 2. thumbnailPath (tauri local) + if (img.thumbnailPath && typeof img.thumbnailPath === 'string') { + const v = toTauriUrl(img.thumbnailPath); + if (v) return v; + } + // 3. path (original file path) + if (img.path && typeof img.path === 'string') { + const v = toTauriUrl(img.path); + if (v) return v; + } + // 4. fallback placeholder + return '/placeholder.svg'; +}; + +export const GalleryView: React.FC = ({ + images, + title, + scrollableRef, + onMonthOffsetsChange, +}) => { + const dispatch = useDispatch(); + const mode = useSelector((s: RootState) => s.viewMode.mode); + const isImageViewOpen = useSelector(selectIsImageViewOpen); + const { toggleFavourite } = useToggleFav(); + + const handleImageClick = useCallback( + (index: number) => { + dispatch(setCurrentViewIndex(index)); + }, + [dispatch], + ); + + const handleToggleFavourite = useCallback( + (img: AnyImage) => { + if (img?.id) { + toggleFavourite(String(img.id)); + } + }, + [toggleFavourite], + ); + + const handleShareImage = useCallback(async (img: AnyImage) => { + const shareUrl = img.url || img.path; + const shareTitle = img.name || img.filename || 'Image'; + try { + if (typeof navigator !== 'undefined' && navigator.share && shareUrl) { + await navigator.share({ title: shareTitle, url: shareUrl }); + } else if ( + typeof navigator !== 'undefined' && + navigator.clipboard && + shareUrl + ) { + await navigator.clipboard.writeText(shareUrl); + console.info('Image link copied to clipboard.'); + } else { + console.info('Share is not supported in this environment.'); + } + } catch (err) { + console.error('Failed to share image:', err); + } + }, []); + + const renderActionButtons = (img: AnyImage) => ( +
+ + + +
+ ); + + // Use chronological gallery (if present) for that mode + if (mode === 'chronological') { + // ChronologicalGallery exists in your repo; pass-through props + return ( + + ); + } + + // GRID + if (mode === 'grid') { + return ( + <> +
+ {images.map((img, index) => ( +
handleImageClick(index)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleImageClick(index); + } + }} + > +
+ {(img.name { + (e.currentTarget as HTMLImageElement).src = '/placeholder.svg'; + }} + /> +
+ {renderActionButtons(img)} +
+
+ ))} +
+ {isImageViewOpen && } + + ); + } + + // LIST + if (mode === 'list') { + return ( + <> +
+ {images.map((img, index) => ( +
handleImageClick(index)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleImageClick(index); + } + }} + > +
+ {(img.name { + (e.currentTarget as HTMLImageElement).src = '/placeholder.svg'; + }} + /> +
+ {renderActionButtons(img)} +
+ +
+
+ {img.name || img.filename || String(img.id)} +
+
+ {img.date || + img.metadata?.date || + img.metadata?.createdAt || + img.path || + '—'} +
+
+
+ ))} +
+ {isImageViewOpen && } + + ); + } + + // MASONRY +if (mode === 'masonry') { + return ( + <> +
+ {images.map((img, index) => ( +
handleImageClick(index)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleImageClick(index); + } + }} + > + {/* IMAGE should control the card height fully */} +
+ {(img.name { + (e.currentTarget as HTMLImageElement).src = '/placeholder.svg'; + }} + /> +
+ {renderActionButtons(img)} +
+ + {/* Info section — removed padding above image so card remains exact size */} +
+
{img.name || img.filename || ''}
+
+ {img.metadata?.date || img.date || ''} +
+
+
+ ))} +
+ {isImageViewOpen && } + + ); +} + + + return null; +}; + +export default GalleryView; diff --git a/frontend/src/components/ViewModeToggle.tsx b/frontend/src/components/ViewModeToggle.tsx new file mode 100644 index 000000000..73ba8192f --- /dev/null +++ b/frontend/src/components/ViewModeToggle.tsx @@ -0,0 +1,59 @@ + + +// src/components/ViewModeToggle.tsx +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '@/app/store'; +import { setViewMode, ViewMode } from '@/features/viewModeSlice'; + +import { + Calendar, + Grid, + List, + LayoutGrid, +} from 'lucide-react'; // ICONS + +const modes: { key: ViewMode; icon: React.ReactNode; label: string }[] = [ + { key: 'chronological', icon: , label: 'Chronological' }, + { key: 'grid', icon: , label: 'Grid' }, + { key: 'list', icon: , label: 'List' }, + { key: 'masonry', icon: , label: 'Masonry' }, +]; + +export const ViewModeToggle: React.FC = () => { + const dispatch = useDispatch(); + const active = useSelector((s: RootState) => s.viewMode.mode); + + return ( +
+ {modes.map((m) => { + const isActive = active === m.key; + + return ( + + ); + })} +
+ ); +}; + +export default ViewModeToggle; diff --git a/frontend/src/features/viewModeSlice.ts b/frontend/src/features/viewModeSlice.ts new file mode 100644 index 000000000..d3f850d6e --- /dev/null +++ b/frontend/src/features/viewModeSlice.ts @@ -0,0 +1,25 @@ +// src/features/viewModeSlice.ts +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export type ViewMode = 'chronological' | 'grid' | 'list' | 'masonry'; + +interface ViewModeState { + mode: ViewMode; +} + +const initialState: ViewModeState = { + mode: 'chronological', +}; + +const viewModeSlice = createSlice({ + name: 'viewMode', + initialState, + reducers: { + setViewMode: (state, action: PayloadAction) => { + state.mode = action.payload; + }, + }, +}); + +export const { setViewMode } = viewModeSlice.actions; +export default viewModeSlice.reducer; diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx index 83c9e5c83..eb6a28296 100644 --- a/frontend/src/pages/Home/Home.tsx +++ b/frontend/src/pages/Home/Home.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { - ChronologicalGallery, + // ChronologicalGallery, MonthMarker, } from '@/components/Media/ChronologicalGallery'; import TimelineScrollbar from '@/components/Timeline/TimelineScrollbar'; @@ -14,6 +14,9 @@ import { RootState } from '@/app/store'; import { EmptyGalleryState } from '@/components/EmptyStates/EmptyGalleryState'; import { useMutationFeedback } from '@/hooks/useMutationFeedback'; +import { ViewModeToggle } from '@/components/ViewModeToggle'; +import { GalleryView } from '@/components/GalleryView'; + export const Home = () => { const dispatch = useDispatch(); const images = useSelector(selectImages); @@ -28,6 +31,7 @@ export const Home = () => { enabled: !isSearchActive, }); + // Keep main branch improvement useMutationFeedback( { isPending: isLoading, isSuccess, isError, error }, { @@ -35,7 +39,7 @@ export const Home = () => { showSuccess: false, errorTitle: 'Error', errorMessage: 'Failed to load images. Please try again later.', - }, + } ); useEffect(() => { @@ -52,25 +56,24 @@ export const Home = () => { return (
- {/* Gallery Section */} + +
{images.length > 0 ? ( - ) : ( )}
- {/* Timeline Scrollbar */} {monthMarkers.length > 0 && ( {
); }; + +export default Home;