feat: minimum assets filter in potential albums list
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: () => { },
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ interface IPotentialAlbumsDatesFilters {
|
||||
endDate?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
minAssets?: number;
|
||||
}
|
||||
export interface IPotentialAlbumsDatesResponse {
|
||||
date: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -92,4 +92,10 @@
|
||||
#ReactGridGallery img {
|
||||
max-width: none !important;
|
||||
height: none !important;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.ReactGridGallery_tile {
|
||||
@apply bg-zinc-800 !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user