diff --git a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx index 9dc755b..c0d0d2d 100644 --- a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx +++ b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx @@ -8,6 +8,7 @@ import Lightbox, { SlideImage, SlideTypes } from "yet-another-react-lightbox"; import Captions from "yet-another-react-lightbox/plugins/captions"; import { CalendarArrowDown, CalendarArrowUp, Hourglass } from "lucide-react"; import Video from "yet-another-react-lightbox/plugins/video"; + export default function PotentialAlbumsAssets() { const { startDate, selectedIds, assets, updateContext } = usePotentialAlbumContext(); diff --git a/src/components/assets/missing-location/MissingLocationAssets.tsx b/src/components/assets/missing-location/MissingLocationAssets.tsx index a151182..7acda77 100644 --- a/src/components/assets/missing-location/MissingLocationAssets.tsx +++ b/src/components/assets/missing-location/MissingLocationAssets.tsx @@ -6,14 +6,24 @@ import React, { useEffect, useMemo, useState } from "react"; import { Gallery } from "react-grid-gallery"; import Lightbox from "yet-another-react-lightbox"; import Captions from "yet-another-react-lightbox/plugins/captions"; -import { CalendarArrowDown, CalendarArrowUp, Hourglass } from "lucide-react"; +import { + ArrowUpRight, + CalendarArrowDown, + CalendarArrowUp, + Hourglass, + Link, +} from "lucide-react"; import { useMissingLocationContext } from "@/contexts/MissingLocationContext"; import { listMissingLocationAssets } from "@/handlers/api/asset.handler"; import { formatDate, parseDate } from "@/helpers/date.helper"; import { addDays } from "date-fns"; +import { useConfig } from "@/contexts/ConfigContext"; export default function MissingLocationAssets() { - const { startDate, selectedIds, assets, updateContext } = useMissingLocationContext(); + const { exImmichUrl } = useConfig(); + + const { startDate, selectedIds, assets, updateContext } = + useMissingLocationContext(); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -25,7 +35,7 @@ export default function MissingLocationAssets() { setLoading(true); updateContext({ assets: [], - }) + }); return listMissingLocationAssets({ startDate }) .then((assets) => updateContext({ assets })) .catch(setErrorMessage) @@ -40,6 +50,16 @@ export default function MissingLocationAssets() { width: p.exifImageWidth as number, height: p.exifImageHeight as number, isSelected: selectedIds.includes(p.id), + tags: [ + { + title: "Immich Link", + value: ( + + Open in Immich + + ), + }, + ], })); }, [assets, selectedIds]); @@ -56,36 +76,40 @@ export default function MissingLocationAssets() { const handleClick = (idx: number) => setIndex(idx); const handleSelect = (_idx: number, asset: IAsset) => { - const isPresent =selectedIds.includes(asset.id) - if(isPresent){ - updateContext({selectedIds:selectedIds.filter((id)=>id!==asset.id)}) + const isPresent = selectedIds.includes(asset.id); + if (isPresent) { + updateContext({ + selectedIds: selectedIds.filter((id) => id !== asset.id), + }); + } else { + updateContext({ selectedIds: [...selectedIds, asset.id] }); } - else{ - updateContext({selectedIds:[...selectedIds,asset.id]}) - } - }; useEffect(() => { if (startDate) fetchAssets(); }, [startDate]); - if (loading) return ( -
- -

Loading...

-
- ) + if (loading) + return ( +
+ +

Loading...

+
+ ); + + if (!startDate) + return ( +
+ +

Please select a date

+

+ When you select a date from the left, you will see all the orphan + assets captured on that date +

+
+ ); - if (!startDate) return ( -
- -

Please select a date

-

- When you select a date from the left, you will see all the orphan assets captured on that date -

-
- ) return ( <> diff --git a/src/components/assets/missing-location/TagMissingLocationDialog.tsx b/src/components/assets/missing-location/TagMissingLocationDialog.tsx new file mode 100644 index 0000000..27aa5f4 --- /dev/null +++ b/src/components/assets/missing-location/TagMissingLocationDialog.tsx @@ -0,0 +1,167 @@ +import ErrorBlock from "@/components/shared/ErrorBlock"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Loader from "@/components/ui/loader"; +import { useToast } from "@/components/ui/use-toast"; +import { searchPlaces } from "@/handlers/api/common.handler"; +import { cn } from "@/lib/utils"; +import { IPlace } from "@/types/common"; +import clsx from "clsx"; +import { Check } from "lucide-react"; +import React, { useRef, useState } from "react"; + +interface ITagMissingLocationDialogProps { + onSubmit: (place: IPlace) => Promise; +} +export default function TagMissingLocationDialog({ + onSubmit, +}: ITagMissingLocationDialogProps) { + const [searchedPlaces, setSearchedPlaces] = useState(null); + const searchTimer = useRef(); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [selectedPlace, setSelectedPlace] = useState(null); + const [open, setOpen] = useState(false); + const { toast } = useToast(); + + const handleSearch = (value: string) => { + if (searchTimer.current) { + clearTimeout(searchTimer.current); + } + + searchTimer.current = setTimeout(() => { + if (!value || !value.trim().length) { + setSearchedPlaces(null); + return; + } + setSelectedPlace(null); + setSearchedPlaces([]); + setLoading(true); + if (!value) { + setSearchedPlaces([]); + setLoading(false); + return; + } + + return searchPlaces(value) + .then(setSearchedPlaces) + .finally(() => { + setLoading(false); + }); + }, 500); + }; + + const handleSelect = (place: IPlace) => { + if (selectedPlace && selectedPlace.name === place.name) { + setSelectedPlace(null); + } else { + setSelectedPlace(place); + } + }; + + const handleSubmit = () => { + if (!selectedPlace) { + return; + } + + setSubmitting(true); + onSubmit(selectedPlace) + .then(() => { + toast({ + title: "Location updated", + description: "Location updated successfully", + }); + }) + .then(() => { + setOpen(false); + setSelectedPlace(null); + }) + .catch(() => { + toast({ + title: "Error", + description: "Error updating location", + }); + }) + .finally(() => { + setSubmitting(false); + }); + }; + + return ( + + + + + + + Tag Missing Location + + Tagging a location will add the location to the selected assets. + + +
+
+ + { + handleSearch(e.target.value); + }} + /> +
+
+ {loading && } + {!loading && searchedPlaces && searchedPlaces.length === 0 && ( + + )} + {!loading && searchedPlaces && searchedPlaces.length > 0 && ( +
+ {searchedPlaces.map((place) => ( +
handleSelect(place)} + className={cn( + "hover:bg-zinc-900 flex justify-between items-center px-2 py-1 rounded-lg cursor-pointer", + { + "bg-zinc-900": + selectedPlace && selectedPlace.name === place.name, + } + )} + > +
+

{place.name}

+ + {place.latitude}, {place.longitude} + +
+ {selectedPlace && selectedPlace.name === place.name && ( + + )} +
+ ))} +
+ )} +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/people/PersonMergeDropdown.tsx b/src/components/people/PersonMergeDropdown.tsx index 4ae42ba..92db3e9 100644 --- a/src/components/people/PersonMergeDropdown.tsx +++ b/src/components/people/PersonMergeDropdown.tsx @@ -115,17 +115,7 @@ export function PersonMergeDropdown({ if (primaryPerson.name.length === 0 && value.name.length > 0) { setPrimaryPerson(value); } - // setOpen(false); - // return mergePerson(person.id, value.id) - // .then(() => { - // onRemove?.(person); - // }) - // .then(() => { - // toast({ - // title: "Success", - // description: "Person merged successfully", - // }); - // }); + }; const handleMerge = () => { diff --git a/src/components/shared/Header.tsx b/src/components/shared/Header.tsx index 71cea36..c876f0e 100644 --- a/src/components/shared/Header.tsx +++ b/src/components/shared/Header.tsx @@ -20,12 +20,16 @@ import { } from "@/components/ui/card"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import Head from "next/head"; +import { sidebarNavs } from "@/config/constants/sidebarNavs"; +import { cn } from "@/lib/utils"; +import { useRouter } from "next/router"; interface IProps { leftComponent?: React.ReactNode | string; rightComponent?: React.ReactNode | string; } export default function Header({ leftComponent, rightComponent }: IProps) { + const { pathname } = useRouter(); const renderLeftComponent = () => { if (typeof leftComponent === "string") { return

{leftComponent}

; @@ -57,68 +61,24 @@ export default function Header({ leftComponent, rightComponent }: IProps) { -
- - - Upgrade to Pro - - Unlock all features and get unlimited access to our support - team. - - - - - - -
+
diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index 30e727f..977f1b7 100644 --- a/src/config/constants/sidebarNavs.tsx +++ b/src/config/constants/sidebarNavs.tsx @@ -1,4 +1,4 @@ -import { GalleryVerticalEnd, Home, Image, User } from "lucide-react"; +import { GalleryVerticalEnd, Home, Image, LocateOff, User } from "lucide-react"; export const sidebarNavs = [ { @@ -16,4 +16,9 @@ export const sidebarNavs = [ link: "/albums/potential-albums", icon: , }, + { + title: "Missing Locations", + link: "/assets/missing-locations", + icon: , + }, ]; diff --git a/src/config/routes.ts b/src/config/routes.ts index abf6e37..2d59e96 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -23,7 +23,12 @@ export const ADD_ASSETS_ALBUMS_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/al // Assets export const LIST_MISSING_LOCATION_DATES_PATH = BASE_API_ENDPOINT + "/assets/missing-location-dates"; export const LIST_MISSING_LOCATION_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-assets"; +export const UPDATE_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/assets"; export const ASSET_THUMBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id; export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id + "?size=preview"; -export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id; \ No newline at end of file +export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id; + +// Location + +export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places"; \ No newline at end of file diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index 693d945..040d6e7 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -3,6 +3,7 @@ import { LIST_ALBUMS_PATH, LIST_MISSING_LOCATION_ASSETS_PATH, LIST_MISSING_LOCATION_DATES_PATH, + UPDATE_ASSETS_PATH, } from "@/config/routes"; import { cleanUpAsset } from "@/helpers/asset.helper"; import API from "@/lib/api"; @@ -30,3 +31,15 @@ export const listMissingLocationAssets = async ( assets.map(cleanUpAsset) ); }; + + +export interface IUpdateAssetsParams { + ids: string[]; + latitude?: number; + longitude?: number; +} + +export const updateAssets = async (params: IUpdateAssetsParams) => { + return API.put(UPDATE_ASSETS_PATH, params); +} + \ No newline at end of file diff --git a/src/handlers/api/common.handler.ts b/src/handlers/api/common.handler.ts new file mode 100644 index 0000000..c2ba640 --- /dev/null +++ b/src/handlers/api/common.handler.ts @@ -0,0 +1,8 @@ +import { SEARCH_PLACES_PATH } from "@/config/routes"; +import API from "@/lib/api"; +import { IPlace } from "@/types/common"; + +export const searchPlaces = async (name: string): Promise => { + return API.get(SEARCH_PLACES_PATH, { name }); +} + diff --git a/src/pages/api/albums/potential-albums-assets.ts b/src/pages/api/albums/potential-albums-assets.ts index e5c753e..4ab957f 100644 --- a/src/pages/api/albums/potential-albums-assets.ts +++ b/src/pages/api/albums/potential-albums-assets.ts @@ -38,6 +38,7 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) => aaa."albumsId" IS NULL AND a."ownerId" = '${ownerId}' AND a."localDateTime"::date = '${date}' + AND a."previewPath" IS NOT NULL `); export default async function handler( diff --git a/src/pages/api/assets/missing-location-assets.ts b/src/pages/api/assets/missing-location-assets.ts index 79981b1..1594cf4 100644 --- a/src/pages/api/assets/missing-location-assets.ts +++ b/src/pages/api/assets/missing-location-assets.ts @@ -55,6 +55,7 @@ export default async function handler( lte(exif.dateTimeOriginal, endDateDate), eq(assets.type, "VIDEO"), eq(assets.ownerId, currentUser.id), + isNotNull(assets.previewPath), )); return res.status(200).json(rows); diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx index 86129b2..fcda8ef 100644 --- a/src/pages/assets/missing-locations.tsx +++ b/src/pages/assets/missing-locations.tsx @@ -3,6 +3,7 @@ import PotentialAlbumsAssets from "@/components/albums/potential-albums/Potentia import PotentialAlbumsDates from "@/components/albums/potential-albums/PotentialAlbumsDates"; import MissingLocationAssets from "@/components/assets/missing-location/MissingLocationAssets"; import MissingLocationDates from "@/components/assets/missing-location/MissingLocationDates"; +import TagMissingLocationDialog from "@/components/assets/missing-location/TagMissingLocationDialog"; import PageLayout from "@/components/layouts/PageLayout"; import Header from "@/components/shared/Header"; import { Badge } from "@/components/ui/badge"; @@ -11,9 +12,9 @@ import { useToast } from "@/components/ui/use-toast"; import MissingLocationContext, { IMissingLocationConfig, } from "@/contexts/MissingLocationContext"; +import { updateAssets } from "@/handlers/api/asset.handler"; -import { addAssetToAlbum } from "@/handlers/api/album.handler"; -import { IAlbum } from "@/types/album"; +import { IPlace } from "@/types/common"; import React from "react"; export default function MissingLocations() { @@ -25,31 +26,17 @@ export default function MissingLocations() { assets: [], }); - const handleSelect = (album: IAlbum) => { - return addAssetToAlbum(album.id, config.selectedIds) - .then(() => { - setConfig({ ...config, selectedIds: [] }); - setConfig({ - ...config, - assets: config.assets.filter( - (asset) => !config.selectedIds.includes(asset.id) - ), - selectedIds: [], - }); - }) - .then(() => { - toast({ - title: `Assets added to ${album.albumName}`, - description: `${config.selectedIds.length} assets added to album`, - }); - }) - .catch(() => { - toast({ - title: "Error", - description: "Failed to add assets album", - variant: "destructive", - }); + const handleSubmit = (place: IPlace) => { + return updateAssets({ + ids: config.selectedIds, + latitude: place.latitude, + longitude: place.longitude, + }).then(() => { + setConfig({ + ...config, + selectedIds: [], }); + }) }; return ( @@ -86,6 +73,7 @@ export default function MissingLocations() { Select all )} + } /> diff --git a/src/types/common.d.ts b/src/types/common.d.ts index 306e4b5..67b3cf4 100644 --- a/src/types/common.d.ts +++ b/src/types/common.d.ts @@ -2,4 +2,10 @@ export interface IListData { hasNextPage: boolean, total: number, hidden: number, +} + +export interface IPlace { + name: string; + latitude: number; + longitude: number; } \ No newline at end of file