feat: tag missing location for assets

Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
Varun Raj committed 2024-08-29 08:57:02 +05:30
1 parent abe002e7bd
commit 4f6117ded2
13 files changed
+301 -126

No files matched your search

@@ -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();
@@ -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<string | null>(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: (
<a href={exImmichUrl + "/photos/" + p.id} target="_blank">
Open in Immich
</a>
),
},
],
}));
}, [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 (
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
<Hourglass />
<p className="text-lg">Loading...</p>
</div>
)
if (loading)
return (
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
<Hourglass />
<p className="text-lg">Loading...</p>
</div>
);
if (!startDate)
return (
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
<CalendarArrowUp />
<p className="text-lg">Please select a date</p>
<p className="text-sm">
When you select a date from the left, you will see all the orphan
assets captured on that date
</p>
</div>
);
if (!startDate) return (
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
<CalendarArrowUp />
<p className="text-lg">Please select a date</p>
<p className="text-sm">
When you select a date from the left, you will see all the orphan assets captured on that date
</p>
</div>
)
return (
<>
<Lightbox
@@ -101,7 +125,13 @@ export default function MissingLocationAssets() {
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
tagStyle={{
color: "white",
fontSize: "12px",
backgroundColor: "rgba(0, 0, 0)",
padding: "2px",
borderRadius: "5px",
}}
/>
</div>
</>
@@ -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<any>;
}
export default function TagMissingLocationDialog({
onSubmit,
}: ITagMissingLocationDialogProps) {
const [searchedPlaces, setSearchedPlaces] = useState<IPlace[] | null>(null);
const searchTimer = useRef<NodeJS.Timeout>();
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedPlace, setSelectedPlace] = useState<IPlace | null>(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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Tag Missing Location</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Tag Missing Location</DialogTitle>
<DialogDescription>
Tagging a location will add the location to the selected assets.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Search Location</Label>
<Input
placeholder="Search location"
onChange={(e) => {
handleSearch(e.target.value);
}}
/>
</div>
<div>
{loading && <Loader />}
{!loading && searchedPlaces && searchedPlaces.length === 0 && (
<ErrorBlock description="No results found" />
)}
{!loading && searchedPlaces && searchedPlaces.length > 0 && (
<div className="flex flex-col gap-2 max-h-[400px] overflow-y-auto">
{searchedPlaces.map((place) => (
<div
key={place.name}
onClick={() => 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,
}
)}
>
<div>
<p>{place.name}</p>
<span className="text-xs text-gray-600">
{place.latitude}, {place.longitude}
</span>
</div>
{selectedPlace && selectedPlace.name === place.name && (
<Check className="text-green-500" />
)}
</div>
))}
</div>
)}
</div>
<div className="self-end">
<Button
variant="outline"
onClick={handleSubmit}
disabled={!selectedPlace || submitting}
>
Add New Location
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+1 -11
View File
@@ -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 = () => {
+21 -61
View File
@@ -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 <p className="font-semibold">{leftComponent}</p>;
@@ -57,68 +61,24 @@ export default function Header({ leftComponent, rightComponent }: IProps) {
</SheetTrigger>
<SheetContent side="left" className="flex flex-col">
<nav className="grid gap-2 text-lg font-medium">
<Link
href="#"
className="flex items-center gap-2 text-lg font-semibold"
>
<Package2 className="h-6 w-6" />
<span className="sr-only">Acme Inc</span>
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Home className="h-5 w-5" />
Dashboard
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
>
<ShoppingCart className="h-5 w-5" />
Orders
<Badge className="ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-full">
6
</Badge>
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Package className="h-5 w-5" />
Products
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<Users className="h-5 w-5" />
Customers
</Link>
<Link
href="#"
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
>
<LineChart className="h-5 w-5" />
Analytics
</Link>
{sidebarNavs.map((nav) => (
<Link
key={nav.title}
href={nav.link}
className={
cn("mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 ", {
"bg-muted text-foreground": nav.link === pathname,
"text-muted-foreground hover:text-foreground": nav.link !== pathname,
})
}
>
{nav.icon}
{nav.title}
</Link>
))}
</nav>
<div className="mt-auto">
<Card>
<CardHeader>
<CardTitle>Upgrade to Pro</CardTitle>
<CardDescription>
Unlock all features and get unlimited access to our support
team.
</CardDescription>
</CardHeader>
<CardContent>
<Button size="sm" className="w-full">
Upgrade
</Button>
</CardContent>
</Card>
</div>
</SheetContent>
</Sheet>
<div className="w-full flex-1">
+6 -1
View File
@@ -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: <GalleryVerticalEnd className="h-4 w-4" />,
},
{
title: "Missing Locations",
link: "/assets/missing-locations",
icon: <LocateOff className="h-4 w-4" />,
},
];
+6 -1
View File
@@ -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;
export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id;
// Location
export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
+13
View File
@@ -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);
}
+8
View File
@@ -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<IPlace[]> => {
return API.get(SEARCH_PLACES_PATH, { name });
}
@@ -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(
@@ -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);
+14 -26
View File
@@ -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
</Button>
)}
<TagMissingLocationDialog onSubmit={handleSubmit} />
</>
}
/>
+6
View File
@@ -2,4 +2,10 @@ export interface IListData {
hasNextPage: boolean,
total: number,
hidden: number,
}
export interface IPlace {
name: string;
latitude: number;
longitude: number;
}