feat: added option to group by albums for missing locations

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj committed 2025-01-05 12:21:13 +05:30
1 parent 5b2b779a04
commit bb6ffeb456
12 files changed
+333 -123

No files matched your search

@@ -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() {
</div>
);
if (!startDate)
if (!startDate && !albumId)
return (
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
<CalendarArrowUp />
@@ -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 (
<div
role="button"
onClick={() => 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" : "")
}
>
<p className="font-mono text-sm">{dateLabel}</p>
<p className="text-sm truncate">{dateLabel}</p>
<p className="text-xs text-foreground/50">{record.asset_count} Orphan Assets</p>
</div>
);
@@ -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<string | null>(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 (
<div className="overflow-y-auto min-w-[200px] py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
<div className="min-w-[200px] max-w-[200px] sticky top-0 py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] border-r border-gray-200 dark:border-zinc-800 flex flex-col gap-2 px-2">
<div className="flex justify-between items-center gap-2">
<Select
{groupBy === "album" ? (
<p className="text-sm text-gray-500 dark:text-zinc-400">Asset Count</p>
) : (<Select
defaultValue={filters.sortBy}
onValueChange={(value) => setFilters({ ...filters, sortBy: value })}
>
@@ -60,21 +84,27 @@ export default function MissingLocationDates() {
<SelectItem value="asset_count">Asset Count</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Select>)}
<div>
<Button variant="default" size="sm" onClick={() => setFilters({ ...filters, sortOrder: filters.sortOrder === "asc" ? "desc" : "asc" })}>
<Button variant="ghost" size="sm" onClick={() =>
setFilters({
...filters,
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc"
})}>
{filters.sortOrder === "asc" ? <SortAsc size={16} /> : <SortDesc size={16} />}
</Button>
</div>
</div>
{dateRecords.map((record) => (
<MissingLocationDateItem
key={record.date}
record={record}
onSelect={handleSelect}
/>
))}
<div className="overflow-y-auto flex flex-col gap-2">
{dateRecords.map((record) => (
<MissingLocationDateItem
key={record.value}
record={record}
groupBy={groupBy as "date" | "album"}
onSelect={handleSelect}
/>
))}
</div>
</div>
);
}
@@ -30,14 +30,14 @@ export default function TagMissingLocationDialog({
const [mapPosition,setMapPosition] = useState<IPlace>({
latitude: 48.0,
longitude: 16.0,
name: "home1"
name: "home"
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Tag Missing Location</Button>
<Button size={"sm"}>Tag Missing Location</Button>
</DialogTrigger>
<DialogContent className="!w-[600px] !max-w-[600px]">
<DialogHeader>
+16
View File
@@ -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 (
<div className='absolute bottom-10 left-0 right-0'>
<div className='max-w-lg mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800'>
{children}
</div>
</div>
)
}
+1
View File
@@ -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";
+2
View File
@@ -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<MissingLocationContext>({
startDate: undefined,
albumId: undefined,
selectedIds: [],
assets: [],
updateContext: () => { },
+11 -1
View File
@@ -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<IMissingLocationDatesResponse[]> => {
return API.get(LIST_MISSING_LOCATION_DATES_PATH, filters);
};
export const listMissingLocationAlbums = async (
filters: IMissingAssetAlbumsFilters
): Promise<IMissingLocationDatesResponse[]> => {
return API.get(LIST_MISSING_LOCATION_ALBUMS_PATH, filters);
};
export const listMissingLocationAssets = async (
filters: IMissingAssetAlbumsFilters
): Promise<IAsset[]> => {
@@ -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,
});
}
}
+93 -39
View File
@@ -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,
});
}
}
}
@@ -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") {
+66 -39
View File
@@ -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<IMissingLocationConfig>({
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 (
<PageLayout className="!p-0 !mb-0">
<PageLayout className="!p-0 !mb-0 relative">
<Header
leftComponent="Missing Location"
rightComponent={
<>
<Badge variant={"outline"}>
{config.selectedIds.length} Selected
</Badge>
{config.selectedIds.length === config.assets.length ? (
<Button
variant={"outline"}
onClick={() =>
setConfig({
...config,
selectedIds: [],
})
}
>
Unselect all
</Button>
) : (
<Button
variant={"outline"}
onClick={() =>
setConfig({
...config,
selectedIds: config.assets.map((a) => a.id),
})
}
>
Select all
</Button>
)}
<TagMissingLocationDialog onSubmit={handleSubmit} />
<div className="flex items-center gap-2">
<Select value={groupBy} onValueChange={(value) => {
push({
pathname: "/assets/missing-locations",
query: {
...query,
groupBy: value,
startDate: undefined,
albumId: undefined,
},
});
}}>
<SelectTrigger>
<SelectValue placeholder="Group by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="album">Album</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
</>
</div>
}
/>
<MissingLocationContext.Provider
@@ -89,9 +79,46 @@ export default function MissingLocations() {
}}
>
<div className="flex divide-y">
<MissingLocationDates />
<MissingLocationAssets />
<MissingLocationDates groupBy={groupBy as "date" | "album"} />
<MissingLocationAssets groupBy={groupBy as "date" | "album"} />
</div>
<FloatingBar>
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-sm text-muted-foreground">
{config.selectedIds.length} Selected
</p>
<div className="flex items-center gap-2">
{config.selectedIds.length === config.assets.length ? (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
setConfig({
...config,
selectedIds: [],
})
}
>
Unselect all
</Button>
) : (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
setConfig({
...config,
selectedIds: config.assets.map((a) => a.id),
})
}
>
Select all
</Button>
)}
<TagMissingLocationDialog onSubmit={handleSubmit} />
</div>
</div>
</FloatingBar>
</MissingLocationContext.Provider>
</PageLayout>
);