feat: minimum assets filter in potential albums list

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2025-01-26 17:32:14 +05:30
parent 0715257048
commit f17356252d
10 changed files with 187 additions and 145 deletions
@@ -12,7 +12,7 @@ import { ArrowDown, ArrowUp, ArrowUpDownIcon, SortAsc, SortDesc } from "lucide-r
export default function PotentialAlbumsDates() {
const router = useRouter();
const { updateContext } = usePotentialAlbumContext();
const { updateContext, minAssets } = usePotentialAlbumContext();
const [dateRecords, setDateRecords] = React.useState<
IPotentialAlbumsDatesResponse[]
>([]);
@@ -26,6 +26,7 @@ export default function PotentialAlbumsDates() {
return listPotentialAlbumsDates({
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
minAssets,
})
.then(setDateRecords)
.catch(setErrorMessage)
@@ -43,7 +44,7 @@ export default function PotentialAlbumsDates() {
useEffect(() => {
fetchData();
}, [filters]);
}, [filters, minAssets]);
return (
<div className="min-w-[200px] 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-1">
@@ -36,7 +36,7 @@ export default function MissingLocationDateItem({ record, onSelect, groupBy }: I
}
>
<p className="text-sm truncate">{dateLabel}</p>
<p className="text-xs text-foreground/50">{record.asset_count} Orphan Assets</p>
<p className="text-xs text-foreground/50">{record.asset_count} Assets</p>
</div>
);
}
+17 -8
View File
@@ -20,6 +20,15 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
const handleClick = (index: number, asset: IAsset, event: React.MouseEvent<HTMLElement>) => {
if (selectedIds.length > 0) {
handleSelect(index, asset, event);
} else {
setIndex(index);
}
}
const handleSelect = (_idx: number, asset: IAsset, event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
@@ -55,13 +64,13 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
type: (asset.type === "VIDEO" ? "video" : "image") as any,
sources:
asset.type === "VIDEO"
? [
{
src: asset.downloadUrl as string,
type: "video/mp4",
},
]
: undefined,
? [
{
src: asset.downloadUrl as string,
type: "video/mp4",
},
]
: undefined,
height: asset.exifImageHeight as number,
width: asset.exifImageWidth as number,
downloadUrl: asset.downloadUrl as string,
@@ -104,7 +113,7 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals
/>
<Gallery
images={images}
onClick={setIndex}
onClick={handleClick}
enableImageSelection={selectable}
thumbnailImageComponent={LazyGridImage}
onSelect={handleSelect}
+14 -5
View File
@@ -5,6 +5,7 @@ import { useConfig } from "@/contexts/ConfigContext";
import { logoutUser } from "@/handlers/api/user.handler"
import { useCurrentUser } from "@/contexts/CurrentUserContext";
import { useToast } from "../ui/use-toast";
import { HandshakeIcon } from "lucide-react";
export default function ProfileInfo() {
const { updateContext, ...user } = useCurrentUser()
@@ -36,11 +37,19 @@ export default function ProfileInfo() {
{user && (
<>
<p className="text-sm">{user?.name}</p>
{!user.isUsingAPIKey && (
<Button variant="destructive" className="mx-4 h-6 text-xs" onClick={handleLogout}>
Log Out
</Button>
)}
<div className="flex items-center justify-between gap-2 px-2">
{!user.isUsingAPIKey && (
<Button variant="destructive" className="h-6 flex-1 text-xs" onClick={handleLogout}>
Log Out
</Button>
)}
<Button asChild variant="secondary" className="h-6 flex-1 text-xs flex items-center justify-center gap-2" onClick={handleLogout}>
<Link href="https://buymeacoffee.com/varunraj" target="_blank">
<HandshakeIcon className="w-4 h-4" />
Support
</Link>
</Button>
</div>
</>
)}
</div>
+2
View File
@@ -5,6 +5,7 @@ export interface IPotentialAlbumConfig {
startDate?: string;
selectedIds: string[];
assets: IAsset[];
minAssets?: number;
}
export interface IPotentialAlbumContext extends IPotentialAlbumConfig {
@@ -14,6 +15,7 @@ const PotentialAlbumContext = createContext<IPotentialAlbumContext>({
startDate: undefined,
selectedIds: [],
assets: [],
minAssets: 1,
updateContext: () => { },
});
+1
View File
@@ -9,6 +9,7 @@ interface IPotentialAlbumsDatesFilters {
endDate?: string;
sortBy?: string;
sortOrder?: string;
minAssets?: number;
}
export interface IPotentialAlbumsDatesResponse {
date: string;
+64 -49
View File
@@ -8,6 +8,7 @@ import Header from "@/components/shared/Header";
import { AlertDialog } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { useCurrentUser } from "@/contexts/CurrentUserContext";
import PotentialAlbumContext, {
@@ -29,6 +30,7 @@ export default function PotentialAlbums() {
startDate: startDate || undefined,
selectedIds: [],
assets: [],
minAssets: 1,
});
const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]);
@@ -108,6 +110,19 @@ export default function PotentialAlbums() {
<PageLayout className="!p-0 !mb-0 relative">
<Header
leftComponent="Potential Albums"
rightComponent={(
<div className="flex items-center gap-2">
<Input
placeholder="Minimum Assets Count"
defaultValue={config.minAssets}
onChange={(e) => {
if (e.target.value) {
setConfig({ ...config, minAssets: parseInt(e.target.value) });
}
}}
/>
</div>
)}
/>
<PotentialAlbumContext.Provider
value={{
@@ -120,57 +135,57 @@ export default function PotentialAlbums() {
<PotentialAlbumsDates />
<PotentialAlbumsAssets />
</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.selectedIds.length > 0 ? (
<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>
)}
<AlbumSelectorDialog onSelected={handleSelect} onSubmit={handleCreate}/>
<AssetOffsetDialog assets={selectedAssets} onComplete={handleOffsetComplete} />
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
{selectedAssets.length &&
<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">
<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),
})
}
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}
disabled={config.selectedIds.length === 0}
>
<Button variant={"destructive"} size={"sm"} disabled={config.selectedIds.length === 0}>
Delete
</Button>
</AlertDialog>
>
Select all
</Button>
<AlbumSelectorDialog onSelected={handleSelect} onSubmit={handleCreate} />
<AssetOffsetDialog assets={selectedAssets} onComplete={handleOffsetComplete} />
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}
disabled={config.selectedIds.length === 0}
>
<Button variant={"destructive"} size={"sm"} disabled={config.selectedIds.length === 0}>
Delete
</Button>
</AlertDialog>
</div>
</div>
</div>
</FloatingBar>
</FloatingBar>
}
</PotentialAlbumContext.Provider>
</PageLayout>
);
+28 -31
View File
@@ -3,52 +3,49 @@ import { db } from "@/config/db";
import { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { parseDate } from "@/helpers/date.helper";
import { sql } from "drizzle-orm";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { albums } from "@/schema/albums.schema";
import { assets } from "@/schema/assets.schema";
import { exif } from "@/schema/exif.schema";
import { and, count, desc, eq, gte, isNotNull, isNull, sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { number } from "zod";
const SELECT_ORPHAN_PHOTOS = (ownerId: string) => sql`
SELECT
DATE(e."dateTimeOriginal") AS "date",
COUNT(a."id") AS "asset_count"
FROM
"assets" a
LEFT JOIN
"albums_assets_assets" aaa ON a."id" = aaa."assetsId"
LEFT JOIN
"exif" e ON a."id" = e."assetId"
WHERE
aaa."albumsId" IS NULL
AND a."ownerId" = ${ownerId}
AND a."isVisible" = true
AND e."dateTimeOriginal" IS NOT NULL
GROUP BY
DATE(e."dateTimeOriginal")
ORDER BY
"asset_count" DESC;
`;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const { sortBy = "date", sortOrder = "desc" } = req.query;
const { sortBy = "date", sortOrder = "desc", minAssets = 1 } = req.query as any;
const currentUser = await getCurrentUser(req)
const data = await db.execute(SELECT_ORPHAN_PHOTOS(currentUser.id)) as any;
const rows = data.rows as IPotentialAlbumsDatesResponse[];
const rows = await db.select({
date: sql`DATE(${exif.dateTimeOriginal})`,
asset_count: desc(count(assets.id)),
}).from(assets)
.leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
.leftJoin(exif, eq(assets.id, exif.assetId))
.where(and (
eq(assets.ownerId, currentUser.id),
eq(assets.isVisible, true),
isNull(albumsAssetsAssets.albumsId),
isNotNull(exif.dateTimeOriginal),
))
.groupBy(sql`DATE(${exif.dateTimeOriginal})`) as IPotentialAlbumsDatesResponse[];
const filteredRows = rows.filter((row) => Number(row.asset_count) >= Number(minAssets));
if (sortBy === "date") {
rows.sort((a, b) => {
const aDate = parseDate(a.date, "yyyy-MM-dd");
const bDate = parseDate(b.date, "yyyy-MM-dd");
filteredRows.sort((a, b) => {
const aDate = parseDate(a.date as string, "yyyy-MM-dd");
const bDate = parseDate(b.date as string, "yyyy-MM-dd");
return sortOrder === "asc" ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime();
});
} else if (sortBy === "asset_count") {
rows.sort((a, b) => {
return sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count;
});
filteredRows.sort((a, b) => sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count);
}
return res.status(200).json(rows);
return res.status(200).json(filteredRows);
} catch (error: any) {
res.status(500).json({
error: error?.message,
+51 -49
View File
@@ -19,6 +19,7 @@ import FloatingBar from "@/components/shared/FloatingBar";
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from "@/components/ui/select";
import { AlertDialog } from "@/components/ui/alert-dialog";
import AssetOffsetDialog from "@/components/assets/assets-options/AssetOffsetDialog";
import { Input } from "@/components/ui/input";
export default function MissingLocations() {
const { query, push } = useRouter();
@@ -110,57 +111,58 @@ export default function MissingLocations() {
<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: [],
})
}
{selectedAssets.length &&
<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>
)}
{/* Seperator */}
<TagMissingLocationDialog onSubmit={handleSubmit} />
<AssetOffsetDialog assets={selectedAssets} onComplete={handleOffsetComplete} />
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}
disabled={config.selectedIds.length === 0}
>
Unselect all
</Button>
) : (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
setConfig({
...config,
selectedIds: config.assets.map((a) => a.id),
})
}
>
Select all
</Button>
)}
{/* Seperator */}
<TagMissingLocationDialog onSubmit={handleSubmit} />
<AssetOffsetDialog assets={selectedAssets} onComplete={handleOffsetComplete} />
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}
disabled={config.selectedIds.length === 0}
>
<Button variant={"destructive"} size={"sm"} disabled={config.selectedIds.length === 0}>
Delete
</Button>
</AlertDialog>
<Button variant={"destructive"} size={"sm"} disabled={config.selectedIds.length === 0}>
Delete
</Button>
</AlertDialog>
</div>
</div>
</div>
</FloatingBar>
</FloatingBar>
}
</MissingLocationContext.Provider>
</PageLayout>
);
+6
View File
@@ -92,4 +92,10 @@
#ReactGridGallery img {
max-width: none !important;
height: none !important;
}
.dark {
.ReactGridGallery_tile {
@apply bg-zinc-800 !important;
}
}