From 5b2b779a04766a53abfba98ae79ea23924cb442a Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 5 Jan 2025 11:27:15 +0530 Subject: [PATCH 1/8] feat: added bulk share links generation Signed-off-by: Varun Raj --- src/components/albums/list/AlbumThumbnail.tsx | 13 +- .../albums/share/AlbumShareDialog.tsx | 137 ++++++++++++++++++ src/config/routes.ts | 1 + src/handlers/api/album.handler.ts | 6 +- src/pages/albums/[albumId].tsx | 10 +- src/pages/albums/index.tsx | 40 +++-- src/pages/api/albums/share.ts | 61 ++++++++ 7 files changed, 243 insertions(+), 25 deletions(-) create mode 100644 src/components/albums/share/AlbumShareDialog.tsx create mode 100644 src/pages/api/albums/share.ts diff --git a/src/components/albums/list/AlbumThumbnail.tsx b/src/components/albums/list/AlbumThumbnail.tsx index a4fb7a1..2278ccd 100644 --- a/src/components/albums/list/AlbumThumbnail.tsx +++ b/src/components/albums/list/AlbumThumbnail.tsx @@ -1,23 +1,23 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' import Link from 'next/link'; -import { useConfig } from '@/contexts/ConfigContext'; import { humanizeBytes, humanizeNumber, pluralize } from '@/helpers/string.helper'; import LazyImage from '@/components/ui/lazy-image'; import { ASSET_THUMBNAIL_PATH } from '@/config/routes'; import { IAlbum } from '@/types/album'; -import { Badge } from '@/components/ui/badge'; import { formatDate } from '@/helpers/date.helper'; import { Checkbox } from '@/components/ui/checkbox'; import { differenceInDays } from 'date-fns'; -import { FaceIcon } from '@radix-ui/react-icons'; -import { Calendar, Camera, Image, User } from 'lucide-react'; +import { Calendar, Camera } from 'lucide-react'; interface IAlbumThumbnailProps { album: IAlbum; onSelect: (checked: boolean) => void; + selected: boolean; } -export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps) { +export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThumbnailProps) { + const [isSelected, setIsSelected] = useState(selected); + const numberOfDays = useMemo(() => { return differenceInDays(album.lastPhotoDate, album.firstPhotoDate); }, [album.firstPhotoDate, album.lastPhotoDate]); @@ -39,6 +39,7 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps {formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy')} - {formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy')} diff --git a/src/components/albums/share/AlbumShareDialog.tsx b/src/components/albums/share/AlbumShareDialog.tsx new file mode 100644 index 0000000..e82ee73 --- /dev/null +++ b/src/components/albums/share/AlbumShareDialog.tsx @@ -0,0 +1,137 @@ +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { shareAlbums } from '@/handlers/api/album.handler'; +import { IAlbum } from '@/types/album'; +import React, { ForwardedRef, forwardRef, useImperativeHandle, useState } from 'react' + +export interface IAlbumShareDialogProps { + +} + +export interface IAlbumShareDialogRef { + open: (selectedAlbums: IAlbum[]) => void; + close: () => void; +} + +interface IAlbumWithLink extends IAlbum { + shareLink?: string; + allowDownload?: boolean; + allowUpload?: boolean; + showMetadata?: boolean; +} + +const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: ForwardedRef) => { + const [open, setOpen] = useState(false); + const [selectedAlbums, setSelectedAlbums] = useState([]); + const [generating, setGenerating] = useState(false); + const [error, setError] = useState(null); + const [generated, setGenerated] = useState(false); + + const handleGenerateShareLink = async () => { + setGenerating(true); + const data = selectedAlbums.map((album) => ({ + albumId: album.id, + albumName: album.albumName, + assetCount: album.assetCount, + allowDownload: !!album.allowDownload, + allowUpload: !!album.allowUpload, + showMetadata: !!album.showMetadata, + })); + + return shareAlbums(data).then((updatedAlbums) => { + setSelectedAlbums(updatedAlbums); + setGenerated(true); + }) + .catch((error) => { + setError(error.message); + }) + .finally(() => { + setGenerating(false); + }); + } + + const handleAllowPropertyChange = (albumId: string, property: string, checked: boolean) => { + setSelectedAlbums((prevAlbums) => prevAlbums.map((album) => album.id === albumId ? { ...album, [property]: checked } : album)); + } + + + useImperativeHandle(ref, () => ({ + open: (selectedAlbums: IAlbum[]) => { + setSelectedAlbums(selectedAlbums.map((album) => ({ ...album, allowDownload: true, allowUpload: true, showMetadata: true }))); + setOpen(true); + }, + close: () => { + setOpen(false); + } + })); + + return ( + + + + Share {selectedAlbums.length} albums + +
+ {error &&
{error}
} +
    + {selectedAlbums.map((album) => ( +
  1. +
    +

    {album.albumName}

    +

    {album.assetCount} Items

    +
    +
    + {album.shareLink ? +

    {album.shareLink}

    : ( + <> +

    No share link generated

    +
    +
    + handleAllowPropertyChange(album.id, 'allowDownload', !!checked)} /> + +
    +
    + handleAllowPropertyChange(album.id, 'allowUpload', !!checked)} /> + +
    +
    + handleAllowPropertyChange(album.id, 'showMetadata', !!checked)} /> + +
    + +
    + + )} +
    +
  2. + + ))} +
+ {generated ? ( + <> +

Share links all generated

+ + ) : ( + <> + {generating ?
+

Generating share links...

+
:
+ + +
} + + )} + +
+
+
+ ) +}) + +AlbumShareDialog.displayName = "AlbumShareDialog"; + +export default AlbumShareDialog; diff --git a/src/config/routes.ts b/src/config/routes.ts index a57bd36..4afe671 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -25,6 +25,7 @@ export const ALBUM_PEOPLE_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" export const ALBUM_ASSETS_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/assets"; export const CREATE_ALBUM_PATH = BASE_PROXY_ENDPOINT + "/albums"; export const ADD_ASSETS_ALBUMS_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/albums/" + id + "/assets"; +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"; diff --git a/src/handlers/api/album.handler.ts b/src/handlers/api/album.handler.ts index 2e39f7e..4554601 100644 --- a/src/handlers/api/album.handler.ts +++ b/src/handlers/api/album.handler.ts @@ -1,4 +1,4 @@ -import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH } from "@/config/routes"; +import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, SHARE_ALBUMS_PATH } from "@/config/routes"; import { cleanUpAsset } from "@/helpers/asset.helper"; import API from "@/lib/api"; import { IAlbumCreate } from "@/types/album"; @@ -45,3 +45,7 @@ export const addAssetToAlbum = async (albumId: string, assetIds: string[]) => { export const createAlbum = async (albumData: IAlbumCreate) => { return API.post(CREATE_ALBUM_PATH, albumData); } + +export const shareAlbums = async (albums: { albumId: string, allowDownload: boolean, allowUpload: boolean, showMetadata: boolean }[]) => { + return API.post(SHARE_ALBUMS_PATH, { albums }); +} \ No newline at end of file diff --git a/src/pages/albums/[albumId].tsx b/src/pages/albums/[albumId].tsx index d529562..3b40c0d 100644 --- a/src/pages/albums/[albumId].tsx +++ b/src/pages/albums/[albumId].tsx @@ -1,20 +1,16 @@ -import { ASSET_THUMBNAIL_PATH } from '@/config/routes' import PageLayout from '@/components/layouts/PageLayout' import Header from '@/components/shared/Header' import Loader from '@/components/ui/loader' import { useConfig } from '@/contexts/ConfigContext' -import { getAlbumInfo, listAlbums } from '@/handlers/api/album.handler' +import { getAlbumInfo } from '@/handlers/api/album.handler' import { IAlbum } from '@/types/album' -import Image from 'next/image' import Link from 'next/link' import React, { useEffect, useState } from 'react' -import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail' -import { Button } from '@/components/ui/button' import { useRouter } from 'next/router' import AlbumPeople from '@/components/albums/info/AlbumPeople' import AlbumImages from '@/components/albums/info/AlbumImages' -import { Camera, ExternalLink, LinkIcon, Users } from 'lucide-react' -import { humanizeNumber, pluralize } from '@/helpers/string.helper' +import { Camera, ExternalLink, Users } from 'lucide-react' +import { humanizeNumber } from '@/helpers/string.helper' export default function AlbumListPage() { const { exImmichUrl } = useConfig() diff --git a/src/pages/albums/index.tsx b/src/pages/albums/index.tsx index 05857d7..182d2ff 100644 --- a/src/pages/albums/index.tsx +++ b/src/pages/albums/index.tsx @@ -1,20 +1,17 @@ -import { ASSET_THUMBNAIL_PATH } from '@/config/routes' import PageLayout from '@/components/layouts/PageLayout' import Header from '@/components/shared/Header' import Loader from '@/components/ui/loader' import { useConfig } from '@/contexts/ConfigContext' import { listAlbums } from '@/handlers/api/album.handler' import { IAlbum } from '@/types/album' -import Image from 'next/image' -import Link from 'next/link' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail' import { Button } from '@/components/ui/button' import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select' import { useRouter } from 'next/router' -import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons' -import { SortAsc, SortDesc } from 'lucide-react' +import { Share, SortAsc, SortDesc } from 'lucide-react' import { Input } from '@/components/ui/input' +import AlbumShareDialog, { IAlbumShareDialogRef } from '@/components/albums/share/AlbumShareDialog' const SORT_BY_OPTIONS = [ { value: 'lastPhotoDate', label: 'Last Photo Date' }, @@ -27,7 +24,6 @@ const SORT_BY_OPTIONS = [ { value: 'faceCount', label: 'Number of People' }, ] export default function AlbumListPage() { - const { exImmichUrl } = useConfig() const router = useRouter() const [search, setSearch] = useState('') const { query, pathname } = router @@ -35,7 +31,8 @@ export default function AlbumListPage() { const [albums, setAlbums] = useState([]) const [loading, setLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') - const [selectedAlbums, setSelectedAlbums] = useState([]) + const [selectedAlbumsIds, setSelectedAlbumsIds] = useState([]) + const albumShareDialogRef = useRef(null); const selectedSortBy = useMemo(() => SORT_BY_OPTIONS.find((option) => option.value === sortBy), [sortBy]) @@ -55,15 +52,18 @@ export default function AlbumListPage() { }) } + const selectedAlbums = useMemo(() => albums.filter((album) => selectedAlbumsIds.includes(album.id)), [albums, selectedAlbumsIds]) + useEffect(() => { fetchAlbums() }, [sortBy, sortOrder]) + const handleSelect = (checked: boolean, albumId: string) => { if (checked) { - setSelectedAlbums([...selectedAlbums, albumId]) + setSelectedAlbumsIds([...selectedAlbumsIds, albumId]) } else { - setSelectedAlbums(selectedAlbums.filter((id) => id !== albumId)) + setSelectedAlbumsIds(selectedAlbumsIds.filter((id) => id !== albumId)) } } @@ -78,7 +78,12 @@ export default function AlbumListPage() { return (
{searchedAlbums.map((album) => ( - handleSelect(checked, album.id)} /> + handleSelect(checked, album.id)} + /> ))}
) @@ -89,6 +94,16 @@ export default function AlbumListPage() { leftComponent="Manage Albums" rightComponent={
+ {!!selectedAlbumsIds.length && ( + + )} setSearch(e.target.value)} /> 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) { - return res.status(400).json({ - error: "startDate and endDate are required", + if (!currentUser) { + return res.status(401).json({ + error: "Unauthorized", }); } - const startDateDate = parseDate(startDate, "yyyy-MM-dd"); - const endDateDate = addDays(parseDate(startDate, "yyyy-MM-dd"), 1); + if (!startDate && !albumId) { + return res.status(400).json({ + error: "startDate or albumId is required", + }); + } - 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 ? ( + + ) : ( + + )} + +
+
+
); From 1b986a831f5de50c63285e0e502337efe1aae256 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 5 Jan 2025 15:26:46 +0530 Subject: [PATCH 3/8] fix: gemini response and query fixes Signed-off-by: Varun Raj --- src/helpers/gemini.helper.ts | 3 +- src/pages/api/find/search.ts | 14 ++++- src/pages/find/index.tsx | 102 ++++++++++++++++++++++++++--------- src/schema/person.schema.ts | 4 +- 4 files changed, 94 insertions(+), 29 deletions(-) diff --git a/src/helpers/gemini.helper.ts b/src/helpers/gemini.helper.ts index d75a58e..bfe853e 100644 --- a/src/helpers/gemini.helper.ts +++ b/src/helpers/gemini.helper.ts @@ -57,7 +57,8 @@ const responseSchema = { export const parseFindQuery = async (query: string): Promise => { const prompt = ` - Parse the following query and return the query and tags: ${query} + Parse the following query and return the query and tags: ${query}. + Dont include any information that are not intentionally provided in the query. Additional Information For Parsing: today's date is ${new Date().toISOString().split('T')[0]} `; diff --git a/src/pages/api/find/search.ts b/src/pages/api/find/search.ts index b76fe55..07c1668 100644 --- a/src/pages/api/find/search.ts +++ b/src/pages/api/find/search.ts @@ -3,6 +3,7 @@ import { ENV } from '@/config/environment'; import { getCurrentUser } from '@/handlers/serverUtils/user.utils'; import { parseFindQuery } from '@/helpers/gemini.helper'; import { assetFaces, assets, person } from '@/schema'; +import { Person } from '@/schema/person.schema'; import { and, eq, inArray } from 'drizzle-orm'; import { NextApiRequest, NextApiResponse } from 'next'; @@ -10,8 +11,16 @@ export default async function search(req: NextApiRequest, res: NextApiResponse) const { query } = req.body; const currentUser = await getCurrentUser(req); const parsedQuery = await parseFindQuery(query as string); + const { personIds } = parsedQuery; const url = ENV.IMMICH_URL + "/api/search/smart"; + let dbPeople: Person[] = []; + if (personIds) { + dbPeople = await db.select().from(person).where( + inArray(person.id, personIds) + ); + } + return fetch(url, { method: 'POST', body: JSON.stringify(parsedQuery), @@ -33,7 +42,10 @@ export default async function search(req: NextApiRequest, res: NextApiResponse) .then(data => { res.status(200).json({ assets: data.assets.items, - filters: parsedQuery + filters: { + ...parsedQuery, + personIds: dbPeople.map((person) => person.name) + }, }); }).catch(err => { res.status(500).json({ error: err.message }); diff --git a/src/pages/find/index.tsx b/src/pages/find/index.tsx index 758e97d..83aaa13 100644 --- a/src/pages/find/index.tsx +++ b/src/pages/find/index.tsx @@ -7,14 +7,27 @@ import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH } from '@/config/routes'; import { useConfig } from '@/contexts/ConfigContext'; import { findAssets } from '@/handlers/api/asset.handler'; import { IAsset } from '@/types/asset'; -import { Captions, Megaphone, Search, Speaker, TriangleAlert, WandSparkles } from 'lucide-react'; +import { ArrowUpRight, Captions, Megaphone, Search, Speaker, TriangleAlert, WandSparkles } from 'lucide-react'; import Image from 'next/image'; import React, { useMemo, useState } from 'react' import Lightbox from 'yet-another-react-lightbox'; import { Button } from "@/components/ui/button"; import Link from "next/link"; +interface IFindFilters { + [key: string]: string; +} +const FILTER_KEY_MAP = { + "city": "City", + "state": "State", + "country": "Country", + "takenAfter": "Taken After", + "takenBefore": "Taken Before", + "size": "Size", + "model": "Model", + "personIds": "People", +} export default function FindPage() { const [index, setIndex] = useState(-1); @@ -22,6 +35,7 @@ export default function FindPage() { const [query, setQuery] = useState(''); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState({}); const slides = useMemo( () => @@ -33,19 +47,46 @@ export default function FindPage() { [assets] ); - + const appliedFilters: { + label: string; + value: string; + }[] = useMemo(() => { + return Object.entries(filters) + .filter(([_key, value]) => value !== undefined) + .map(([key, value]) => ({ + label: key, + value: Array.isArray(value) ? value.join(', ') : value, + })) + .filter((filter) => filter.label !== "query"); + }, [filters]); + const handleSearch = (query: string) => { setQuery(query); setLoading(true); findAssets(query) - .then(({ assets }) => { + .then(({ assets, filters: _filters }) => { setAssets(assets); + setFilters(_filters); }) .finally(() => { setLoading(false); }); } + const renderFilters = () => { + if (appliedFilters.length === 0) return null; + return ( +
+ {appliedFilters.map((filter) => ( +
+

{FILTER_KEY_MAP[filter.label as keyof typeof FILTER_KEY_MAP] || filter.label}

+

{filter.value}

+
+ ))} +
+ ) + } + const renderContent = () => { if (loading) { return
@@ -53,37 +94,46 @@ export default function FindPage() {
} else if (query.length === 0) { - return
- -

Search for photos in natural language

-

- Example: Sunset photos from last week. Use @ to search for photos of a specific person. -

-

- Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini. -

-
+ return ( +
+ +

Search for photos in natural language

+

+ Example: Sunset photos from last week. Use @ to search for photos of a specific person. +

+

+ Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini. +

+
+ ) } else if (assets.length === 0) { - return
- No results found + return
+ +

No results found for the below filters

+

+ {renderFilters()} +

+
} return ( <> - = 0} - index={index} - close={() => setIndex(-1)} - /> + = 0} + index={index} + close={() => setIndex(-1)} + /> + {renderFilters()}
{assets.map((asset, idx) => ( -
- - Open in Immich +
+ + Open + Date: Sun, 5 Jan 2025 15:38:07 +0530 Subject: [PATCH 4/8] fix: minor design fixes Signed-off-by: Varun Raj --- src/components/albums/AlbumCreateDialog.tsx | 2 +- src/components/albums/AlbumSelectorDialog.tsx | 2 +- src/components/albums/info/AlbumImages.tsx | 9 ++++----- src/components/albums/info/AlbumPeople.tsx | 4 ++-- .../potential-albums/PotentialAlbumsDates.tsx | 19 ++++++++++--------- .../potential-albums/PotentialDateItem.tsx | 4 ++-- .../analytics/exif/AssetHeatMap.tsx | 2 +- src/pages/albums/potential-albums.tsx | 6 ++++-- 8 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/components/albums/AlbumCreateDialog.tsx b/src/components/albums/AlbumCreateDialog.tsx index 619cfde..cd20447 100644 --- a/src/components/albums/AlbumCreateDialog.tsx +++ b/src/components/albums/AlbumCreateDialog.tsx @@ -55,7 +55,7 @@ export default function AlbumCreateDialog({ onSubmit, assetIds }: IProps) { return ( - + diff --git a/src/components/albums/AlbumSelectorDialog.tsx b/src/components/albums/AlbumSelectorDialog.tsx index af49ef7..d5dfc88 100644 --- a/src/components/albums/AlbumSelectorDialog.tsx +++ b/src/components/albums/AlbumSelectorDialog.tsx @@ -78,7 +78,7 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) { return ( - + diff --git a/src/components/albums/info/AlbumImages.tsx b/src/components/albums/info/AlbumImages.tsx index 3bb99af..c040931 100644 --- a/src/components/albums/info/AlbumImages.tsx +++ b/src/components/albums/info/AlbumImages.tsx @@ -125,22 +125,21 @@ export default function AlbumImages({ album }: AlbumImagesProps) { index={index} close={() => setIndex(-1)} /> -
+
{images.map((image) => (
handleClick(images.indexOf(image))} /> diff --git a/src/components/albums/info/AlbumPeople.tsx b/src/components/albums/info/AlbumPeople.tsx index 2def649..b37a69b 100644 --- a/src/components/albums/info/AlbumPeople.tsx +++ b/src/components/albums/info/AlbumPeople.tsx @@ -99,7 +99,7 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) { } return ( -
+
{selectedPerson && (
@@ -157,7 +157,7 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) { )} {selectedPeople.length > 0 && ( -
+
diff --git a/src/components/albums/potential-albums/PotentialAlbumsDates.tsx b/src/components/albums/potential-albums/PotentialAlbumsDates.tsx index 5710ec2..bbe0c92 100644 --- a/src/components/albums/potential-albums/PotentialAlbumsDates.tsx +++ b/src/components/albums/potential-albums/PotentialAlbumsDates.tsx @@ -46,7 +46,7 @@ export default function PotentialAlbumsDates() { }, [filters]); return ( -
+
setSearch(e.target.value)} /> handleBlur()} />
-
+
From 1132a046f4e712ac838ec5d263a0b9b7c8bf2e92 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sat, 11 Jan 2025 11:11:29 +0530 Subject: [PATCH 8/8] fix: album list fix Signed-off-by: Varun Raj --- src/pages/api/albums/list.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/pages/api/albums/list.ts b/src/pages/api/albums/list.ts index a45526c..0174da7 100644 --- a/src/pages/api/albums/list.ts +++ b/src/pages/api/albums/list.ts @@ -11,31 +11,34 @@ import { users } from "@/schema/users.schema"; import { assetFaces, exif, person } from "@/schema"; import { IAlbum } from "@/types/album"; +const getTime = (date: Date) => { + return date ? date.getTime() : 0; +} const sortAlbums = (albums: IAlbum[], sortBy: string, sortOrder: string) => { if (sortBy === 'createdAt') { - return albums.sort((a, b) => sortOrder === 'asc' ? a.createdAt.getTime() - b.createdAt.getTime() : b.createdAt.getTime() - a.createdAt.getTime()); + return albums.sort((a, b) => sortOrder === 'asc' ? getTime(a?.createdAt) - getTime(b?.createdAt) : getTime(b?.createdAt) - getTime(a?.createdAt)); } if (sortBy === 'updatedAt') { - return albums.sort((a, b) => sortOrder === 'asc' ? a.updatedAt.getTime() - b.updatedAt.getTime() : b.updatedAt.getTime() - a.updatedAt.getTime()); + return albums.sort((a, b) => sortOrder === 'asc' ? getTime(a?.updatedAt) - getTime(b?.updatedAt) : getTime(b?.updatedAt) - getTime(a?.updatedAt)); } if (sortBy === 'firstPhotoDate') { - return albums.sort((a, b) => sortOrder === 'asc' ? a.firstPhotoDate.getTime() - b.firstPhotoDate.getTime() : b.firstPhotoDate.getTime() - a.firstPhotoDate.getTime()); + return albums.sort((a, b) => sortOrder === 'asc' ? getTime(a?.firstPhotoDate) - getTime(b?.firstPhotoDate) : getTime(b?.firstPhotoDate) - getTime(a?.firstPhotoDate)); } if (sortBy === 'lastPhotoDate') { - return albums.sort((a, b) => sortOrder === 'asc' ? a.lastPhotoDate.getTime() - b.lastPhotoDate.getTime() : b.lastPhotoDate.getTime() - a.lastPhotoDate.getTime()); + return albums.sort((a, b) => sortOrder === 'asc' ? getTime(a?.lastPhotoDate) - getTime(b?.lastPhotoDate) : getTime(b?.lastPhotoDate) - getTime(a?.lastPhotoDate)); } if (sortBy === 'assetCount') { - return albums.sort((a, b) => sortOrder === 'asc' ? a.assetCount - b.assetCount : b.assetCount - a.assetCount); + return albums.sort((a, b) => sortOrder === 'asc' ? a?.assetCount - b?.assetCount : b?.assetCount - a?.assetCount); } if (sortBy === 'albumName') { - return albums.sort((a, b) => sortOrder === 'asc' ? a.albumName.localeCompare(b.albumName) : b.albumName.localeCompare(a.albumName)); + return albums.sort((a, b) => sortOrder === 'asc' ? a?.albumName?.localeCompare(b?.albumName) : b?.albumName?.localeCompare(a?.albumName)); } if (sortBy === 'albumSize') { - return albums.sort((a, b) => sortOrder === 'asc' ? parseInt(a.size) - parseInt(b.size) : parseInt(b.size) - parseInt(a.size)); + return albums.sort((a, b) => sortOrder === 'asc' ? parseInt(a?.size) - parseInt(b?.size) : parseInt(b?.size) - parseInt(a?.size)); } if (sortBy === 'faceCount') { - return albums.sort((a, b) => sortOrder === 'asc' ? a.faceCount - b.faceCount : b.faceCount - a.faceCount); + return albums.sort((a, b) => sortOrder === 'asc' ? a?.faceCount - b?.faceCount : b?.faceCount - a?.faceCount); } return albums; }