diff --git a/src/components/assets/missing-location/MissingLocationAssets.tsx b/src/components/assets/missing-location/MissingLocationAssets.tsx index 2971187..400b37a 100644 --- a/src/components/assets/missing-location/MissingLocationAssets.tsx +++ b/src/components/assets/missing-location/MissingLocationAssets.tsx @@ -20,10 +20,13 @@ import { addDays } from "date-fns"; import { useConfig } from "@/contexts/ConfigContext"; import LazyGridImage from "@/components/ui/lazy-grid-image"; -export default function MissingLocationAssets() { +interface IProps { + groupBy: "date" | "album"; +} +export default function MissingLocationAssets({ groupBy }: IProps) { const { exImmichUrl } = useConfig(); - const { startDate, selectedIds, assets, updateContext } = + const { startDate, albumId, selectedIds, assets, updateContext } = useMissingLocationContext(); const [loading, setLoading] = useState(false); @@ -32,12 +35,13 @@ export default function MissingLocationAssets() { const [index, setIndex] = useState(-1); const fetchAssets = async () => { - if (!startDate) return; + if (!startDate && !albumId) return; setLoading(true); updateContext({ assets: [], }); - return listMissingLocationAssets({ startDate }) + const filters = groupBy === "date" ? { startDate } : { albumId } + return listMissingLocationAssets(filters) .then((assets) => updateContext({ assets })) .catch(setErrorMessage) .finally(() => setLoading(false)); @@ -88,8 +92,8 @@ export default function MissingLocationAssets() { }; useEffect(() => { - if (startDate) fetchAssets(); - }, [startDate]); + if (startDate || albumId) fetchAssets(); + }, [startDate, albumId]); if (loading) return ( @@ -99,7 +103,7 @@ export default function MissingLocationAssets() { ); - if (!startDate) + if (!startDate && !albumId) return (
diff --git a/src/components/assets/missing-location/MissingLocationDateItem.tsx b/src/components/assets/missing-location/MissingLocationDateItem.tsx index dde352b..4e4378e 100644 --- a/src/components/assets/missing-location/MissingLocationDateItem.tsx +++ b/src/components/assets/missing-location/MissingLocationDateItem.tsx @@ -8,24 +8,34 @@ import React, { useMemo } from "react"; interface IProps { record: IMissingLocationDatesResponse; onSelect: (date: string) => void; + groupBy: "date" | "album"; } -export default function MissingLocationDateItem({ record, onSelect }: IProps) { +export default function MissingLocationDateItem({ record, onSelect, groupBy }: IProps) { const { startDate } = useMissingLocationContext() + const dateLabel = useMemo(() => { - if (!record.date) return "Unknown" - return formatDate(parseDate(record.date, "yyyy-MM-dd").toISOString(), "do MMM yyyy") - }, [record.date]) + if (groupBy === "album") { + return record.label + } + if (!record.label) return "Unknown" + try { + return formatDate(parseDate(record.label, "yyyy-MM-dd").toISOString(), "do MMM yyyy") + } catch (e) { + return record.label + } + }, [record.label, groupBy]) + return (
onSelect(record.date)} - key={record.date} + onClick={() => onSelect(record.value)} + key={record.label} className={ - cn("flex gap-1 flex-col p-2 py-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100 px-4", - startDate === record.date ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "") + cn("flex gap-1 flex-col p-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100", + startDate === record.label ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "") } > -

{dateLabel}

+

{dateLabel}

{record.asset_count} Orphan Assets

); diff --git a/src/components/assets/missing-location/MissingLocationDates.tsx b/src/components/assets/missing-location/MissingLocationDates.tsx index d7c6fad..e9a24e3 100644 --- a/src/components/assets/missing-location/MissingLocationDates.tsx +++ b/src/components/assets/missing-location/MissingLocationDates.tsx @@ -1,6 +1,6 @@ import React, { use, useEffect, useState } from "react"; import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; -import { IMissingLocationDatesResponse, listMissingLocationDates } from "@/handlers/api/asset.handler"; +import { IMissingLocationDatesResponse, listMissingLocationAlbums, listMissingLocationDates } from "@/handlers/api/asset.handler"; import MissingLocationDateItem from "./MissingLocationDateItem"; import { useMissingLocationContext } from "@/contexts/MissingLocationContext"; import { useRouter } from "next/router"; @@ -8,7 +8,11 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrig import { Button } from "@/components/ui/button"; import { SortAsc, SortDesc } from "lucide-react"; -export default function MissingLocationDates() { +interface IMissingLocationDatesProps { + groupBy: string; + +} +export default function MissingLocationDates({ groupBy }: IMissingLocationDatesProps) { const { updateContext } = useMissingLocationContext(); const router = useRouter(); const [dateRecords, setDateRecords] = React.useState< @@ -20,7 +24,9 @@ export default function MissingLocationDates() { const [errorMessage, setErrorMessage] = useState(null); const fetchData = async () => { - return listMissingLocationDates({ + const func = groupBy === "album" ? listMissingLocationAlbums : listMissingLocationDates + setDateRecords([]) + return func({ sortBy: filters.sortBy, sortOrder: filters.sortOrder, }) @@ -30,23 +36,41 @@ export default function MissingLocationDates() { }; const handleSelect = (date: string) => { - updateContext({ startDate: date }); - router.push({ - pathname: router.pathname, - query: { startDate: date }, - }); + if (groupBy === "album") { + updateContext({ albumId: date }); + router.push({ + pathname: router.pathname, + query: { + ...router.query, + albumId: date, + groupBy: "album", + startDate: undefined + }, + }); + } else { + updateContext({ startDate: date }); + router.push({ + pathname: router.pathname, + query: { + ...router.query, + startDate: date, + groupBy: "date", + albumId: undefined + }, + }); + } }; - - useEffect(() => { fetchData(); - }, [filters]); + }, [filters, groupBy]); return ( -
+
- setFilters({ ...filters, sortBy: value })} > @@ -60,21 +84,27 @@ export default function MissingLocationDates() { Asset Count - + )}
-
- - {dateRecords.map((record) => ( - - ))} +
+ {dateRecords.map((record) => ( + + ))} +
); } diff --git a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx index 765796e..d2c60bb 100644 --- a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx +++ b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx @@ -30,14 +30,14 @@ export default function TagMissingLocationDialog({ const [mapPosition,setMapPosition] = useState({ latitude: 48.0, longitude: 16.0, - name: "home1" + name: "home" }); return ( - + diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx new file mode 100644 index 0000000..83d89db --- /dev/null +++ b/src/components/shared/FloatingBar.tsx @@ -0,0 +1,16 @@ +import { Button } from '@/components/ui/button' +import React from 'react' + +interface FloatingBarProps { + children: React.ReactNode; +} + +export default function FloatingBar({ children }: FloatingBarProps) { + return ( +
+
+ {children} +
+
+ ) +} diff --git a/src/config/routes.ts b/src/config/routes.ts index 4afe671..4c417a2 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -29,6 +29,7 @@ export const SHARE_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/share"; // Assets export const LIST_MISSING_LOCATION_DATES_PATH = BASE_API_ENDPOINT + "/assets/missing-location-dates"; +export const LIST_MISSING_LOCATION_ALBUMS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-albums"; export const LIST_MISSING_LOCATION_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-assets"; export const UPDATE_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/assets"; diff --git a/src/contexts/MissingLocationContext.tsx b/src/contexts/MissingLocationContext.tsx index 5044a12..445d3f7 100644 --- a/src/contexts/MissingLocationContext.tsx +++ b/src/contexts/MissingLocationContext.tsx @@ -3,6 +3,7 @@ import { createContext, useContext } from "react"; export interface IMissingLocationConfig { startDate?: string; + albumId?: string; selectedIds: string[]; assets: IAsset[]; } @@ -12,6 +13,7 @@ export interface MissingLocationContext extends IMissingLocationConfig { } const MissingLocationContext = createContext({ startDate: undefined, + albumId: undefined, selectedIds: [], assets: [], updateContext: () => { }, diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index 279f0f7..2b86b79 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -3,6 +3,7 @@ import { ASSET_GEO_HEATMAP_PATH, FIND_ASSETS, LIST_ALBUMS_PATH, + LIST_MISSING_LOCATION_ALBUMS_PATH, LIST_MISSING_LOCATION_ASSETS_PATH, LIST_MISSING_LOCATION_DATES_PATH, UPDATE_ASSETS_PATH, @@ -18,16 +19,25 @@ interface IMissingAssetAlbumsFilters { sortOrder?: string; } export interface IMissingLocationDatesResponse { - date: string; + label: string; asset_count: number; + value: string; + createdAt?: string; } + export const listMissingLocationDates = async ( filters: IMissingAssetAlbumsFilters ): Promise => { return API.get(LIST_MISSING_LOCATION_DATES_PATH, filters); }; +export const listMissingLocationAlbums = async ( + filters: IMissingAssetAlbumsFilters +): Promise => { + return API.get(LIST_MISSING_LOCATION_ALBUMS_PATH, filters); +}; + export const listMissingLocationAssets = async ( filters: IMissingAssetAlbumsFilters ): Promise => { diff --git a/src/pages/api/assets/missing-location-albums.ts b/src/pages/api/assets/missing-location-albums.ts new file mode 100644 index 0000000..3a30e0f --- /dev/null +++ b/src/pages/api/assets/missing-location-albums.ts @@ -0,0 +1,55 @@ +import { db } from "@/config/db"; +import { IMissingLocationDatesResponse } from "@/handlers/api/asset.handler"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { parseDate } from "@/helpers/date.helper"; +import { assets, exif } from "@/schema"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; +import { albums } from "@/schema/albums.schema"; +import { and, count, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + const { sortBy = "date", sortOrder = "desc" } = req.query; + const currentUser = await getCurrentUser(req); + if (!currentUser) { + return res.status(401).json({ + error: "Unauthorized", + }); + } + const rows = await db + .select({ + asset_count: desc(count(assets.id)), + label: albums.albumName, + value: albums.id, + }) + .from(assets) + .leftJoin(exif, eq(exif.assetId, assets.id)) + .leftJoin(albumsAssetsAssets, eq(albumsAssetsAssets.assetsId, assets.id)) + .leftJoin(albums, eq(albums.id, albumsAssetsAssets.albumsId)) + .where( + and( + isNull(exif.latitude), + isNotNull(assets.createdAt), + isNotNull(exif.dateTimeOriginal), + eq(assets.ownerId, currentUser.id), + eq(assets.isVisible, true), + isNotNull(albums.id) + )) + .groupBy(albums.id) + .orderBy(desc(count(assets.id))) as IMissingLocationDatesResponse[]; + + + rows.sort((a, b) => { + return sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count; + }); + return res.status(200).json(rows); + } catch (error: any) { + res.status(500).json({ + error: error?.message, + }); + } +} diff --git a/src/pages/api/assets/missing-location-assets.ts b/src/pages/api/assets/missing-location-assets.ts index fe4934d..5d74468 100644 --- a/src/pages/api/assets/missing-location-assets.ts +++ b/src/pages/api/assets/missing-location-assets.ts @@ -3,62 +3,116 @@ import { db } from "@/config/db"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { parseDate } from "@/helpers/date.helper"; import { assets, exif } from "@/schema"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; +import { albums } from "@/schema/albums.schema"; +import { IUser } from "@/types/user"; import { addDays } from "date-fns"; import { and, eq, gte, isNotNull, isNull, lte, ne, sql } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; +const getRowsByDates = async (startDateDate: Date, endDateDate: Date, currentUser: IUser) => { + return db + .select({ + id: assets.id, + deviceId: assets.deviceId, + type: assets.type, + originalPath: assets.originalPath, + isFavorite: assets.isFavorite, + duration: assets.duration, + encodedVideoPath: assets.encodedVideoPath, + originalFileName: assets.originalFileName, + sidecarPath: assets.sidecarPath, + deletedAt: assets.deletedAt, + localDateTime: assets.localDateTime, + exifImageWidth: exif.exifImageWidth, + exifImageHeight: exif.exifImageHeight, + ownerId: assets.ownerId, + dateTimeOriginal: exif.dateTimeOriginal, + }) + .from(assets) + .leftJoin(exif, eq(exif.assetId, assets.id)) + .where(and( + isNull(exif.latitude), + isNotNull(assets.createdAt), + gte(exif.dateTimeOriginal, startDateDate), + lte(exif.dateTimeOriginal, endDateDate), + eq(assets.ownerId, currentUser.id), + )); +} + +const getRowsByAlbums = async (currentUser: IUser, albumId: string) => { + return db.select({ + id: assets.id, + deviceId: assets.deviceId, + type: assets.type, + originalPath: assets.originalPath, + isFavorite: assets.isFavorite, + duration: assets.duration, + encodedVideoPath: assets.encodedVideoPath, + originalFileName: assets.originalFileName, + sidecarPath: assets.sidecarPath, + deletedAt: assets.deletedAt, + localDateTime: assets.localDateTime, + exifImageWidth: exif.exifImageWidth, + exifImageHeight: exif.exifImageHeight, + ownerId: assets.ownerId, + dateTimeOriginal: exif.dateTimeOriginal, + }) + .from(assets) + .leftJoin(exif, eq(exif.assetId, assets.id)) + .leftJoin(albumsAssetsAssets, eq(albumsAssetsAssets.assetsId, assets.id)) + .leftJoin(albums, eq(albums.id, albumsAssetsAssets.albumsId)) + .where(and( + isNull(exif.latitude), + isNotNull(assets.createdAt), + eq(assets.ownerId, currentUser.id), + eq(albums.id, albumId) + )); +} + export default async function handler( req: NextApiRequest, res: NextApiResponse ) { - const { startDate } = req.query as { + const { startDate, groupBy, albumId } = req.query as { startDate: string; + groupBy: "date" | "album"; + albumId?: string; }; const currentUser = await getCurrentUser(req); - if (!startDate) { + if (!currentUser) { + return res.status(401).json({ + error: "Unauthorized", + }); + } + + if (!startDate && !albumId) { return res.status(400).json({ - error: "startDate and endDate are required", + error: "startDate or albumId is required", }); } - const startDateDate = parseDate(startDate, "yyyy-MM-dd"); - const endDateDate = addDays(parseDate(startDate, "yyyy-MM-dd"), 1); - - try { - const rows = await db - .select({ - id: assets.id, - deviceId: assets.deviceId, - type: assets.type, - originalPath: assets.originalPath, - isFavorite: assets.isFavorite, - duration: assets.duration, - encodedVideoPath: assets.encodedVideoPath, - originalFileName: assets.originalFileName, - sidecarPath: assets.sidecarPath, - deletedAt: assets.deletedAt, - localDateTime: assets.localDateTime, - exifImageWidth: exif.exifImageWidth, - exifImageHeight: exif.exifImageHeight, - ownerId: assets.ownerId, - dateTimeOriginal: exif.dateTimeOriginal, - }) - .from(assets) - .leftJoin(exif, eq(exif.assetId, assets.id)) - .where(and( - isNull(exif.latitude), - isNotNull(assets.createdAt), - gte(exif.dateTimeOriginal, startDateDate), - lte(exif.dateTimeOriginal, endDateDate), - eq(assets.ownerId, currentUser.id), - )); + if (startDate) { + const startDateDate = parseDate(startDate, "yyyy-MM-dd"); + const endDateDate = addDays(parseDate(startDate, "yyyy-MM-dd"), 1); + try { + const rows = await getRowsByDates(startDateDate, endDateDate, currentUser); + return res.status(200).json(rows); + } catch (error: any) { + return res.status(500).json({ + error: error?.message, + }); + } + } else if (albumId) { + if (!albumId) { + return res.status(400).json({ + error: "albumId is required", + }); + } + const rows = await getRowsByAlbums(currentUser, albumId); return res.status(200).json(rows); - } catch (error: any) { - res.status(500).json({ - error: error?.message, - }); } -} +} \ No newline at end of file diff --git a/src/pages/api/assets/missing-location-dates.ts b/src/pages/api/assets/missing-location-dates.ts index c09692a..339c073 100644 --- a/src/pages/api/assets/missing-location-dates.ts +++ b/src/pages/api/assets/missing-location-dates.ts @@ -23,7 +23,8 @@ export default async function handler( const rows = await db .select({ asset_count: desc(count(assets.id)), - date: sql`DATE(${exif.dateTimeOriginal})`, + label: sql`DATE(${exif.dateTimeOriginal})`, + value: sql`DATE(${exif.dateTimeOriginal})`, }) .from(assets) .leftJoin(exif, eq(exif.assetId, assets.id)) @@ -39,8 +40,8 @@ export default async function handler( if (sortBy === "date") { rows.sort((a, b) => { - const aDate = parseDate(a.date, "yyyy-MM-dd"); - const bDate = parseDate(b.date, "yyyy-MM-dd"); + const aDate = parseDate(a.label, "yyyy-MM-dd"); + const bDate = parseDate(b.label, "yyyy-MM-dd"); return sortOrder === "asc" ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime(); }); } else if (sortBy === "asset_count") { diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx index 7a3b30e..9a1c5d0 100644 --- a/src/pages/assets/missing-locations.tsx +++ b/src/pages/assets/missing-locations.tsx @@ -15,19 +15,19 @@ import { updateAssets } from "@/handlers/api/asset.handler"; import { IPlace } from "@/types/common"; import { useRouter } from "next/router"; import React, { useMemo } from "react"; +import FloatingBar from "@/components/shared/FloatingBar"; +import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from "@/components/ui/select"; export default function MissingLocations() { - const { toast } = useToast(); - - const { query } = useRouter(); - const { startDate } = query as { startDate: string }; + const { query, push } = useRouter(); + const { startDate, groupBy = "date" } = query as { startDate: string, groupBy: string }; const [config, setConfig] = React.useState({ startDate: startDate || undefined, selectedIds: [], assets: [], }); - const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]) ; + const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]); const handleSubmit = (place: IPlace) => { return updateAssets({ @@ -43,42 +43,32 @@ export default function MissingLocations() { }; return ( - +
- - {config.selectedIds.length} Selected - - {config.selectedIds.length === config.assets.length ? ( - - ) : ( - - )} - +
+ { }} /> - +
} />
- - + +
+ +
+

+ {config.selectedIds.length} Selected +

+
+ {config.selectedIds.length === config.assets.length ? ( + + ) : ( + + )} + +
+
+
);