diff --git a/src/components/shared/AlbumDropdown.tsx b/src/components/shared/AlbumDropdown.tsx new file mode 100644 index 0000000..8ff4f3f --- /dev/null +++ b/src/components/shared/AlbumDropdown.tsx @@ -0,0 +1,63 @@ +import { listAlbums } from '@/handlers/api/album.handler'; +import { IAlbum } from '@/types/album'; +import React, { useEffect, useState } from 'react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { X } from 'lucide-react'; + +interface IAlbumDropdownProps { + albumIds?: string[]; + onChange: (albumIds: string[]) => void; +} +export default function AlbumDropdown({ albumIds, onChange }: IAlbumDropdownProps) { + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedAlbumIds, setSelectedAlbumIds] = useState(albumIds); + + const fetchAlbums = async () => { + setLoading(true); + return listAlbums({ + sortBy: "createdAt", + sortOrder: "desc", + }).then((albums) => setAlbums(albums)).catch((error) => setError(error)).finally(() => setLoading(false)) ; + } + + useEffect(() => { + fetchAlbums(); + }, []); + + + useEffect(() => { + if (albumIds) { + setSelectedAlbumIds(albumIds); + } else { + setSelectedAlbumIds([]); + } + }, [albumIds]); + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/components/shared/PeopleDropdown.tsx b/src/components/shared/PeopleDropdown.tsx new file mode 100644 index 0000000..5af0e30 --- /dev/null +++ b/src/components/shared/PeopleDropdown.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react' +import { IPerson } from '@/types/person'; +import { IPersonListFilters, listPeople } from '@/handlers/api/people.handler'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +interface IPeopleDropdownProps { + peopleIds?: string[]; + onChange: (peopleIds: string[]) => void; +} +export default function PeopleDropdown({ peopleIds, onChange }: IPeopleDropdownProps) { + const [people, setPeople] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedPeopleIds, setSelectedPeopleIds] = useState(peopleIds); + const [filters, setFilters] = useState({ + page: 1, + perPage: 10000, + sort: "createdAt", + sortOrder: "desc", + visibility: "visible", + type: "named", + }); + + const fetchPeople = async () => { + setLoading(true); + return listPeople(filters).then((people) => setPeople(people.people)).catch((error) => setError(error)).finally(() => setLoading(false)); + } + + useEffect(() => { + fetchPeople(); + }, [filters]); + + useEffect(() => { + if (peopleIds) { + setSelectedPeopleIds(peopleIds); + } else { + setSelectedPeopleIds([]); + } + }, [peopleIds]); + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index b7fd690..4277f28 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -26,21 +26,27 @@ export interface IComboBoxProps { onSelect?: (value: string) => void onOpenChange?: (open: boolean) => void closeOnSelect?: boolean + label?: string + onTextChange?: (text: string) => void } export function Combobox( - { options, onSelect, onOpenChange, closeOnSelect }: IComboBoxProps + { options, onSelect, onOpenChange, closeOnSelect, label, onTextChange }: IComboBoxProps ) { const [open, setOpen] = React.useState(false) const [value, setValue] = React.useState("") + return ( { setOpen(openState) onOpenChange?.(openState) }}> - +
+ + {label} +
diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index 4ea921a..13ba4a2 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -61,8 +61,12 @@ export const findAssets = async (query: string) => { return API.post(FIND_ASSETS, { query }); } -export const getAssetGeoHeatmap = async () => { - return API.get(ASSET_GEO_HEATMAP_PATH); +export interface IHeatMapParams { + albumIds?: string; + peopleIds?: string; +} +export const getAssetGeoHeatmap = async (filters: IHeatMapParams) => { + return API.get(ASSET_GEO_HEATMAP_PATH, filters); } export const deleteAssets = async (ids: string[]) => { diff --git a/src/pages/api/assets/geo-heatmap.ts b/src/pages/api/assets/geo-heatmap.ts index ba5870a..e3d6eb0 100644 --- a/src/pages/api/assets/geo-heatmap.ts +++ b/src/pages/api/assets/geo-heatmap.ts @@ -3,26 +3,34 @@ import { NextApiResponse } from "next"; import { db } from "@/config/db"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { NextApiRequest } from "next"; -import { assets, exif } from "@/schema"; -import { and, eq, isNotNull } from "drizzle-orm"; +import { assetFaces, assets, exif, person } from "@/schema"; +import { and, eq, inArray, isNotNull } from "drizzle-orm"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const currentUser = await getCurrentUser(req); + const { albumIds, peopleIds } = req.query as { albumIds: string, peopleIds: string }; if (!currentUser) { return res.status(401).json({ message: 'Unauthorized' }); } + const dbAssets = await db.select({ assetId: assets.id, latitude: exif.latitude, longitude: exif.longitude, }).from(assets) .innerJoin(exif, eq(assets.id, exif.assetId)) + .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) + .innerJoin(assetFaces, eq(albumsAssetsAssets.assetsId, assetFaces.assetId)) + .innerJoin(person, eq(assetFaces.personId, person.id)) .where( and( eq(assets.ownerId, currentUser.id), isNotNull(exif.latitude), - isNotNull(exif.longitude) + isNotNull(exif.longitude), + albumIds?.length > 0 ? inArray(albumsAssetsAssets.albumsId, [albumIds]) : undefined, + peopleIds?.length > 0 ? inArray(person.id, [peopleIds]) : undefined ) ); const heatmapData = dbAssets.map((asset) => [ diff --git a/src/pages/assets/geo-heatmap.tsx b/src/pages/assets/geo-heatmap.tsx index fc1339c..94b376b 100644 --- a/src/pages/assets/geo-heatmap.tsx +++ b/src/pages/assets/geo-heatmap.tsx @@ -4,13 +4,19 @@ import React, { useEffect, useMemo, useState } from 'react' import { useMap, useMapsLibrary } from '@vis.gl/react-google-maps'; import { APIProvider, Map } from '@vis.gl/react-google-maps'; import PageLayout from '@/components/layouts/PageLayout'; -import { getAssetGeoHeatmap } from '@/handlers/api/asset.handler'; +import { getAssetGeoHeatmap, IHeatMapParams } from '@/handlers/api/asset.handler'; import { useTheme } from 'next-themes'; -import { MapPinX } from 'lucide-react'; +import { MapPinX, X } from 'lucide-react'; +import AlbumDropdown from '@/components/shared/AlbumDropdown'; +import PeopleDropdown from '@/components/shared/PeopleDropdown'; +import { Button } from '@/components/ui/button'; +interface IHeatMapProps { + filters: IHeatMapParams; +} + +const HeatMap = ({ filters }: IHeatMapProps) => { -const HeatMap = () => { - const map = useMap(); const visualization = useMapsLibrary('visualization'); @@ -27,14 +33,15 @@ const HeatMap = () => { useEffect(() => { if (!heatmap) return; - getAssetGeoHeatmap().then((data) => { + getAssetGeoHeatmap(filters).then((data) => { + heatmap.setData([]); heatmap.setData(data.map(([lng, lat]: [number, number]) => ({ location: new google.maps.LatLng(lat, lng), weight: 10 }))); }); - }, [heatmap]); + }, [heatmap, filters]); useEffect(() => { if (!heatmap) return; @@ -53,6 +60,10 @@ export default function GeoHeatmap() { const { googleMapsApiKey } = useConfig(); const { theme } = useTheme(); const [mapId, setMapId] = useState('7a9e2ebecd32a903'); + const [filters, setFilters] = useState<{ + albumIds?: string + peopleIds?: string + }>({ }); useEffect(() => { if (theme === 'light') { @@ -65,21 +76,42 @@ export default function GeoHeatmap() { return ( -
+
+ { + setFilters({ + ...filters, + albumIds: albumIds?.[0] + }) + }} /> + { + setFilters({ + ...filters, + peopleIds: peopleIds?.[0] + }) + }} /> + + + } />
{googleMapsApiKey ? ( - + - - + + ) : (