From 5b2b779a04766a53abfba98ae79ea23924cb442a Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 5 Jan 2025 11:27:15 +0530 Subject: [PATCH 01/22] 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 03/22] 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 04/22] 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 08/22] 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; } From 34f32e49069828ec4c7ca8595636cde9daf87421 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Fri, 17 Jan 2025 22:21:17 +0530 Subject: [PATCH 09/22] feat: shared album links Signed-off-by: Varun Raj --- README.md | 14 ++- bun.lockb | Bin 474668 -> 478938 bytes package.json | 2 + src/components/albums/info/AlbumPeople.tsx | 87 +++++++++-------- .../PotentialAlbumsAssets.tsx | 1 + .../albums/share/AlbumShareDialog.tsx | 79 +++++++++++++--- .../MissingLocationAssets.tsx | 1 + src/components/layouts/RootLayout.tsx | 15 +++ src/components/people/PersonItem.tsx | 2 + src/components/shared/AssetGrid.tsx | 71 ++++++++++++++ src/components/shared/ShareAssetsTrigger.tsx | 69 ++++++++++++++ src/config/environment.ts | 2 + src/config/routes.ts | 10 ++ src/handlers/api/shareLink.handler.ts | 17 ++++ src/helpers/asset.helper.ts | 32 ++++++- src/helpers/user.helper.ts | 14 ++- src/pages/api/albums/[id]/info.ts | 4 +- src/pages/api/albums/[id]/public-info.ts | 44 +++++++++ .../asset/share-thumbnail/[id].ts | 63 +++++++++++++ .../api/immich-proxy/asset/thumbnail/[id].ts | 1 - src/pages/api/share-link/[token].ts | 89 ++++++++++++++++++ src/pages/api/share-link/generate.ts | 24 +++++ src/pages/s/[token].tsx | 73 ++++++++++++++ src/styles/globals.scss | 5 + src/types/asset.d.ts | 12 +-- src/types/shareLink.d.ts | 6 ++ 26 files changed, 667 insertions(+), 70 deletions(-) create mode 100644 src/components/shared/AssetGrid.tsx create mode 100644 src/components/shared/ShareAssetsTrigger.tsx create mode 100644 src/handlers/api/shareLink.handler.ts create mode 100644 src/pages/api/albums/[id]/public-info.ts create mode 100644 src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts create mode 100644 src/pages/api/share-link/[token].ts create mode 100644 src/pages/api/share-link/generate.ts create mode 100644 src/pages/s/[token].tsx create mode 100644 src/types/shareLink.d.ts diff --git a/README.md b/README.md index 00e6057..1eb62e4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ A unofficial immich client to provide better tools to organize and manage your i [![Immich Power Tools](./screenshots/screenshot-1.png)](https://www.loom.com/embed/13aa90d8ab2e4acab0993bdc8703a750?sid=71498690-b745-473f-b239-a7bdbe6efc21) +### Support me 🙏 + +If you find this tool useful, please consider supporting me by buying me a coffee. + +[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/varunraj) + ## 💭 Back story Recently I've migrated my entire Google photos library to Immich, I was able to successfully migrate all my assets along with its albums to immich. But there were few things like people match that was lacking. I loved Immich UI on the whole but for organizing content I felt its quite restricted and I had to do a lot of things in bulk instead of opening each asset and doing it. Hence I built this tool (continuing to itereate) to make my life and any other Immich user's life easier. @@ -51,7 +57,11 @@ Refer here for obtaining Immich API Key: https://immich.app/docs/features/comman If you're using portainer, run the docker using `docker run` and add the power tools to the same network as immich. ```bash +# Run the power tools from docker docker run -d --name immich_power_tools -p 8001:3000 --env-file .env ghcr.io/varun-raj/immich-power-tools:latest + +# Add Power tools to the same network as immich +docker network connect immich_default immich_power_tools ``` @@ -90,7 +100,8 @@ bun run dev - [x] Manage People - [x] Smart Merge - [x] Manage Albums - - [ ] Bulk Delete + - [x] Bulk Delete + - [x] Bulk Share - [ ] Bulk Edit - [ ] Filters - [x] Potential Albums @@ -127,7 +138,6 @@ Google Gemini 1.5 Flash model is used for parsing your search query in "Find" pa > Code where Gemini is used: [src/helpers/gemini.helper.ts](./src/helpers/gemini.helper.ts) - ## Contributing Feel free to contribute to this project, I'm open to any suggestions and improvements. Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 3d7d6135e457cfe889195692a1e2d0a496bf5f04..9155ecc3b85bdc20453bb44d179830d4522c4b4d 100755 GIT binary patch delta 95249 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,N_N-WOB7I^VkOi(+xB-a&O`80XtA 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 delta 92881 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 void + readOnly?: boolean } interface IAlbumPerson { id: string name: string numberOfPhotos: number + } -export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) { +export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeopleProps) { const { exImmichUrl } = useConfig() const router = useRouter() const { query, pathname } = router @@ -117,51 +119,56 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) { {selectedPerson.name || "No Name"}
-
- { - router.push({ - pathname, - query: { - ...query, - faceId: mergedPerson.id - } - }) - }} /> - -
+ {!readOnly && ( +
+ { + router.push({ + pathname, + query: { + ...query, + faceId: mergedPerson.id + } + }) + }} /> + +
+ )}
)}

People

- - {!selectedPerson && ( - - )} - {selectedPeople.length > 0 && ( -
- -
+ {!readOnly && ( + <> + {!selectedPerson && ( + + )} + {selectedPeople.length > 0 && ( +
+ +
+ )} + )}
diff --git a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx index 6fc44f8..0e4752f 100644 --- a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx +++ b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx @@ -41,6 +41,7 @@ export default function PotentialAlbumsAssets() { width: p.exifImageWidth as number, height: p.exifImageHeight as number, isSelected: selectedIds.includes(p.id), + orientation: 1, tags: [ { title: "Immich Link", diff --git a/src/components/albums/share/AlbumShareDialog.tsx b/src/components/albums/share/AlbumShareDialog.tsx index e82ee73..03ea878 100644 --- a/src/components/albums/share/AlbumShareDialog.tsx +++ b/src/components/albums/share/AlbumShareDialog.tsx @@ -1,8 +1,11 @@ +import { AlertDialog } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { shareAlbums } from '@/handlers/api/album.handler'; +import { generateShareLink } from '@/handlers/api/shareLink.handler'; import { IAlbum } from '@/types/album'; import React, { ForwardedRef, forwardRef, useImperativeHandle, useState } from 'react' @@ -28,6 +31,10 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); const [generated, setGenerated] = useState(false); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [generatedLink, setGeneratedLink] = useState(null); + const [copied, setCopied] = useState(false); const handleGenerateShareLink = async () => { setGenerating(true); @@ -52,10 +59,32 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded }); } + const handleGenerateGlobalShareLink = async () => { + setLoading(true); + return generateShareLink({ + albumIds: selectedAlbums.map((album) => album.id), + }).then(({ link }) => { + setGeneratedLink(link); + }).catch((err) => { + setErrorMessage(err.message); + }).finally(() => { + setLoading(false); + }); + } const handleAllowPropertyChange = (albumId: string, property: string, checked: boolean) => { setSelectedAlbums((prevAlbums) => prevAlbums.map((album) => album.id === albumId ? { ...album, [property]: checked } : album)); } + const handleCopy = () => { + if (generatedLink) { + navigator.clipboard.writeText(generatedLink); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + } + } + useImperativeHandle(ref, () => ({ open: (selectedAlbums: IAlbum[]) => { @@ -109,23 +138,51 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded ))} + {generatedLink && ( +
+

Share links all generated

+
+ + +
+
+ + )} {generated ? ( <> -

Share links all generated

+

Share links all generated

) : ( <> - {generating ?
-

Generating share links...

-
:
- - -
} - + {generating ?
+

Generating share links...

+
: ( +
+ + + + + + +
+ )} + )} - +
diff --git a/src/components/assets/missing-location/MissingLocationAssets.tsx b/src/components/assets/missing-location/MissingLocationAssets.tsx index 5c6d81f..c8b36b7 100644 --- a/src/components/assets/missing-location/MissingLocationAssets.tsx +++ b/src/components/assets/missing-location/MissingLocationAssets.tsx @@ -56,6 +56,7 @@ export default function MissingLocationAssets({ groupBy }: IProps) { width: p.exifImageWidth as number, height: p.exifImageHeight as number, isSelected: selectedIds.includes(p.id), + orientation: 1, tags: [ { title: "Immich Link", diff --git a/src/components/layouts/RootLayout.tsx b/src/components/layouts/RootLayout.tsx index 8f56340..692fc9a 100644 --- a/src/components/layouts/RootLayout.tsx +++ b/src/components/layouts/RootLayout.tsx @@ -12,6 +12,8 @@ import { useConfig } from "@/contexts/ConfigContext"; import { queryClient } from "@/config/rQuery"; import { QueryClientProvider } from "react-query"; import { Toaster } from "react-hot-toast"; +import { useRouter } from "next/router"; +import PageLayout from "./PageLayout"; type RootLayoutProps = { children: ReactNode; @@ -20,6 +22,8 @@ type RootLayoutProps = { export default function RootLayout({ children }: RootLayoutProps) { const { immichURL, exImmichUrl } = useConfig(); + const router = useRouter(); + const { pathname } = router; const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [errorMessage, setErrorMessage] = useState<{ @@ -84,6 +88,17 @@ export default function RootLayout({ children }: RootLayoutProps) {
); + + if (pathname.startsWith("/s/")) { + return ( + +
+
{children}
+ +
+
+ ) + } if (!user) return ; return ( diff --git a/src/components/people/PersonItem.tsx b/src/components/people/PersonItem.tsx index cd19efa..facbbbd 100644 --- a/src/components/people/PersonItem.tsx +++ b/src/components/people/PersonItem.tsx @@ -11,6 +11,7 @@ import { useConfig } from "@/contexts/ConfigContext"; import { useToast } from "../ui/use-toast"; import { Badge } from "../ui/badge"; import { Button } from "@/components/ui/button"; +import ShareAssetsTrigger from "../shared/ShareAssetsTrigger"; interface IProps { person: IPerson; onRemove: (person: IPerson) => void; @@ -108,6 +109,7 @@ export default function PersonItem({ person, onRemove }: IProps) { }}> Hide +
{!editMode ? ( diff --git a/src/components/shared/AssetGrid.tsx b/src/components/shared/AssetGrid.tsx new file mode 100644 index 0000000..56625aa --- /dev/null +++ b/src/components/shared/AssetGrid.tsx @@ -0,0 +1,71 @@ +import "yet-another-react-lightbox/styles.css"; + +import { IAsset } from '@/types/asset'; +import React, { useMemo, useState } from 'react' +import Lightbox from 'yet-another-react-lightbox'; +import { Gallery } from "react-grid-gallery"; +import LazyGridImage from "../ui/lazy-grid-image"; +import Download from "yet-another-react-lightbox/plugins/download"; + + +interface AssetGridProps { + assets: IAsset[]; + isInternal?: boolean; + selectable?: boolean; +} + +export default function AssetGrid({ assets, isInternal = true, selectable = false }: AssetGridProps) { + const [index, setIndex] = useState(-1); + + const slides = useMemo(() => { + return assets.map((asset) => ({ + ...asset, + orientation: 1, + src: asset.previewUrl as string, + type: (asset.type === "VIDEO" ? "video" : "image") as any, + sources: + asset.type === "VIDEO" + ? [ + { + src: asset.downloadUrl as string, + type: "video/mp4", + }, + ] + : undefined, + height: asset.exifImageHeight as number, + width: asset.exifImageWidth as number, + downloadUrl: asset.downloadUrl as string, + })); + }, [assets]); + + console.log(slides); + + const images = useMemo(() => { + return assets.map((p) => ({ + ...p, + src: p.url as string, + original: p.previewUrl as string, + width: p.exifImageWidth / 10 as number, + height: p.exifImageHeight / 10 as number, + orientation: 1 + })); + }, [assets]); + + return ( +
+ = 0} + index={index} + close={() => setIndex(-1)} + /> + +
+ ); +} diff --git a/src/components/shared/ShareAssetsTrigger.tsx b/src/components/shared/ShareAssetsTrigger.tsx new file mode 100644 index 0000000..cfd8a5b --- /dev/null +++ b/src/components/shared/ShareAssetsTrigger.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react' +import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogTrigger, DialogDescription } from '../ui/dialog'; +import { Button, ButtonProps } from '../ui/button'; +import { ShareLinkFilters } from '@/types/shareLink'; +import { generateShareLink } from '@/handlers/api/shareLink.handler'; +import { Input } from '../ui/input'; +import { Label } from '@radix-ui/react-label'; + + +interface ShareAssetsTriggerProps { + filters: ShareLinkFilters + buttonProps?: ButtonProps +} + +export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssetsTriggerProps) { + const [generatedLink, setGeneratedLink] = useState(null); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [copied, setCopied] = useState(false); + + const handleGenerate = async () => { + setLoading(true); + return generateShareLink(filters).then(({ link }) => { + setGeneratedLink(link); + }).catch((err) => { + setErrorMessage(err.message); + }).finally(() => { + setLoading(false); + }); + } + + const handleCopy = () => { + if (generatedLink) { + navigator.clipboard.writeText(generatedLink); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + } + } + + + return ( + + + + + + + Share Assets + + + Share your assets with your friends and family. + + {errorMessage &&
{errorMessage}
} + {generatedLink ?
+ + +

+ This is a stateless link, it will not work if you leave the page. Which means it cannot be expired. +

+ +
: ( + + )} +
+
+ ) +} diff --git a/src/config/environment.ts b/src/config/environment.ts index d503f56..488dfc9 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -13,6 +13,8 @@ export const ENV = { VERSION: process.env.VERSION, GEMINI_API_KEY: process.env.GEMINI_API_KEY as string, GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY as string, + IMMICH_SHARE_LINK_KEY: process.env.IMMICH_SHARE_LINK_KEY as string, + POWER_TOOLS_ENDPOINT_URL: process.env.POWER_TOOLS_ENDPOINT_URL as string, }; diff --git a/src/config/routes.ts b/src/config/routes.ts index ca1db88..9495a1c 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -21,8 +21,10 @@ export const LIST_POTENTIAL_ALBUMS_DATES_PATH = BASE_API_ENDPOINT + "/albums/pot export const LIST_POTENTIAL_ALBUMS_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-assets"; export const LIST_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/list"; export const ALBUM_INFO_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/info"; + export const ALBUM_PEOPLE_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/people"; export const ALBUM_ASSETS_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/assets"; +export const ALBUM_ASSETS_PATH_PUBLIC = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/public-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"; @@ -38,6 +40,8 @@ export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/ export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id; export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap"; +export const ASSET_SHARE_THUMBNAIL_PATH = (id: string, size: string = "thumbnail", token: string) => BASE_PROXY_ENDPOINT + "/asset/share-thumbnail/" + id + "?size=" + size + "&token=" + token; + // Location export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places"; @@ -57,3 +61,9 @@ export const GET_PERSON_INFO = (personId: string) => BASE_API_ENDPOINT + "/peopl export const REWIND_STATS = BASE_API_ENDPOINT + "/rewind/stats"; // Find export const FIND_ASSETS = BASE_API_ENDPOINT + "/find/search"; + + +// Share Link +export const SHARE_LINK_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token; +export const SHARE_LINK_GENERATE_PATH = BASE_API_ENDPOINT + "/share-link/generate"; +export const SHARE_LINK_ASSETS_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/assets"; diff --git a/src/handlers/api/shareLink.handler.ts b/src/handlers/api/shareLink.handler.ts new file mode 100644 index 0000000..7cdeb87 --- /dev/null +++ b/src/handlers/api/shareLink.handler.ts @@ -0,0 +1,17 @@ + +import { SHARE_LINK_ASSETS_PATH, SHARE_LINK_GENERATE_PATH, SHARE_LINK_PATH } from "@/config/routes"; +import { cleanUpAssets } from "@/helpers/asset.helper"; +import API from "@/lib/api"; +import { ShareLinkFilters } from "@/types/shareLink"; + +export const getShareLinkInfo = async (token: string) => { + return API.get(SHARE_LINK_PATH(token)); +} + +export const getShareLinkAssets = async (token: string) => { + return API.get(SHARE_LINK_ASSETS_PATH(token)); +} + +export const generateShareLink = async (filters: ShareLinkFilters) => { + return API.post(SHARE_LINK_GENERATE_PATH, filters); +} \ No newline at end of file diff --git a/src/helpers/asset.helper.ts b/src/helpers/asset.helper.ts index b36bc3d..f85ed3e 100644 --- a/src/helpers/asset.helper.ts +++ b/src/helpers/asset.helper.ts @@ -1,6 +1,4 @@ -import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH, ASSET_VIDEO_PATH, PERSON_THUBNAIL_PATH } from "@/config/routes" -import { IPerson } from "@/types/person" -import { parseDate } from "./date.helper" +import { ASSET_PREVIEW_PATH, ASSET_SHARE_THUMBNAIL_PATH, ASSET_THUMBNAIL_PATH, ASSET_VIDEO_PATH, PERSON_THUBNAIL_PATH } from "@/config/routes" import { IAsset } from "@/types/asset" @@ -12,3 +10,31 @@ export const cleanUpAsset = (asset: IAsset): IAsset => { videoURL: ASSET_VIDEO_PATH(asset.id), } } + + +export const cleanUpShareAsset = (asset: IAsset, token: string): IAsset => { + return { + ...asset, + url: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "thumbnail", token), + downloadUrl: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "original", token), + previewUrl: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "preview", token), + videoURL: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "video", token), + } +} + +export const cleanUpAssets = (assets: IAsset[]): IAsset[] => { + return assets.map(cleanUpAsset); +} + +function isRotated90CW(orientation: number) { + return orientation === 5 || orientation === 6 || orientation === 90; +} + +function isRotated270CW(orientation: number) { + return orientation === 7 || orientation === 8 || orientation === -90; +} + +export function isFlipped(orientation?: string | null) { + const value = Number(orientation); + return value && (isRotated270CW(value) || isRotated90CW(value)); +} diff --git a/src/helpers/user.helper.ts b/src/helpers/user.helper.ts index fe805fd..6713b45 100644 --- a/src/helpers/user.helper.ts +++ b/src/helpers/user.helper.ts @@ -1,7 +1,11 @@ import { ENV } from "@/config/environment" import { IUser } from "@/types/user" -export const getUserHeaders = (user: IUser, otherHeaders?: { +export const getUserHeaders = (user: { + isUsingAPIKey?: boolean, + isUsingShareKey?: boolean, + accessToken?: string +}, otherHeaders?: { 'Content-Type': string }) => { let headers: { @@ -11,11 +15,13 @@ export const getUserHeaders = (user: IUser, otherHeaders?: { } = { 'Content-Type': 'application/json', } - if (user.isUsingAPIKey) { + if (user.isUsingShareKey) { + headers['x-api-key'] = ENV.IMMICH_SHARE_LINK_KEY + } else if (user.isUsingAPIKey) { headers['x-api-key'] = ENV.IMMICH_API_KEY - } - else { + } else { headers['Authorization'] = `Bearer ${user.accessToken}` } + return {...headers, ...otherHeaders} } \ No newline at end of file diff --git a/src/pages/api/albums/[id]/info.ts b/src/pages/api/albums/[id]/info.ts index a84c6fd..4b1f2c1 100644 --- a/src/pages/api/albums/[id]/info.ts +++ b/src/pages/api/albums/[id]/info.ts @@ -4,12 +4,10 @@ import { db } from "@/config/db"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { NextApiResponse } from "next"; import { albums } from "@/schema/albums.schema"; -import { count, desc, eq, min, max, sql, and } from "drizzle-orm"; +import { count, eq, min, max, sql, and } from "drizzle-orm"; import { assets } from "@/schema/assets.schema"; import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; -import { users } from "@/schema/users.schema"; import { assetFaces, exif, person } from "@/schema"; -import { IAlbum } from "@/types/album"; export default async function handler( req: NextApiRequest, diff --git a/src/pages/api/albums/[id]/public-info.ts b/src/pages/api/albums/[id]/public-info.ts new file mode 100644 index 0000000..33333ab --- /dev/null +++ b/src/pages/api/albums/[id]/public-info.ts @@ -0,0 +1,44 @@ +import { NextApiRequest } from "next"; + +import { db } from "@/config/db"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { NextApiResponse } from "next"; +import { albums } from "@/schema/albums.schema"; +import { count, eq, min, max, sql, and } from "drizzle-orm"; +import { assets } from "@/schema/assets.schema"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; +import { assetFaces, exif, person } from "@/schema"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + + const { id } = req.query as { id: string }; + + const dbAlbums = await db.selectDistinctOn([albums.id], { + id: albums.id, + albumName: albums.albumName, + createdAt: albums.createdAt, + updatedAt: albums.updatedAt, + albumThumbnailAssetId: albums.albumThumbnailAssetId, + assetCount: count(assets.id), + firstPhotoDate: min(exif.dateTimeOriginal), + lastPhotoDate: max(exif.dateTimeOriginal), + faceCount: count(sql`DISTINCT ${person.id}`), // Ensure unique personId + }) + .from(albums) + .leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId)) + .leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id)) + .leftJoin(exif, and(eq(assets.id, exif.assetId), eq(assets.isVisible, true))) + .leftJoin(assetFaces, eq(assets.id, assetFaces.assetId)) + .leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false))) + .where(and(eq(albums.id, id))) + .groupBy(albums.id) + .limit(1); + + if (dbAlbums.length === 0) { + return res.status(404).json({ error: "Album not found" }); + } + res.status(200).json(dbAlbums[0]); +} \ No newline at end of file diff --git a/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts b/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts new file mode 100644 index 0000000..182a077 --- /dev/null +++ b/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts @@ -0,0 +1,63 @@ +// pages/api/proxy.js + +import { ENV } from '@/config/environment'; +import { getUserHeaders } from '@/helpers/user.helper'; +import { verify } from 'jsonwebtoken'; +import { NextApiRequest, NextApiResponse } from 'next' + +export const config = { + api: { + bodyParser: false, + }, +} + + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ message: 'Method Not Allowed' }) + } + + const { id, size, token } = req.query; + if (!token) { + return res.status(401).json({ message: 'Unauthorized' }) + } + + try { + verify(token as string, ENV.JWT_SECRET); + } catch (error) { + return res.status(401).json({ message: 'Token is invalid' }) + } + + let targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/thumbnail?size=${size || 'thumbnail'}`; + if (size === "original") { + targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/original`; + } + + try { + // Forward the request to the target API + const response = await fetch(targetUrl, { + method: 'GET', + headers: getUserHeaders({ isUsingShareKey: true }, { + 'Content-Type': 'application/octet-stream', + }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error("Error fetching thumbnail " + error.message) + } + + // Get the image data from the response + const imageBuffer = await response.arrayBuffer() + + // Set the appropriate headers for the image response + res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png') + res.setHeader('Content-Length', imageBuffer.byteLength) + + // Send the image data + res.send(Buffer.from(imageBuffer)) + } catch (error: any) { + res.redirect("https://placehold.co/400") + console.error('Error:', error) + } +} \ No newline at end of file diff --git a/src/pages/api/immich-proxy/asset/thumbnail/[id].ts b/src/pages/api/immich-proxy/asset/thumbnail/[id].ts index aa29165..222ea7c 100644 --- a/src/pages/api/immich-proxy/asset/thumbnail/[id].ts +++ b/src/pages/api/immich-proxy/asset/thumbnail/[id].ts @@ -20,7 +20,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/thumbnail?size=${size || 'thumbnail'}`; const user = await getCurrentUser(req); - if (!user) { return res.status(403).json({ message: 'Unauthorized' }) } diff --git a/src/pages/api/share-link/[token].ts b/src/pages/api/share-link/[token].ts new file mode 100644 index 0000000..37cfe57 --- /dev/null +++ b/src/pages/api/share-link/[token].ts @@ -0,0 +1,89 @@ +import { db } from "@/config/db"; +import { ENV } from "@/config/environment"; +import { desc, gte, lte } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; +import { assetFaces, assets, exif } from "@/schema"; +import { and } from "drizzle-orm"; +import { sign, verify } from "jsonwebtoken"; +import { NextApiResponse } from "next"; + +import { NextApiRequest } from "next"; +import { albums } from "@/schema/albums.schema"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; +import { cleanUpAsset, cleanUpAssets, cleanUpShareAsset, isFlipped } from "@/helpers/asset.helper"; +import { IAsset } from "@/types/asset"; +import { parseDate } from "@/helpers/date.helper"; + +interface ShareLinkFilters { + personIds: string[]; + albumIds: string[]; + startDate: string; + endDate: string; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { token } = req.query; + if (!token) { + return res.status(404).json({ message: "Token not found" }); + } + + const decoded = verify(token as string, ENV.JWT_SECRET); + + if (!decoded) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { + expiresIn: "2m" + }); + const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters; + + const dbAssets = 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, + url: assets.id, + previewUrl: assets.id, + videoURL: assets.id, + orientation: exif.orientation, + }).from(assets) + .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) + .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) + .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) + .innerJoin(exif, eq(exif.assetId, assets.id)) + .where(and( + personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined, + albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, + startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, + endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, + eq(assets.isArchived, false), + eq(assets.isVisible, true), + eq(assets.isOffline, false), + )) + .orderBy(desc(assets.createdAt)); + + const cleanedAssets = dbAssets.map((asset) => { + return { + ...asset, + localDateTime: new Date(asset.localDateTime), + duration: asset.duration ? parseInt(asset.duration) : 0, + exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0, + exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0, + dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(), + } + }).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string)); + + return res.status(200).json(cleanedAssets); +} \ No newline at end of file diff --git a/src/pages/api/share-link/generate.ts b/src/pages/api/share-link/generate.ts new file mode 100644 index 0000000..7adb506 --- /dev/null +++ b/src/pages/api/share-link/generate.ts @@ -0,0 +1,24 @@ + +import { ENV } from "@/config/environment"; +import { SHARE_LINK_ASSETS_PATH } from "@/config/routes"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { sign } from "jsonwebtoken"; +import { NextApiRequest, NextApiResponse } from "next"; + + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const currentUser = await getCurrentUser(req); + const filters = req.body; + + if (!currentUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + if (!ENV.IMMICH_SHARE_LINK_KEY) { + return res.status(401).json({ message: "Unauthorized" }); + } + + console.log(filters); + const token = sign(filters, ENV.JWT_SECRET); + return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` }); +} \ No newline at end of file diff --git a/src/pages/s/[token].tsx b/src/pages/s/[token].tsx new file mode 100644 index 0000000..4dd0f1f --- /dev/null +++ b/src/pages/s/[token].tsx @@ -0,0 +1,73 @@ +import PageLayout from '@/components/layouts/PageLayout' +import Header from '@/components/shared/Header' +import Loader from '@/components/ui/loader' +import { useConfig } from '@/contexts/ConfigContext' +import { IAlbum } from '@/types/album' +import Link from 'next/link' +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import AlbumPeople from '@/components/albums/info/AlbumPeople' +import AlbumImages from '@/components/albums/info/AlbumImages' +import { Camera, ExternalLink, Users } from 'lucide-react' +import { humanizeNumber } from '@/helpers/string.helper' +import { getShareLinkInfo } from '@/handlers/api/shareLink.handler' +import AssetGrid from '@/components/shared/AssetGrid' +import { IAsset } from '@/types/asset' + +export default function AlbumListPage() { + const router = useRouter() + const { token } = router.query as { token: string } + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + const fetchAlbumInfo = async () => { + setLoading(true) + getShareLinkInfo(token) + .then(setAssets) + .catch((error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setLoading(false) + }) + } + + useEffect(() => { + fetchAlbumInfo() + }, []) + + const renderContent = () => { + + if (loading) { + return + } + else if (errorMessage) { + return
{errorMessage}
+ } + return ( + + ) + } + return ( + +
+

+ {humanizeNumber(assets.length || 0)} + +

+
+ ) + } + /> + {renderContent()} + + ) +} diff --git a/src/styles/globals.scss b/src/styles/globals.scss index f8be68f..fc4c145 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -88,3 +88,8 @@ } } + +#ReactGridGallery img { + max-width: none !important; + height: none !important; +} \ No newline at end of file diff --git a/src/types/asset.d.ts b/src/types/asset.d.ts index 545dfc5..71af4aa 100644 --- a/src/types/asset.d.ts +++ b/src/types/asset.d.ts @@ -5,19 +5,19 @@ export interface IAsset { type: string; originalPath: string; isFavorite: boolean; - duration: null; - encodedVideoPath: string; + duration: number | null; + encodedVideoPath: string | null; originalFileName: string; - sidecarPath: null; - thumbhash: IAssetThumbhash; - deletedAt: null; - localDateTime: string; + thumbhash?: IAssetThumbhash; + localDateTime: string | Date; exifImageWidth: number; exifImageHeight: number; url: string; previewUrl: string; videoURL?: string; dateTimeOriginal: string; + orientation?: number | null | string; + downloadUrl?: string; } export interface IAssetThumbhash { diff --git a/src/types/shareLink.d.ts b/src/types/shareLink.d.ts new file mode 100644 index 0000000..3e4a40d --- /dev/null +++ b/src/types/shareLink.d.ts @@ -0,0 +1,6 @@ +export interface ShareLinkFilters { + personIds?: string[]; + albumIds?: string[]; + startDate?: string; + endDate?: string; +} \ No newline at end of file From 1fea9ccaeec464efe7c643f2d376c09093708c39 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sat, 18 Jan 2025 13:41:35 +0530 Subject: [PATCH 10/22] feat: people selection in share link Signed-off-by: Varun Raj --- src/components/albums/info/AlbumPeople.tsx | 1 - .../albums/share/AlbumShareDialog.tsx | 11 +- src/components/shared/AssetGrid.tsx | 2 - src/components/shared/PeopleList.tsx | 48 +++++++ src/components/shared/ShareAssetsTrigger.tsx | 25 +++- src/config/routes.ts | 5 +- src/handlers/api/shareLink.handler.ts | 16 ++- src/helpers/asset.helper.ts | 8 +- .../asset/share-thumbnail/[id].ts | 10 +- .../{[token].ts => [token]/assets.ts} | 44 +++++-- src/pages/api/share-link/[token]/index.ts | 21 ++++ src/pages/api/share-link/[token]/people.ts | 72 +++++++++++ src/pages/api/share-link/generate.ts | 2 +- src/pages/assets/geo-heatmap.tsx | 1 - src/pages/s/[token].tsx | 119 ++++++++++++++---- src/types/shareLink.d.ts | 1 + 16 files changed, 320 insertions(+), 66 deletions(-) create mode 100644 src/components/shared/PeopleList.tsx rename src/pages/api/share-link/{[token].ts => [token]/assets.ts} (71%) create mode 100644 src/pages/api/share-link/[token]/index.ts create mode 100644 src/pages/api/share-link/[token]/people.ts diff --git a/src/components/albums/info/AlbumPeople.tsx b/src/components/albums/info/AlbumPeople.tsx index 3610c07..cd4dc33 100644 --- a/src/components/albums/info/AlbumPeople.tsx +++ b/src/components/albums/info/AlbumPeople.tsx @@ -195,7 +195,6 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr /> )} - - - + album.id) }} /> )} diff --git a/src/components/shared/AssetGrid.tsx b/src/components/shared/AssetGrid.tsx index 56625aa..2405c10 100644 --- a/src/components/shared/AssetGrid.tsx +++ b/src/components/shared/AssetGrid.tsx @@ -38,8 +38,6 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals })); }, [assets]); - console.log(slides); - const images = useMemo(() => { return assets.map((p) => ({ ...p, diff --git a/src/components/shared/PeopleList.tsx b/src/components/shared/PeopleList.tsx new file mode 100644 index 0000000..8a6084a --- /dev/null +++ b/src/components/shared/PeopleList.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react' +import { IPerson } from '@/types/person'; +import { cn } from '@/lib/utils'; +import LazyImage from '../ui/lazy-image'; +import { PERSON_THUBNAIL_PATH } from '@/config/routes'; + +interface PeopleListProps { + people: IPerson[]; + onSelect: (person: IPerson) => void; + selectedIds?: string[]; +} + +export default function PeopleList({ people, onSelect, selectedIds }: PeopleListProps) { + + const isSelected = useCallback((person: IPerson) => { + return selectedIds?.includes(person.id) + }, [selectedIds]) + + return ( +
+ {people.map((person) => ( +
onSelect(person)} + > + +
+

{person.name || "No Name"}

+

{person.assetCount} photos

+
+
+ ))} +
+ ) +} diff --git a/src/components/shared/ShareAssetsTrigger.tsx b/src/components/shared/ShareAssetsTrigger.tsx index cfd8a5b..846f047 100644 --- a/src/components/shared/ShareAssetsTrigger.tsx +++ b/src/components/shared/ShareAssetsTrigger.tsx @@ -5,6 +5,8 @@ import { ShareLinkFilters } from '@/types/shareLink'; import { generateShareLink } from '@/handlers/api/shareLink.handler'; import { Input } from '../ui/input'; import { Label } from '@radix-ui/react-label'; +import { Checkbox } from '../ui/checkbox'; +import { Switch } from '../ui/switch'; interface ShareAssetsTriggerProps { @@ -17,10 +19,11 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [copied, setCopied] = useState(false); + const [config, setConfig] = useState>({}); const handleGenerate = async () => { setLoading(true); - return generateShareLink(filters).then(({ link }) => { + return generateShareLink({ ...filters, ...config }).then(({ link }) => { setGeneratedLink(link); }).catch((err) => { setErrorMessage(err.message); @@ -50,7 +53,7 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets Share Assets - Share your assets with your friends and family. + Generate a share link for the selected assets (either bunch of albums or bunch of people) and share it with your friends and family. {errorMessage &&
{errorMessage}
} {generatedLink ?
@@ -59,9 +62,23 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets

This is a stateless link, it will not work if you leave the page. Which means it cannot be expired.

- +
+ + +
: ( - +
+
+
+ +

+ Show the list of people in the shared photos +

+
+ setConfig({ ...config, p: !!checked })} /> +
+ +
)}
diff --git a/src/config/routes.ts b/src/config/routes.ts index 9495a1c..fa90722 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -40,7 +40,9 @@ export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/ export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id; export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap"; -export const ASSET_SHARE_THUMBNAIL_PATH = (id: string, size: string = "thumbnail", token: string) => BASE_PROXY_ENDPOINT + "/asset/share-thumbnail/" + id + "?size=" + size + "&token=" + token; +export const ASSET_SHARE_THUMBNAIL_PATH = ( + { id, size, token, isPeople }: { id: string, size: string, token: string, isPeople: boolean } +) => BASE_PROXY_ENDPOINT + "/asset/share-thumbnail/" + id + "?size=" + size + "&token=" + token + "&p=" + isPeople; // Location @@ -67,3 +69,4 @@ export const FIND_ASSETS = BASE_API_ENDPOINT + "/find/search"; export const SHARE_LINK_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token; export const SHARE_LINK_GENERATE_PATH = BASE_API_ENDPOINT + "/share-link/generate"; export const SHARE_LINK_ASSETS_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/assets"; +export const SHARE_LINK_PEOPLE_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/people"; \ No newline at end of file diff --git a/src/handlers/api/shareLink.handler.ts b/src/handlers/api/shareLink.handler.ts index 7cdeb87..a6886a9 100644 --- a/src/handlers/api/shareLink.handler.ts +++ b/src/handlers/api/shareLink.handler.ts @@ -1,6 +1,10 @@ -import { SHARE_LINK_ASSETS_PATH, SHARE_LINK_GENERATE_PATH, SHARE_LINK_PATH } from "@/config/routes"; -import { cleanUpAssets } from "@/helpers/asset.helper"; +import { + SHARE_LINK_ASSETS_PATH, + SHARE_LINK_GENERATE_PATH, + SHARE_LINK_PATH, + SHARE_LINK_PEOPLE_PATH +} from "@/config/routes"; import API from "@/lib/api"; import { ShareLinkFilters } from "@/types/shareLink"; @@ -8,10 +12,14 @@ export const getShareLinkInfo = async (token: string) => { return API.get(SHARE_LINK_PATH(token)); } -export const getShareLinkAssets = async (token: string) => { - return API.get(SHARE_LINK_ASSETS_PATH(token)); +export const getShareLinkAssets = async (token: string, filters: ShareLinkFilters) => { + return API.get(SHARE_LINK_ASSETS_PATH(token), filters); } export const generateShareLink = async (filters: ShareLinkFilters) => { return API.post(SHARE_LINK_GENERATE_PATH, filters); +} + +export const getShareLinkPeople = async (token: string) => { + return API.get(SHARE_LINK_PEOPLE_PATH(token)); } \ No newline at end of file diff --git a/src/helpers/asset.helper.ts b/src/helpers/asset.helper.ts index f85ed3e..706ec50 100644 --- a/src/helpers/asset.helper.ts +++ b/src/helpers/asset.helper.ts @@ -15,10 +15,10 @@ export const cleanUpAsset = (asset: IAsset): IAsset => { export const cleanUpShareAsset = (asset: IAsset, token: string): IAsset => { return { ...asset, - url: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "thumbnail", token), - downloadUrl: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "original", token), - previewUrl: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "preview", token), - videoURL: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "video", token), + url: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "thumbnail", token, isPeople: false }), + downloadUrl: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "original", token, isPeople: false }), + previewUrl: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "preview", token, isPeople: false }), + videoURL: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "video", token, isPeople: false }), } } diff --git a/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts b/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts index 182a077..4fa6c77 100644 --- a/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts +++ b/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts @@ -17,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(405).json({ message: 'Method Not Allowed' }) } - const { id, size, token } = req.query; + const { id, size, token, p } = req.query; if (!token) { return res.status(401).json({ message: 'Unauthorized' }) } @@ -28,10 +28,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: 'Token is invalid' }) } - let targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/thumbnail?size=${size || 'thumbnail'}`; - if (size === "original") { - targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/original`; - } + const resource = p === "true" ? "people" : "assets"; + const baseURL = `${ENV.IMMICH_URL}/api/${resource}/${id}`; + const version = size === "original" ? "original" : "thumbnail"; + let targetUrl = `${baseURL}/${version}?size=${size}`; try { // Forward the request to the target API diff --git a/src/pages/api/share-link/[token].ts b/src/pages/api/share-link/[token]/assets.ts similarity index 71% rename from src/pages/api/share-link/[token].ts rename to src/pages/api/share-link/[token]/assets.ts index 37cfe57..28621ad 100644 --- a/src/pages/api/share-link/[token].ts +++ b/src/pages/api/share-link/[token]/assets.ts @@ -2,7 +2,7 @@ import { db } from "@/config/db"; import { ENV } from "@/config/environment"; import { desc, gte, lte } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm"; -import { assetFaces, assets, exif } from "@/schema"; +import { assetFaces, assets, exif, person } from "@/schema"; import { and } from "drizzle-orm"; import { sign, verify } from "jsonwebtoken"; import { NextApiResponse } from "next"; @@ -10,19 +10,21 @@ import { NextApiResponse } from "next"; import { NextApiRequest } from "next"; import { albums } from "@/schema/albums.schema"; import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; -import { cleanUpAsset, cleanUpAssets, cleanUpShareAsset, isFlipped } from "@/helpers/asset.helper"; -import { IAsset } from "@/types/asset"; -import { parseDate } from "@/helpers/date.helper"; +import { cleanUpShareAsset, isFlipped } from "@/helpers/asset.helper"; +import { ShareLinkFilters } from "@/types/shareLink"; -interface ShareLinkFilters { - personIds: string[]; - albumIds: string[]; - startDate: string; - endDate: string; +interface IQuery extends ShareLinkFilters { + token: string + ['personIds[]']?: string[] } export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { token } = req.query; + const { token, ...rest } = req.query as any as IQuery + + const filters = { + personIds: rest['personIds[]'] ?? rest.personIds ?? [], + } + if (!token) { return res.status(404).json({ message: "Token not found" }); } @@ -36,8 +38,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { expiresIn: "2m" }); - const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters; + + const { personIds = [], albumIds = [], startDate, endDate } = decoded as ShareLinkFilters; + let filteredPersonIds: string[] = personIds ?? []; + const queryPersonIds = filters.personIds ? (Array.isArray(filters.personIds) ? filters.personIds : [filters.personIds]) : []; + + if (queryPersonIds.length > 0) { + + if (personIds.length === 0) { + filteredPersonIds = queryPersonIds; + } else { + filteredPersonIds = queryPersonIds.filter((id) => personIds.includes(id)); + } + } + const dbAssets = await db.select({ id: assets.id, deviceId: assets.deviceId, @@ -64,7 +79,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) .innerJoin(exif, eq(exif.assetId, assets.id)) .where(and( - personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined, + filteredPersonIds?.length > 0 ? inArray(assetFaces.personId, filteredPersonIds) : undefined, albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, @@ -85,5 +100,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } }).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string)); - return res.status(200).json(cleanedAssets); + return res.status(200).json({ + assets: cleanedAssets, + filters: decoded as ShareLinkFilters + }); } \ No newline at end of file diff --git a/src/pages/api/share-link/[token]/index.ts b/src/pages/api/share-link/[token]/index.ts new file mode 100644 index 0000000..7ec9be4 --- /dev/null +++ b/src/pages/api/share-link/[token]/index.ts @@ -0,0 +1,21 @@ +import { NextApiResponse } from "next"; + +import { ENV } from "@/config/environment"; +import { ShareLinkFilters } from "@/types/shareLink"; +import { verify } from "jsonwebtoken"; +import { NextApiRequest } from "next"; + +export default async function GET(req: NextApiRequest, res: NextApiResponse) { + const { token } = req.query; + if (!token) { + return res.status(404).json({ message: "Token not found" }); + } + + const decoded = verify(token as string, ENV.JWT_SECRET); + + if (!decoded) { + return res.status(401).json({ message: "Unauthorized" }); + } + + return res.status(200).json(decoded); +} diff --git a/src/pages/api/share-link/[token]/people.ts b/src/pages/api/share-link/[token]/people.ts new file mode 100644 index 0000000..418f9b5 --- /dev/null +++ b/src/pages/api/share-link/[token]/people.ts @@ -0,0 +1,72 @@ +import { db } from "@/config/db"; +import { ENV } from "@/config/environment"; +import { count, desc, gte, isNotNull, lte, ne } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; +import { assetFaces, assets, exif, person } from "@/schema"; +import { and } from "drizzle-orm"; +import { sign, verify } from "jsonwebtoken"; +import { NextApiResponse } from "next"; + +import { NextApiRequest } from "next"; +import { albums } from "@/schema/albums.schema"; +import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; +import { ASSET_SHARE_THUMBNAIL_PATH } from "@/config/routes"; + +interface ShareLinkFilters { + personIds: string[]; + albumIds: string[]; + startDate: string; + endDate: string; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { token } = req.query; + if (!token) { + return res.status(404).json({ message: "Token not found" }); + } + + const decoded = verify(token as string, ENV.JWT_SECRET); + + if (!decoded) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { + expiresIn: "2m" + }); + const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters; + + const dbPeople = await db.select({ + id: person.id, + name: person.name, + thumbnailAssetId: person.faceAssetId, + assetCount: count(assets.id), + }).from(assets) + .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) + .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) + .innerJoin(person, eq(assetFaces.personId, person.id)) + .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) + .innerJoin(exif, eq(exif.assetId, assets.id)) + .where(and( + personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined, + albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, + startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, + endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, + eq(assets.isArchived, false), + eq(assets.isVisible, true), + eq(assets.isOffline, false), + isNotNull(person.id), + ne(person.name, "") + )) + .groupBy(person.id) + .orderBy(desc(count(assets.id))); + + const cleanedPeople = dbPeople.map((person) => { + return { + ...person, + thumbnailPath: person.thumbnailAssetId ? ASSET_SHARE_THUMBNAIL_PATH({ id: person.id, size: "thumbnail", token: newPreviewToken, isPeople: true }) : null + } + }) + + return res.status(200).json(cleanedPeople); +} \ No newline at end of file diff --git a/src/pages/api/share-link/generate.ts b/src/pages/api/share-link/generate.ts index 7adb506..5c4affb 100644 --- a/src/pages/api/share-link/generate.ts +++ b/src/pages/api/share-link/generate.ts @@ -10,6 +10,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const currentUser = await getCurrentUser(req); const filters = req.body; + console.log(filters); if (!currentUser) { return res.status(401).json({ message: "Unauthorized" }); } @@ -18,7 +19,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "Unauthorized" }); } - console.log(filters); const token = sign(filters, ENV.JWT_SECRET); return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` }); } \ No newline at end of file diff --git a/src/pages/assets/geo-heatmap.tsx b/src/pages/assets/geo-heatmap.tsx index 44a9136..fc1339c 100644 --- a/src/pages/assets/geo-heatmap.tsx +++ b/src/pages/assets/geo-heatmap.tsx @@ -28,7 +28,6 @@ const HeatMap = () => { if (!heatmap) return; getAssetGeoHeatmap().then((data) => { - console.log(data); heatmap.setData(data.map(([lng, lat]: [number, number]) => ({ location: new google.maps.LatLng(lat, lng), weight: 10 diff --git a/src/pages/s/[token].tsx b/src/pages/s/[token].tsx index 4dd0f1f..410ed41 100644 --- a/src/pages/s/[token].tsx +++ b/src/pages/s/[token].tsx @@ -1,30 +1,81 @@ import PageLayout from '@/components/layouts/PageLayout' import Header from '@/components/shared/Header' import Loader from '@/components/ui/loader' -import { useConfig } from '@/contexts/ConfigContext' -import { IAlbum } from '@/types/album' -import Link from 'next/link' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/router' -import AlbumPeople from '@/components/albums/info/AlbumPeople' -import AlbumImages from '@/components/albums/info/AlbumImages' -import { Camera, ExternalLink, Users } from 'lucide-react' +import { Camera } from 'lucide-react' import { humanizeNumber } from '@/helpers/string.helper' -import { getShareLinkInfo } from '@/handlers/api/shareLink.handler' +import { getShareLinkAssets, getShareLinkInfo, getShareLinkPeople } from '@/handlers/api/shareLink.handler' import AssetGrid from '@/components/shared/AssetGrid' import { IAsset } from '@/types/asset' +import { IPerson } from '@/types/person' +import PeopleList from '@/components/shared/PeopleList' +import { ShareLinkFilters } from '@/types/shareLink' +import clsx from 'clsx' export default function AlbumListPage() { const router = useRouter() - const { token } = router.query as { token: string } + const { pathname, query } = router + const { token, personIds } = query as { token: string, personIds: string[] } const [assets, setAssets] = useState([]) const [loading, setLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') + const [people, setPeople] = useState([]) + const [peopleLoading, setPeopleLoading] = useState(false) + const [config, setConfig] = useState({}) + const [filters, setFilters] = useState(null) + + + const handleSelectPerson = (person: IPerson) => { + const personIds = query.personIds as string[] + let currentPersonIds = personIds + if (!personIds) { + currentPersonIds = [] + } else { + currentPersonIds = Array.isArray(personIds) ? personIds : [personIds] + } + const isPersonSelected = currentPersonIds.includes(person.id); + const newPersonIds = isPersonSelected ? currentPersonIds.filter((id) => id !== person.id) : [...currentPersonIds, person.id]; + + router.push({ + pathname: pathname, + query: { + ...query, + personIds: newPersonIds + } + }) + setFilters({ + personIds: newPersonIds + }) + } + + const fetchAssets = async () => { + if (!filters) return + setLoading(true) + getShareLinkAssets(token, filters) + .then(({ assets }) => { + setAssets(assets) + }) + .catch((error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setLoading(false) + }) + } const fetchAlbumInfo = async () => { setLoading(true) getShareLinkInfo(token) - .then(setAssets) + .then((conf) => { + setConfig(conf) + if (conf.p) { + fetchPeople() + } + setFilters({ + personIds: query.personIds as string[] + }) + }) .catch((error) => { setErrorMessage(error.message) }) @@ -33,23 +84,49 @@ export default function AlbumListPage() { }) } + const fetchPeople = async () => { + setPeopleLoading(true) + getShareLinkPeople(token) + .then(setPeople) + .catch((error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setPeopleLoading(false) + }) + } + useEffect(() => { fetchAlbumInfo() }, []) - const renderContent = () => { + useEffect(() => { + if (!filters) return + fetchAssets() + }, [filters]) - if (loading) { - return - } - else if (errorMessage) { - return
{errorMessage}
- } + const renderContent = () => { + if (loading) return + else if (errorMessage) return
{errorMessage}
return ( - +
+ {config.p && ( +
+ {peopleLoading ? : + } +
+ )} +
+ {loading ? : } +
+
) } return ( diff --git a/src/types/shareLink.d.ts b/src/types/shareLink.d.ts index 3e4a40d..23498d6 100644 --- a/src/types/shareLink.d.ts +++ b/src/types/shareLink.d.ts @@ -3,4 +3,5 @@ export interface ShareLinkFilters { albumIds?: string[]; startDate?: string; endDate?: string; + p?: boolean; } \ No newline at end of file From 29f80b8cd981f430bb4ffa6edd94fff2dce3cfdd Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sat, 18 Jan 2025 14:07:13 +0530 Subject: [PATCH 11/22] feat: people search added Signed-off-by: Varun Raj --- src/components/people/PeopleFilters.tsx | 11 ++++++++++- src/handlers/api/people.handler.ts | 1 + src/pages/api/people/list.ts | 16 +++++++++------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/people/PeopleFilters.tsx b/src/components/people/PeopleFilters.tsx index b73c705..66e8205 100644 --- a/src/components/people/PeopleFilters.tsx +++ b/src/components/people/PeopleFilters.tsx @@ -19,7 +19,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". export function PeopleFilters() { const router = useRouter(); - const { updateContext, page, maximumAssetCount, type = "all" } = usePeopleFilterContext(); + const { updateContext, page, maximumAssetCount, type = "all", query ="" } = usePeopleFilterContext(); const handleChange = (data: Partial) => { updateContext(data); @@ -40,6 +40,15 @@ export function PeopleFilters() { return (
+ { + handleChange({ query: e.target.value }); + }} + /> => { return API.get(LIST_PEOPLE_PATH, filters).then((response) => { diff --git a/src/pages/api/people/list.ts b/src/pages/api/people/list.ts index 62d9e59..4dabcd0 100644 --- a/src/pages/api/people/list.ts +++ b/src/pages/api/people/list.ts @@ -1,8 +1,6 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import { CHART_COLORS } from "@/config/constants/chart.constant"; import { db } from "@/config/db"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; -import { stringToBoolean } from "@/helpers/data.helper"; import { assetFaces, assets, exif, person } from "@/schema"; import { and, @@ -11,6 +9,7 @@ import { desc, eq, gte, + ilike, isNotNull, isNull, lte, @@ -29,6 +28,7 @@ interface IQuery { maximumAssetCount: number; sort: ISortField; sortOrder: "asc" | "desc"; + query: string; } export default async function handler( req: NextApiRequest, @@ -42,6 +42,7 @@ export default async function handler( sort = "assetCount", sortOrder = "desc", type = "all", + query = "", } = req.query as any as IQuery; const currentUser = await getCurrentUser(req); @@ -53,10 +54,11 @@ export default async function handler( eq(assets.isVisible, true), eq(assets.isArchived, false), eq(assets.ownerId, currentUser.id), - type === "all" ? undefined : (type === "nameless" ? eq(person.name, "") : ne(person.name, "")) + type === "all" ? undefined : (type === "nameless" ? eq(person.name, "") : ne(person.name, "")), + query && query.length > 0 ? ilike(person.name, `%${query}%`) : undefined ); - let query = db + let dbQuery = db .select({ id: person.id, name: person.name, @@ -74,17 +76,17 @@ export default async function handler( let sortedQuery; if (sort === "assetCount") { - sortedQuery = query.orderBy( + sortedQuery = dbQuery.orderBy( sortOrder === "asc" ? asc(count(assetFaces.id)) : desc(count(assetFaces.id)) ); } else if (sort === "updatedAt") { - sortedQuery = query.orderBy( + sortedQuery = dbQuery.orderBy( sortOrder === "asc" ? asc(person.updatedAt) : desc(person.updatedAt) ); } - const people = await query.limit(perPage).offset((page - 1) * perPage); + const people = await dbQuery.limit(perPage).offset((page - 1) * perPage); return res.status(200).json({ people, From 098701b828644d42af2592aca41bd2dae85f1f19 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sat, 18 Jan 2025 14:21:12 +0530 Subject: [PATCH 12/22] feat: option to delete assets from missing locations page Signed-off-by: Varun Raj --- .../TagMissingLocationDialog.tsx | 2 +- src/components/shared/FloatingBar.tsx | 2 +- src/config/routes.ts | 1 - src/handlers/api/asset.handler.ts | 8 +++--- .../api/assets/missing-location-assets.ts | 27 ++++++++++++++++--- .../api/assets/missing-location-dates.ts | 2 ++ src/pages/assets/missing-locations.tsx | 24 ++++++++++++++++- 7 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx index 86856cf..af77728 100644 --- a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx +++ b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx @@ -36,7 +36,7 @@ export default function TagMissingLocationDialog({ return ( - + diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx index 83d89db..45c8cc9 100644 --- a/src/components/shared/FloatingBar.tsx +++ b/src/components/shared/FloatingBar.tsx @@ -8,7 +8,7 @@ interface FloatingBarProps { export default function FloatingBar({ children }: FloatingBarProps) { return (
-
+
{children}
diff --git a/src/config/routes.ts b/src/config/routes.ts index fa90722..d92ef88 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -34,7 +34,6 @@ export const LIST_MISSING_LOCATION_DATES_PATH = BASE_API_ENDPOINT + "/assets/mis 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"; - export const ASSET_THUMBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id; export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id + "?size=preview"; export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id; diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index 2b86b79..4ea921a 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -1,8 +1,6 @@ import { - ADD_ASSETS_ALBUMS_PATH, ASSET_GEO_HEATMAP_PATH, FIND_ASSETS, - LIST_ALBUMS_PATH, LIST_MISSING_LOCATION_ALBUMS_PATH, LIST_MISSING_LOCATION_ASSETS_PATH, LIST_MISSING_LOCATION_DATES_PATH, @@ -65,4 +63,8 @@ export const findAssets = async (query: string) => { export const getAssetGeoHeatmap = async () => { return API.get(ASSET_GEO_HEATMAP_PATH); -} \ No newline at end of file +} + +export const deleteAssets = async (ids: string[]) => { + return API.delete(UPDATE_ASSETS_PATH, { ids }); +} \ No newline at end of file diff --git a/src/pages/api/assets/missing-location-assets.ts b/src/pages/api/assets/missing-location-assets.ts index 5d74468..20e391a 100644 --- a/src/pages/api/assets/missing-location-assets.ts +++ b/src/pages/api/assets/missing-location-assets.ts @@ -1,6 +1,7 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction import { db } from "@/config/db"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { isFlipped } from "@/helpers/asset.helper"; import { parseDate } from "@/helpers/date.helper"; import { assets, exif } from "@/schema"; import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; @@ -28,6 +29,7 @@ const getRowsByDates = async (startDateDate: Date, endDateDate: Date, currentUse exifImageHeight: exif.exifImageHeight, ownerId: assets.ownerId, dateTimeOriginal: exif.dateTimeOriginal, + orientation: exif.orientation, }) .from(assets) .leftJoin(exif, eq(exif.assetId, assets.id)) @@ -37,6 +39,9 @@ const getRowsByDates = async (startDateDate: Date, endDateDate: Date, currentUse gte(exif.dateTimeOriginal, startDateDate), lte(exif.dateTimeOriginal, endDateDate), eq(assets.ownerId, currentUser.id), + eq(assets.isVisible, true), + eq(assets.isArchived, false), + isNull(assets.deletedAt), )); } @@ -57,6 +62,7 @@ const getRowsByAlbums = async (currentUser: IUser, albumId: string) => { exifImageHeight: exif.exifImageHeight, ownerId: assets.ownerId, dateTimeOriginal: exif.dateTimeOriginal, + orientation: exif.orientation, }) .from(assets) .leftJoin(exif, eq(exif.assetId, assets.id)) @@ -66,7 +72,10 @@ const getRowsByAlbums = async (currentUser: IUser, albumId: string) => { isNull(exif.latitude), isNotNull(assets.createdAt), eq(assets.ownerId, currentUser.id), - eq(albums.id, albumId) + eq(albums.id, albumId), + eq(assets.isVisible, true), + eq(assets.isArchived, false), + isNull(assets.deletedAt), )); } @@ -100,7 +109,13 @@ export default async function handler( try { const rows = await getRowsByDates(startDateDate, endDateDate, currentUser); - return res.status(200).json(rows); + const cleanedRows = rows.map((row) => ({ + ...row, + exifImageWidth: isFlipped(row.orientation) ? row.exifImageHeight : row.exifImageWidth, + exifImageHeight: isFlipped(row.orientation) ? row.exifImageWidth : row.exifImageHeight, + orientation: row.orientation, + })); + return res.status(200).json(cleanedRows); } catch (error: any) { return res.status(500).json({ error: error?.message, @@ -113,6 +128,12 @@ export default async function handler( }); } const rows = await getRowsByAlbums(currentUser, albumId); - return res.status(200).json(rows); + const cleanedRows = rows.map((row) => ({ + ...row, + exifImageWidth: isFlipped(row.orientation) ? row.exifImageHeight : row.exifImageWidth, + exifImageHeight: isFlipped(row.orientation) ? row.exifImageWidth : row.exifImageHeight, + orientation: row.orientation, + })); + return res.status(200).json(cleanedRows); } } \ 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 339c073..5cde150 100644 --- a/src/pages/api/assets/missing-location-dates.ts +++ b/src/pages/api/assets/missing-location-dates.ts @@ -34,6 +34,8 @@ export default async function handler( isNotNull(exif.dateTimeOriginal), eq(assets.ownerId, currentUser.id), eq(assets.isVisible, true), + eq(assets.isArchived, false), + isNull(assets.deletedAt), )) .groupBy(sql`DATE(${exif.dateTimeOriginal})`) .orderBy(desc(count(assets.id))) as IMissingLocationDatesResponse[]; diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx index 9a1c5d0..2da46e2 100644 --- a/src/pages/assets/missing-locations.tsx +++ b/src/pages/assets/missing-locations.tsx @@ -10,13 +10,14 @@ import { useToast } from "@/components/ui/use-toast"; import MissingLocationContext, { IMissingLocationConfig, } from "@/contexts/MissingLocationContext"; -import { updateAssets } from "@/handlers/api/asset.handler"; +import { deleteAssets, 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"; +import { AlertDialog } from "@/components/ui/alert-dialog"; export default function MissingLocations() { const { query, push } = useRouter(); @@ -42,6 +43,16 @@ export default function MissingLocations() { }) }; + const handleDelete = () => { + return deleteAssets(config.selectedIds).then(() => { + setConfig({ + ...config, + selectedIds: [], + assets: config.assets.filter((a) => !config.selectedIds.includes(a.id)), + }); + }) + } + return (
)} + {/* Seperator */} + +
+ + +
From 72dcb928efeb5b9da58761ceec75cae03eb79ea2 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sat, 18 Jan 2025 14:44:04 +0530 Subject: [PATCH 13/22] feat: unified album creation and option to delete assets from potential albums Signed-off-by: Varun Raj --- src/components/albums/AlbumCreateDialog.tsx | 86 ------------ src/components/albums/AlbumSelectorDialog.tsx | 80 ++++++++++- .../assets-options/AssetOffsetDialog.tsx | 22 +-- .../assets/assets-options/AssetsOptions.tsx | 31 ----- .../MissingLocationAssets.tsx | 7 - src/components/shared/FloatingBar.tsx | 2 +- src/pages/albums/potential-albums.tsx | 129 ++++++++++++------ .../api/albums/potential-albums-assets.ts | 14 +- src/pages/assets/missing-locations.tsx | 11 +- 9 files changed, 197 insertions(+), 185 deletions(-) delete mode 100644 src/components/albums/AlbumCreateDialog.tsx delete mode 100644 src/components/assets/assets-options/AssetsOptions.tsx diff --git a/src/components/albums/AlbumCreateDialog.tsx b/src/components/albums/AlbumCreateDialog.tsx deleted file mode 100644 index cd20447..0000000 --- a/src/components/albums/AlbumCreateDialog.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../ui/dialog"; -import { Button } from "../ui/button"; -import { listAlbums } from "@/handlers/api/album.handler"; -import { IAlbum, IAlbumCreate } from "@/types/album"; -import { Input } from "../ui/input"; -import { Label } from "../ui/label"; -import { useToast } from "../ui/use-toast"; - -interface IProps { - onCreated?: (album: IAlbum) => Promise; - onSubmit?: (data: IAlbumCreate) => Promise; - assetIds?: string[]; -} -export default function AlbumCreateDialog({ onSubmit, assetIds }: IProps) { - const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [formData, setFormData] = useState({ - albumName: "", - }); - const { toast } = useToast(); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!onSubmit) return; - setLoading(true); - onSubmit(formData) - .then(() => { - toast({ - title: "Album Created", - description: "Album created successfully", - }); - setFormData({ albumName: "" }); - setOpen(false); - }) - .catch((e) => { - toast({ - title: "Error", - description: "Failed to create album", - variant: "destructive", - }); - }) - .finally(() => { - setLoading(false); - }); - }; - - return ( - - - - - - - Create Album - - Create and add photos to a new album - - -
-
- - { - setFormData({ ...formData, albumName: e.target.value }); - }} - /> -
-
- -
-
-
-
- ); -} diff --git a/src/components/albums/AlbumSelectorDialog.tsx b/src/components/albums/AlbumSelectorDialog.tsx index d5dfc88..6e53b1b 100644 --- a/src/components/albums/AlbumSelectorDialog.tsx +++ b/src/components/albums/AlbumSelectorDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Dialog, DialogContent, @@ -9,18 +9,28 @@ import { } from "../ui/dialog"; import { Button } from "../ui/button"; import { listAlbums } from "@/handlers/api/album.handler"; -import { IAlbum } from "@/types/album"; +import { IAlbum, IAlbumCreate } from "@/types/album"; import { Input } from "../ui/input"; +import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import toast from "react-hot-toast"; +import { Label } from "../ui/label"; interface IProps { onSelected: (album: IAlbum) => Promise; + onCreated?: (album: IAlbum) => Promise; + onSubmit?: (data: IAlbumCreate) => Promise; } -export default function AlbumSelectorDialog({ onSelected }: IProps) { +export default function AlbumSelectorDialog({ onSelected, onCreated, onSubmit }: IProps) { const [open, setOpen] = useState(false); const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [search, setSearch] = useState(""); + const { selectedIds, assets } = usePotentialAlbumContext(); + const [formData, setFormData] = useState({ + albumName: "", + }); const fetchData = () => { return listAlbums() @@ -42,11 +52,32 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) { }); } + + const handleSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + console.log(formData); + if (!onSubmit) return; + setLoading(true); + onSubmit(formData) + .then(() => { + toast.success("Album created successfully"); + setFormData({ albumName: "" }); + setOpen(false); + }) + .catch((e) => { + toast.error("Failed to create album"); + }) + .finally(() => { + setLoading(false); + }); + }, [onSubmit, formData]); + + useEffect(() => { if (open && !albums.length) fetchData(); }, [open]); - const renderContent = () => { + const renderContent = useCallback(() => { if (loading) return

Loading...

; if (errorMessage) return

{errorMessage}

; return ( @@ -73,12 +104,36 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) { ); - }; + }, [albums, filteredAlbums, handleSelect, loading, errorMessage]); + + const renderCreateContent = useCallback(() => { + return ( +
+

+ Create a new album and add the selected assets to it +

+
+ + { + setFormData({ ...formData, albumName: e.target.value }); + }} + /> +
+
+ +
+
+ ) + }, [loading, formData, handleSubmit]); return ( - + @@ -87,7 +142,18 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) { Select the albums you want to add the selected assets to - {renderContent()} + + + Albums + Create + + + {renderContent()} + + + {renderCreateContent()} + + ); diff --git a/src/components/assets/assets-options/AssetOffsetDialog.tsx b/src/components/assets/assets-options/AssetOffsetDialog.tsx index 882ca3f..9e29cf2 100644 --- a/src/components/assets/assets-options/AssetOffsetDialog.tsx +++ b/src/components/assets/assets-options/AssetOffsetDialog.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { IAsset } from '@/types/asset'; -import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog'; +import { Dialog, DialogTitle, DialogHeader, DialogContent, DialogTrigger } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -11,12 +11,12 @@ import { useToast } from '@/components/ui/use-toast'; interface IProps { assets: IAsset[]; - open: boolean; - toggleOpen: (open: boolean) => void; + onComplete: () => void; } -export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: IProps) { +export default function AssetOffsetDialog({ assets: _assets, onComplete }: IProps) { const { toast } = useToast(); + const [open, setOpen] = useState(false); const [assets, setAssets] = useState(_assets); const [offsetData, setOffsetData] = useState<{ days: number, hours: number, minutes: number, seconds: number, years: number }>({ days: 0, @@ -43,7 +43,7 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: }) .then(() => { setLoading(false); - toggleOpen(false); + setOpen(false); }) .catch((error) => { setLoading(false); @@ -52,7 +52,8 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: }); await Promise.all(promises); setLoading(false); - toggleOpen(false); + setOpen(false); + onComplete(); toast({ title: 'Asset dates offset', description: 'Asset dates have been offset', @@ -65,12 +66,17 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: }, [_assets]); return ( - + + + + Offset Asset Dates -
+
handleChange("years", parseInt(e.target.value))} /> diff --git a/src/components/assets/assets-options/AssetsOptions.tsx b/src/components/assets/assets-options/AssetsOptions.tsx deleted file mode 100644 index cb65e53..0000000 --- a/src/components/assets/assets-options/AssetsOptions.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Button } from '@/components/ui/button' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { IAsset } from '@/types/asset'; -import { DotsVerticalIcon, HamburgerMenuIcon } from '@radix-ui/react-icons'; -import { Clock, PlusIcon } from 'lucide-react' -import React, { useState } from 'react' -import AssetOffsetDialog from './AssetOffsetDialog'; - -interface IProps { - onAdd?: () => void; - assets: IAsset[]; -} -export default function AssetsOptions({ onAdd, assets }: IProps) { - const [open, setOpen] = useState(false); - - return ( - <> - - - - - - setOpen(true)}> - Offset Asset Dates - - - - {open && } - - ) -} diff --git a/src/components/assets/missing-location/MissingLocationAssets.tsx b/src/components/assets/missing-location/MissingLocationAssets.tsx index c8b36b7..6f55085 100644 --- a/src/components/assets/missing-location/MissingLocationAssets.tsx +++ b/src/components/assets/missing-location/MissingLocationAssets.tsx @@ -1,22 +1,15 @@ import "yet-another-react-lightbox/styles.css"; -import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; -import { listPotentialAlbumsAssets } from "@/handlers/api/album.handler"; import { IAsset } from "@/types/asset"; import React, { useEffect, useMemo, useState, MouseEvent } from "react"; import { Gallery } from "react-grid-gallery"; import Lightbox from "yet-another-react-lightbox"; import Captions from "yet-another-react-lightbox/plugins/captions"; import { - ArrowUpRight, - CalendarArrowDown, CalendarArrowUp, Hourglass, - Link, } from "lucide-react"; import { useMissingLocationContext } from "@/contexts/MissingLocationContext"; import { listMissingLocationAssets } from "@/handlers/api/asset.handler"; -import { formatDate, parseDate } from "@/helpers/date.helper"; -import { addDays } from "date-fns"; import { useConfig } from "@/contexts/ConfigContext"; import LazyGridImage from "@/components/ui/lazy-grid-image"; diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx index 45c8cc9..73e3aa7 100644 --- a/src/components/shared/FloatingBar.tsx +++ b/src/components/shared/FloatingBar.tsx @@ -8,7 +8,7 @@ interface FloatingBarProps { export default function FloatingBar({ children }: FloatingBarProps) { return (
-
+
{children}
diff --git a/src/pages/albums/potential-albums.tsx b/src/pages/albums/potential-albums.tsx index 469afa5..b3276c1 100644 --- a/src/pages/albums/potential-albums.tsx +++ b/src/pages/albums/potential-albums.tsx @@ -1,10 +1,11 @@ -import AlbumCreateDialog from "@/components/albums/AlbumCreateDialog"; import AlbumSelectorDialog from "@/components/albums/AlbumSelectorDialog"; import PotentialAlbumsAssets from "@/components/albums/potential-albums/PotentialAlbumsAssets"; import PotentialAlbumsDates from "@/components/albums/potential-albums/PotentialAlbumsDates"; -import AssetsOptions from "@/components/assets/assets-options/AssetsOptions"; +import AssetOffsetDialog from "@/components/assets/assets-options/AssetOffsetDialog"; import PageLayout from "@/components/layouts/PageLayout"; +import FloatingBar from "@/components/shared/FloatingBar"; 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 { useToast } from "@/components/ui/use-toast"; @@ -13,6 +14,7 @@ import PotentialAlbumContext, { IPotentialAlbumConfig, } from "@/contexts/PotentialAlbumContext"; import { addAssetToAlbum, createAlbum } from "@/handlers/api/album.handler"; +import { deleteAssets } from "@/handlers/api/asset.handler"; import { IAlbum, IAlbumCreate } from "@/types/album"; import { useRouter } from "next/router"; import React, { useMemo } from "react"; @@ -29,7 +31,7 @@ export default function PotentialAlbums() { 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 handleSelect = (album: IAlbum) => { return addAssetToAlbum(album.id, config.selectedIds) @@ -66,50 +68,46 @@ export default function PotentialAlbums() { role: "editor", userId: id, }] + }).then(() => { + toast({ + title: "Album created", + description: "Album created successfully", + }); + setConfig({ ...config, selectedIds: [], assets: config.assets.filter(a => !config.selectedIds.includes(a.id)) }); }) } + const handleDelete = () => { + return deleteAssets(config.selectedIds) + .then(() => { + setConfig({ ...config, selectedIds: [], assets: config.assets.filter(a => !config.selectedIds.includes(a.id)) }); + }) + .then(() => { + toast({ + title: "Assets deleted", + description: "Assets deleted successfully", + }); + }) + .catch(() => { + toast({ + title: "Error", + description: "Failed to delete assets", + variant: "destructive", + }); + }); + } + + const handleOffsetComplete = () => { + setConfig({ + ...config, + selectedIds: [], + }); + } + return ( - +
- - {config.selectedIds.length} Selected - - {config.selectedIds.length && config.selectedIds.length === config.assets.length ? ( - - ) : ( - - )} - - a.id)} /> - { }} /> -
- } />
+ +
+

+ {config.selectedIds.length} Selected +

+
+ {config.selectedIds.length && config.selectedIds.length === config.assets.length ? ( + + ) : ( + + )} + + +
+ + + + +
+
+
); diff --git a/src/pages/api/albums/potential-albums-assets.ts b/src/pages/api/albums/potential-albums-assets.ts index 080fb8f..fa725b2 100644 --- a/src/pages/api/albums/potential-albums-assets.ts +++ b/src/pages/api/albums/potential-albums-assets.ts @@ -2,6 +2,7 @@ import { CHART_COLORS } from "@/config/constants/chart.constant"; import { db } from "@/config/db"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { isFlipped } from "@/helpers/asset.helper"; import { exif } from "@/schema"; import { count, desc, isNotNull, ne, sql } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -23,7 +24,8 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) => a."deletedAt", e."exifImageWidth", e."exifImageHeight", - e."dateTimeOriginal" + e."dateTimeOriginal", + e."orientation" FROM assets a LEFT JOIN @@ -47,7 +49,15 @@ export default async function handler( const currentUser = await getCurrentUser(req) const { startDate } = req.query as { startDate: string }; const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(startDate, currentUser.id)); - return res.status(200).json(rows); + + const cleanedRows = rows.map((row: any) => { + return { + ...row, + exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth, + exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight, + }; + }); + return res.status(200).json(cleanedRows); } catch (error: any) { res.status(500).json({ error: error?.message, diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx index 2da46e2..5b7d60d 100644 --- a/src/pages/assets/missing-locations.tsx +++ b/src/pages/assets/missing-locations.tsx @@ -1,4 +1,3 @@ -import AssetsOptions from "@/components/assets/assets-options/AssetsOptions"; import MissingLocationAssets from "@/components/assets/missing-location/MissingLocationAssets"; import MissingLocationDates from "@/components/assets/missing-location/MissingLocationDates"; import TagMissingLocationDialog from "@/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog"; @@ -18,6 +17,7 @@ import React, { useMemo } from "react"; 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"; export default function MissingLocations() { const { query, push } = useRouter(); @@ -53,6 +53,13 @@ export default function MissingLocations() { }) } + const handleOffsetComplete = () => { + setConfig({ + ...config, + selectedIds: [], + }); + } + return (
Date - { }} /> + } /> From 237857b1d16ebfef828778e7c79346a3f3202db3 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sat, 18 Jan 2025 17:51:55 +0530 Subject: [PATCH 14/22] docs: readme update Signed-off-by: Varun Raj --- .env.sample | 6 +++++- README.md | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.env.sample b/.env.sample index 2e8a180..1971f20 100644 --- a/.env.sample +++ b/.env.sample @@ -11,4 +11,8 @@ SECURE_COOKIE=false # Set to true to enable secure cookies # Optional GOOGLE_MAPS_API_KEY="" # Google Maps API Key for heatmap -GEMINI_API_KEY="" # Gemini API Key for parsing search query in "Find" \ No newline at end of file +GEMINI_API_KEY="" # Gemini API Key for parsing search query in "Find" + +# Immich Share Link +IMMICH_SHARE_LINK_KEY="" # Share link key for Immich +POWER_TOOLS_ENDPOINT_URL="" # URL of the Power Tools endpoint (Used for share links) \ No newline at end of file diff --git a/README.md b/README.md index 1eb62e4..c769fba 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,15 @@ A unofficial immich client to provide better tools to organize and manage your i [![Immich Power Tools](./screenshots/screenshot-1.png)](https://www.loom.com/embed/13aa90d8ab2e4acab0993bdc8703a750?sid=71498690-b745-473f-b239-a7bdbe6efc21) +### 🎒Features +- **Manage people data in bulk 👫**: Options to update people data in bulk, and with advance filters +- **People Merge Suggestion 🎭**: Option to bulk merge people with suggested faces based on similarity. +- **Update Missing Locations 📍**: Find assets in your library those are without location and update them with the location of the asset. +- **Potential Albums 🖼️**: Find albums that are potential to be created based on the assets and people in your library. +- **Analytics 📈 **: Get analytics on your library like assets over time, exif data, etc. +- **Smart Search 🔎**: Search your library with natural language, supports queries like "show me all my photos from 2024 of " +- **Bulk Date Offset 📅**: Offset the date of selected assets by a given amount of time. Majorly used to fix the date of assets that are out of sync with the actual date. + ### Support me 🙏 If you find this tool useful, please consider supporting me by buying me a coffee. From b60660fe2ebe35365c392a43eda7d7bdf2212dc2 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sat, 18 Jan 2025 17:54:33 +0530 Subject: [PATCH 15/22] fix: minor design fix Signed-off-by: Varun Raj --- src/pages/albums/potential-albums.tsx | 2 +- src/pages/assets/missing-locations.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/albums/potential-albums.tsx b/src/pages/albums/potential-albums.tsx index b3276c1..bc3893a 100644 --- a/src/pages/albums/potential-albums.tsx +++ b/src/pages/albums/potential-albums.tsx @@ -156,7 +156,7 @@ export default function PotentialAlbums() { )} -
+
Date - + } /> @@ -136,7 +136,8 @@ export default function MissingLocations() { {/* Seperator */} -
+ +
Date: Sun, 19 Jan 2025 09:31:35 +0530 Subject: [PATCH 16/22] feat: bulk delete in find page and find page clean up Signed-off-by: Varun Raj --- src/components/albums/AlbumSelectorDialog.tsx | 1 - .../assets-options/AssetOffsetDialog.tsx | 2 +- .../TagMissingLocationDialog.tsx | 7 +- src/components/shared/AssetGrid.tsx | 53 +++++++++++++-- .../shared/AssetsBulkDeleteButton.tsx | 36 +++++++++++ src/components/ui/alert-dialog.tsx | 6 +- src/pages/404.tsx | 10 +++ src/pages/albums/potential-albums.tsx | 6 +- src/pages/api/find/search.ts | 13 +++- src/pages/api/share-link/generate.ts | 1 - src/pages/assets/missing-locations.tsx | 6 +- src/pages/find/index.tsx | 64 ++++++++++--------- 12 files changed, 156 insertions(+), 49 deletions(-) create mode 100644 src/components/shared/AssetsBulkDeleteButton.tsx create mode 100644 src/pages/404.tsx diff --git a/src/components/albums/AlbumSelectorDialog.tsx b/src/components/albums/AlbumSelectorDialog.tsx index 6e53b1b..c8c006f 100644 --- a/src/components/albums/AlbumSelectorDialog.tsx +++ b/src/components/albums/AlbumSelectorDialog.tsx @@ -55,7 +55,6 @@ export default function AlbumSelectorDialog({ onSelected, onCreated, onSubmit }: const handleSubmit = useCallback((e: React.FormEvent) => { e.preventDefault(); - console.log(formData); if (!onSubmit) return; setLoading(true); onSubmit(formData) diff --git a/src/components/assets/assets-options/AssetOffsetDialog.tsx b/src/components/assets/assets-options/AssetOffsetDialog.tsx index 9e29cf2..bc21585 100644 --- a/src/components/assets/assets-options/AssetOffsetDialog.tsx +++ b/src/components/assets/assets-options/AssetOffsetDialog.tsx @@ -68,7 +68,7 @@ export default function AssetOffsetDialog({ assets: _assets, onComplete }: IProp return ( - diff --git a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx index af77728..d100f3b 100644 --- a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx +++ b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx @@ -13,6 +13,7 @@ import React, { useState } from "react"; import TagMissingLocationSearchAndAdd from "./TagMissingLocationSearchAndAdd"; import TagMissingLocationSearchLatLong from "./TagMissingLocationSearchLatLong"; import dynamic from "next/dynamic"; +import { useMissingLocationContext } from "@/contexts/MissingLocationContext"; const LazyMap = dynamic(() => import("./Map"), { ssr: false @@ -24,7 +25,7 @@ interface ITagMissingLocationDialogProps { export default function TagMissingLocationDialog({ onSubmit, }: ITagMissingLocationDialogProps) { - + const {selectedIds} = useMissingLocationContext(); const [open, setOpen] = useState(false); const [mapPosition,setMapPosition] = useState({ latitude: 48.0, @@ -35,8 +36,8 @@ export default function TagMissingLocationDialog({ return ( - - + + diff --git a/src/components/shared/AssetGrid.tsx b/src/components/shared/AssetGrid.tsx index 2405c10..bd5a74b 100644 --- a/src/components/shared/AssetGrid.tsx +++ b/src/components/shared/AssetGrid.tsx @@ -1,7 +1,7 @@ import "yet-another-react-lightbox/styles.css"; import { IAsset } from '@/types/asset'; -import React, { useMemo, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import Lightbox from 'yet-another-react-lightbox'; import { Gallery } from "react-grid-gallery"; import LazyGridImage from "../ui/lazy-grid-image"; @@ -12,10 +12,40 @@ interface AssetGridProps { assets: IAsset[]; isInternal?: boolean; selectable?: boolean; + onSelectionChange?: (ids: string[]) => void; } -export default function AssetGrid({ assets, isInternal = true, selectable = false }: AssetGridProps) { +export default function AssetGrid({ assets, isInternal = true, selectable = false, onSelectionChange }: AssetGridProps) { const [index, setIndex] = useState(-1); + const [selectedIds, setSelectedIds] = useState([]); + const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); + + const handleSelect = (_idx: number, asset: IAsset, event: React.MouseEvent) => { + + event.stopPropagation(); + const isPresent = selectedIds.includes(asset.id); + if (isPresent) { + setSelectedIds(selectedIds.filter((id) => id !== asset.id)); + onSelectionChange?.(selectedIds.filter((id) => id !== asset.id)); + } else { + const clickedIndex = images.findIndex((image) => { + return image.id === asset.id; + }); + if (event.shiftKey) { + const startIndex = Math.min(clickedIndex, lastSelectedIndex); + const endIndex = Math.max(clickedIndex, lastSelectedIndex); + const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); + const allSelectedIds = [...selectedIds, ...newSelectedIds]; + const uniqueSelectedIds = [...new Set(allSelectedIds)]; + setSelectedIds(uniqueSelectedIds); + onSelectionChange?.(uniqueSelectedIds); + } else { + setSelectedIds([...selectedIds, asset.id]); + onSelectionChange?.([...selectedIds, asset.id]); + } + setLastSelectedIndex(clickedIndex); + } + }; const slides = useMemo(() => { return assets.map((asset) => ({ @@ -45,9 +75,23 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals original: p.previewUrl as string, width: p.exifImageWidth / 10 as number, height: p.exifImageHeight / 10 as number, - orientation: 1 + orientation: 1, + isSelected: selectedIds.includes(p.id), })); - }, [assets]); + }, [assets, selectedIds]); + + const handleEsc = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setSelectedIds([]); + onSelectionChange?.([]); + } + }; + + useEffect(() => { + // Listen for esc key press and unselect all images + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [images]); return (
@@ -63,6 +107,7 @@ export default function AssetGrid({ assets, isInternal = true, selectable = fals onClick={setIndex} enableImageSelection={selectable} thumbnailImageComponent={LazyGridImage} + onSelect={handleSelect} />
); diff --git a/src/components/shared/AssetsBulkDeleteButton.tsx b/src/components/shared/AssetsBulkDeleteButton.tsx new file mode 100644 index 0000000..85418b6 --- /dev/null +++ b/src/components/shared/AssetsBulkDeleteButton.tsx @@ -0,0 +1,36 @@ +import React, { useMemo } from 'react' +import { Button } from '../ui/button'; +import { AlertDialog } from '../ui/alert-dialog'; +import { deleteAssets } from '@/handlers/api/asset.handler'; + +interface AssetsBulkDeleteButtonProps { + selectedIds: string[]; + onDelete: (ids: string[]) => void; +} + +export default function AssetsBulkDeleteButton({ selectedIds, onDelete }: AssetsBulkDeleteButtonProps) { + + const ids = useMemo(() => { + return selectedIds + }, [selectedIds]); + + + const handleDelete = () => { + return deleteAssets(ids).then(() => { + onDelete(ids); + }) + } + + return ( + + + + ) +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index d898f67..35c8942 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -139,6 +139,7 @@ interface AlertDialogProps extends AlertDialogPrimitive.AlertDialogProps { onCancel: () => void | Promise variant?: ButtonProps['variant'] asChild?: boolean + disabled?: boolean } const AlertDialog = React.forwardRef>(({ @@ -149,6 +150,7 @@ const AlertDialog = React.forwardRef { const [loading, setLoading] = useState(false) @@ -169,7 +171,7 @@ const AlertDialog = React.forwardRef - {children} + {children} {title} @@ -178,7 +180,7 @@ const AlertDialog = React.forwardRef Cancel diff --git a/src/pages/404.tsx b/src/pages/404.tsx new file mode 100644 index 0000000..5343444 --- /dev/null +++ b/src/pages/404.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +export default function FourOFour() { + return ( +
+

404

+

Page not found

+
+ ) +} \ No newline at end of file diff --git a/src/pages/albums/potential-albums.tsx b/src/pages/albums/potential-albums.tsx index bc3893a..8cbb392 100644 --- a/src/pages/albums/potential-albums.tsx +++ b/src/pages/albums/potential-albums.tsx @@ -161,8 +161,10 @@ export default function PotentialAlbums() { - diff --git a/src/pages/api/find/search.ts b/src/pages/api/find/search.ts index 07c1668..471bc40 100644 --- a/src/pages/api/find/search.ts +++ b/src/pages/api/find/search.ts @@ -1,6 +1,7 @@ import { db } from '@/config/db'; import { ENV } from '@/config/environment'; import { getCurrentUser } from '@/handlers/serverUtils/user.utils'; +import { cleanUpAsset, cleanUpAssets, isFlipped } from '@/helpers/asset.helper'; import { parseFindQuery } from '@/helpers/gemini.helper'; import { assetFaces, assets, person } from '@/schema'; import { Person } from '@/schema/person.schema'; @@ -23,7 +24,7 @@ export default async function search(req: NextApiRequest, res: NextApiResponse) return fetch(url, { method: 'POST', - body: JSON.stringify(parsedQuery), + body: JSON.stringify({...parsedQuery, withExif: true }), headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${currentUser?.accessToken}` @@ -40,8 +41,16 @@ export default async function search(req: NextApiRequest, res: NextApiResponse) return response.json(); }) .then(data => { + const items = data.assets.items.map((item: any) => { + return { + ...item, + exifImageHeight: isFlipped(item?.exifInfo?.orientation) ? item?.exifInfo?.exifImageWidth : item?.exifInfo?.exifImageHeight, + exifImageWidth: isFlipped(item?.exifInfo?.orientation) ? item?.exifInfo?.exifImageHeight : item?.exifInfo?.exifImageWidth, + orientation: item?.exifInfo?.orientation, + } + }).map(cleanUpAsset) res.status(200).json({ - assets: data.assets.items, + assets: items, filters: { ...parsedQuery, personIds: dbPeople.map((person) => person.name) diff --git a/src/pages/api/share-link/generate.ts b/src/pages/api/share-link/generate.ts index 5c4affb..ca87aa1 100644 --- a/src/pages/api/share-link/generate.ts +++ b/src/pages/api/share-link/generate.ts @@ -10,7 +10,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const currentUser = await getCurrentUser(req); const filters = req.body; - console.log(filters); if (!currentUser) { return res.status(401).json({ message: "Unauthorized" }); } diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx index 23fc94a..70d15a1 100644 --- a/src/pages/assets/missing-locations.tsx +++ b/src/pages/assets/missing-locations.tsx @@ -141,8 +141,10 @@ export default function MissingLocations() { - diff --git a/src/pages/find/index.tsx b/src/pages/find/index.tsx index 83aaa13..a714348 100644 --- a/src/pages/find/index.tsx +++ b/src/pages/find/index.tsx @@ -13,6 +13,9 @@ import React, { useMemo, useState } from 'react' import Lightbox from 'yet-another-react-lightbox'; import { Button } from "@/components/ui/button"; import Link from "next/link"; +import AssetGrid from "@/components/shared/AssetGrid"; +import FloatingBar from "@/components/shared/FloatingBar"; +import AssetsBulkDeleteButton from "@/components/shared/AssetsBulkDeleteButton"; interface IFindFilters { [key: string]: string; @@ -30,7 +33,7 @@ const FILTER_KEY_MAP = { } export default function FindPage() { - const [index, setIndex] = useState(-1); + const [selectedIds, setSelectedIds] = useState([]); const { geminiEnabled, exImmichUrl } = useConfig(); const [query, setQuery] = useState(''); const [assets, setAssets] = useState([]); @@ -52,7 +55,12 @@ export default function FindPage() { value: string; }[] = useMemo(() => { return Object.entries(filters) - .filter(([_key, value]) => value !== undefined) + .filter(([_key, value]) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== undefined && value !== null && value !== ''; + }) .map(([key, value]) => ({ label: key, value: Array.isArray(value) ? value.join(', ') : value, @@ -73,6 +81,16 @@ export default function FindPage() { }); } + const handleSelectionChange = (ids: string[]) => { + setSelectedIds(ids); + } + + const handleDelete = (ids: string[]) => { + setSelectedIds([]); + setAssets(assets.filter((asset) => !ids.includes(asset.id))); + + } + const renderFilters = () => { if (appliedFilters.length === 0) return null; return ( @@ -114,41 +132,25 @@ export default function FindPage() {

{renderFilters()}

- } + return ( <> - = 0} - index={index} - close={() => setIndex(-1)} - /> {renderFilters()} -
- {assets.map((asset, idx) => ( -
- - Open - - - {asset.id} setIndex(idx)} +
+ + {selectedIds.length > 0 && ( + +

+ {selectedIds.length} Selected +

+ -
- ))} + + )}
) From 19d147d3ebd66bb34c069f7ed22bbaba4e53822b Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 19 Jan 2025 09:32:33 +0530 Subject: [PATCH 17/22] docs: fixed readme Signed-off-by: Varun Raj --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c769fba..92ceefe 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A unofficial immich client to provide better tools to organize and manage your i - **People Merge Suggestion 🎭**: Option to bulk merge people with suggested faces based on similarity. - **Update Missing Locations 📍**: Find assets in your library those are without location and update them with the location of the asset. - **Potential Albums 🖼️**: Find albums that are potential to be created based on the assets and people in your library. -- **Analytics 📈 **: Get analytics on your library like assets over time, exif data, etc. +- **Analytics 📈**: Get analytics on your library like assets over time, exif data, etc. - **Smart Search 🔎**: Search your library with natural language, supports queries like "show me all my photos from 2024 of " - **Bulk Date Offset 📅**: Offset the date of selected assets by a given amount of time. Majorly used to fix the date of assets that are out of sync with the actual date. From fdef49158636aea8bae00c87c8fa9b76f518714c Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 19 Jan 2025 09:33:02 +0530 Subject: [PATCH 18/22] docs: fixed readme Signed-off-by: Varun Raj --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92ceefe..cbd95d3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A unofficial immich client to provide better tools to organize and manage your i If you find this tool useful, please consider supporting me by buying me a coffee. -[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/varunraj) +[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/varunraj){:target="_blank"} ## 💭 Back story From d5c6f0ac2907e6a26e467346a4aa2838d68da161 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 19 Jan 2025 10:09:19 +0530 Subject: [PATCH 19/22] feat: added option for link expiry Signed-off-by: Varun Raj --- README.md | 2 +- src/components/shared/ShareAssetsTrigger.tsx | 46 +++++- src/pages/api/share-link/[token]/assets.ts | 163 ++++++++++--------- src/pages/api/share-link/[token]/index.ts | 12 +- src/pages/api/share-link/[token]/people.ts | 93 ++++++----- src/pages/api/share-link/generate.ts | 19 ++- src/pages/s/[token].tsx | 8 +- src/types/shareLink.d.ts | 1 + 8 files changed, 206 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index cbd95d3..92ceefe 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A unofficial immich client to provide better tools to organize and manage your i If you find this tool useful, please consider supporting me by buying me a coffee. -[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/varunraj){:target="_blank"} +[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/varunraj) ## 💭 Back story diff --git a/src/components/shared/ShareAssetsTrigger.tsx b/src/components/shared/ShareAssetsTrigger.tsx index 846f047..d8fc7ab 100644 --- a/src/components/shared/ShareAssetsTrigger.tsx +++ b/src/components/shared/ShareAssetsTrigger.tsx @@ -5,8 +5,9 @@ import { ShareLinkFilters } from '@/types/shareLink'; import { generateShareLink } from '@/handlers/api/shareLink.handler'; import { Input } from '../ui/input'; import { Label } from '@radix-ui/react-label'; -import { Checkbox } from '../ui/checkbox'; + import { Switch } from '../ui/switch'; +import { Select, SelectValue, SelectContent, SelectItem, SelectTrigger } from '../ui/select'; interface ShareAssetsTriggerProps { @@ -19,10 +20,18 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [copied, setCopied] = useState(false); - const [config, setConfig] = useState>({}); + const [config, setConfig] = useState>({ + expiresIn: "never" + }); + + const handleReset = () => { + setConfig({ expiresIn: "never" }); + setGeneratedLink(null); + } const handleGenerate = async () => { setLoading(true); + setErrorMessage(null); return generateShareLink({ ...filters, ...config }).then(({ link }) => { setGeneratedLink(link); }).catch((err) => { @@ -63,20 +72,41 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets This is a stateless link, it will not work if you leave the page. Which means it cannot be expired.

- - + +
: (
- -

- Show the list of people in the shared photos -

+ +

+ Show the list of people in the shared photos +

setConfig({ ...config, p: !!checked })} />
+
+
+ +

+ Should the link expire after the selected time +

+
+ +
)} diff --git a/src/pages/api/share-link/[token]/assets.ts b/src/pages/api/share-link/[token]/assets.ts index 28621ad..c6338ae 100644 --- a/src/pages/api/share-link/[token]/assets.ts +++ b/src/pages/api/share-link/[token]/assets.ts @@ -4,7 +4,7 @@ import { desc, gte, lte } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm"; import { assetFaces, assets, exif, person } from "@/schema"; import { and } from "drizzle-orm"; -import { sign, verify } from "jsonwebtoken"; +import { JsonWebTokenError, sign, verify } from "jsonwebtoken"; import { NextApiResponse } from "next"; import { NextApiRequest } from "next"; @@ -19,89 +19,96 @@ interface IQuery extends ShareLinkFilters { } export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { token, ...rest } = req.query as any as IQuery + try { + const { token, ...rest } = req.query as any as IQuery - const filters = { - personIds: rest['personIds[]'] ?? rest.personIds ?? [], - } - - if (!token) { - return res.status(404).json({ message: "Token not found" }); - } + const filters = { + personIds: rest['personIds[]'] ?? rest.personIds ?? [], + } - const decoded = verify(token as string, ENV.JWT_SECRET); + if (!token) { + return res.status(404).json({ message: "Token not found" }); + } - if (!decoded) { - return res.status(401).json({ message: "Unauthorized" }); - } + const decoded = verify(token as string, ENV.JWT_SECRET); + + if (!decoded) { + return res.status(401).json({ message: "Unauthorized" }); + } - const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { - expiresIn: "2m" - }); - - const { personIds = [], albumIds = [], startDate, endDate } = decoded as ShareLinkFilters; - let filteredPersonIds: string[] = personIds ?? []; - const queryPersonIds = filters.personIds ? (Array.isArray(filters.personIds) ? filters.personIds : [filters.personIds]) : []; + const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { + expiresIn: "2m" + }); - - if (queryPersonIds.length > 0) { - - if (personIds.length === 0) { - filteredPersonIds = queryPersonIds; - } else { - filteredPersonIds = queryPersonIds.filter((id) => personIds.includes(id)); + const { personIds = [], albumIds = [], startDate, endDate } = decoded as ShareLinkFilters; + let filteredPersonIds: string[] = personIds ?? []; + const queryPersonIds = filters.personIds ? (Array.isArray(filters.personIds) ? filters.personIds : [filters.personIds]) : []; + + + if (queryPersonIds.length > 0) { + + if (personIds.length === 0) { + filteredPersonIds = queryPersonIds; + } else { + filteredPersonIds = queryPersonIds.filter((id) => personIds.includes(id)); + } } - } - - const dbAssets = 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, - url: assets.id, - previewUrl: assets.id, - videoURL: assets.id, - orientation: exif.orientation, - }).from(assets) - .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) - .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) - .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) - .innerJoin(exif, eq(exif.assetId, assets.id)) - .where(and( - filteredPersonIds?.length > 0 ? inArray(assetFaces.personId, filteredPersonIds) : undefined, - albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, - startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, - endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, - eq(assets.isArchived, false), - eq(assets.isVisible, true), - eq(assets.isOffline, false), - )) - .orderBy(desc(assets.createdAt)); - const cleanedAssets = dbAssets.map((asset) => { - return { - ...asset, - localDateTime: new Date(asset.localDateTime), - duration: asset.duration ? parseInt(asset.duration) : 0, - exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0, - exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0, - dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(), + const dbAssets = 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, + url: assets.id, + previewUrl: assets.id, + videoURL: assets.id, + orientation: exif.orientation, + }).from(assets) + .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) + .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) + .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) + .innerJoin(exif, eq(exif.assetId, assets.id)) + .where(and( + filteredPersonIds?.length > 0 ? inArray(assetFaces.personId, filteredPersonIds) : undefined, + albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, + startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, + endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, + eq(assets.isArchived, false), + eq(assets.isVisible, true), + eq(assets.isOffline, false), + )) + .orderBy(desc(assets.createdAt)); + + const cleanedAssets = dbAssets.map((asset) => { + return { + ...asset, + localDateTime: new Date(asset.localDateTime), + duration: asset.duration ? parseInt(asset.duration) : 0, + exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0, + exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0, + dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(), + } + }).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string)); + + return res.status(200).json({ + assets: cleanedAssets, + filters: decoded as ShareLinkFilters + }); + } catch (error) { + if (error instanceof JsonWebTokenError) { + return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." }); } - }).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string)); - - return res.status(200).json({ - assets: cleanedAssets, - filters: decoded as ShareLinkFilters - }); + return res.status(500).json({ message: (error as Error).message }); + } } \ No newline at end of file diff --git a/src/pages/api/share-link/[token]/index.ts b/src/pages/api/share-link/[token]/index.ts index 7ec9be4..fca170d 100644 --- a/src/pages/api/share-link/[token]/index.ts +++ b/src/pages/api/share-link/[token]/index.ts @@ -2,20 +2,30 @@ import { NextApiResponse } from "next"; import { ENV } from "@/config/environment"; import { ShareLinkFilters } from "@/types/shareLink"; -import { verify } from "jsonwebtoken"; +import { JsonWebTokenError, verify } from "jsonwebtoken"; import { NextApiRequest } from "next"; export default async function GET(req: NextApiRequest, res: NextApiResponse) { const { token } = req.query; + try { + if (!token) { return res.status(404).json({ message: "Token not found" }); } const decoded = verify(token as string, ENV.JWT_SECRET); + console.log(decoded); if (!decoded) { return res.status(401).json({ message: "Unauthorized" }); } + return res.status(200).json(decoded); +} catch (error) { + if (error instanceof JsonWebTokenError) { + return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." }); + } + return res.status(500).json({ message: (error as Error).message }); +} } diff --git a/src/pages/api/share-link/[token]/people.ts b/src/pages/api/share-link/[token]/people.ts index 418f9b5..7f8ea77 100644 --- a/src/pages/api/share-link/[token]/people.ts +++ b/src/pages/api/share-link/[token]/people.ts @@ -4,7 +4,7 @@ import { count, desc, gte, isNotNull, lte, ne } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm"; import { assetFaces, assets, exif, person } from "@/schema"; import { and } from "drizzle-orm"; -import { sign, verify } from "jsonwebtoken"; +import { JsonWebTokenError, sign, verify } from "jsonwebtoken"; import { NextApiResponse } from "next"; import { NextApiRequest } from "next"; @@ -21,52 +21,59 @@ interface ShareLinkFilters { export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { token } = req.query; - if (!token) { - return res.status(404).json({ message: "Token not found" }); - } + try { + if (!token) { + return res.status(404).json({ message: "Token not found" }); + } - const decoded = verify(token as string, ENV.JWT_SECRET); + const decoded = verify(token as string, ENV.JWT_SECRET); - if (!decoded) { - return res.status(401).json({ message: "Unauthorized" }); - } + if (!decoded) { + return res.status(401).json({ message: "Unauthorized" }); + } - const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { - expiresIn: "2m" - }); - const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters; + const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { + expiresIn: "2m" + }); + const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters; - const dbPeople = await db.select({ - id: person.id, - name: person.name, - thumbnailAssetId: person.faceAssetId, - assetCount: count(assets.id), - }).from(assets) - .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) - .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) - .innerJoin(person, eq(assetFaces.personId, person.id)) - .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) - .innerJoin(exif, eq(exif.assetId, assets.id)) - .where(and( - personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined, - albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, - startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, - endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, - eq(assets.isArchived, false), - eq(assets.isVisible, true), - eq(assets.isOffline, false), - isNotNull(person.id), - ne(person.name, "") - )) - .groupBy(person.id) - .orderBy(desc(count(assets.id))); + const dbPeople = await db.select({ + id: person.id, + name: person.name, + thumbnailAssetId: person.faceAssetId, + assetCount: count(assets.id), + }).from(assets) + .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) + .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) + .innerJoin(person, eq(assetFaces.personId, person.id)) + .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) + .innerJoin(exif, eq(exif.assetId, assets.id)) + .where(and( + personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined, + albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, + startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, + endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, + eq(assets.isArchived, false), + eq(assets.isVisible, true), + eq(assets.isOffline, false), + isNotNull(person.id), + ne(person.name, "") + )) + .groupBy(person.id) + .orderBy(desc(count(assets.id))); - const cleanedPeople = dbPeople.map((person) => { - return { - ...person, - thumbnailPath: person.thumbnailAssetId ? ASSET_SHARE_THUMBNAIL_PATH({ id: person.id, size: "thumbnail", token: newPreviewToken, isPeople: true }) : null - } - }) + const cleanedPeople = dbPeople.map((person) => { + return { + ...person, + thumbnailPath: person.thumbnailAssetId ? ASSET_SHARE_THUMBNAIL_PATH({ id: person.id, size: "thumbnail", token: newPreviewToken, isPeople: true }) : null + } + }) - return res.status(200).json(cleanedPeople); + return res.status(200).json(cleanedPeople); + } catch (error) { + if (error instanceof JsonWebTokenError) { + return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." }); + } + return res.status(500).json({ message: (error as Error).message }); + } } \ No newline at end of file diff --git a/src/pages/api/share-link/generate.ts b/src/pages/api/share-link/generate.ts index ca87aa1..b76b93c 100644 --- a/src/pages/api/share-link/generate.ts +++ b/src/pages/api/share-link/generate.ts @@ -1,6 +1,5 @@ import { ENV } from "@/config/environment"; -import { SHARE_LINK_ASSETS_PATH } from "@/config/routes"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { sign } from "jsonwebtoken"; import { NextApiRequest, NextApiResponse } from "next"; @@ -8,8 +7,10 @@ import { NextApiRequest, NextApiResponse } from "next"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const currentUser = await getCurrentUser(req); - const filters = req.body; + const allFilters = req.body; + const { expiresIn, ...filters } = allFilters; + try { if (!currentUser) { return res.status(401).json({ message: "Unauthorized" }); } @@ -18,6 +19,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "Unauthorized" }); } - const token = sign(filters, ENV.JWT_SECRET); - return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` }); -} \ No newline at end of file + console.log(expiresIn); + + const token = sign(filters, ENV.JWT_SECRET, expiresIn !== "never" ? { + expiresIn: expiresIn + } : {}); + + return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` }); + } catch (error) { + return res.status(500).json({ message: (error as Error).message }); + } +} diff --git a/src/pages/s/[token].tsx b/src/pages/s/[token].tsx index 410ed41..0abe807 100644 --- a/src/pages/s/[token].tsx +++ b/src/pages/s/[token].tsx @@ -12,6 +12,7 @@ import { IPerson } from '@/types/person' import PeopleList from '@/components/shared/PeopleList' import { ShareLinkFilters } from '@/types/shareLink' import clsx from 'clsx' +import { LinkBreak2Icon } from '@radix-ui/react-icons' export default function AlbumListPage() { const router = useRouter() @@ -107,7 +108,10 @@ export default function AlbumListPage() { const renderContent = () => { if (loading) return - else if (errorMessage) return
{errorMessage}
+ else if (errorMessage) return
+ +

{errorMessage}

+
return (
{config.p && ( @@ -132,7 +136,7 @@ export default function AlbumListPage() { return (
diff --git a/src/types/shareLink.d.ts b/src/types/shareLink.d.ts index 23498d6..bb691ca 100644 --- a/src/types/shareLink.d.ts +++ b/src/types/shareLink.d.ts @@ -4,4 +4,5 @@ export interface ShareLinkFilters { startDate?: string; endDate?: string; p?: boolean; + expiresIn?: string; } \ No newline at end of file From 9fcda54e4fbc81b407d1246e647aca5fb5f71afc Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 19 Jan 2025 10:31:44 +0530 Subject: [PATCH 20/22] feat: added proper validations for errors with messages Signed-off-by: Varun Raj --- src/components/shared/ProfileInfo.tsx | 2 +- src/config/db.ts | 12 ++++- src/handlers/serverUtils/user.utils.ts | 20 +++++++- src/helpers/data.helper.ts | 5 ++ src/lib/api.ts | 17 ++++++- src/pages/analytics/exif.tsx | 56 ++++++++++++----------- src/pages/api/share-link/[token]/index.ts | 8 +++- src/pages/api/share-link/generate.ts | 10 +++- 8 files changed, 94 insertions(+), 36 deletions(-) diff --git a/src/components/shared/ProfileInfo.tsx b/src/components/shared/ProfileInfo.tsx index c6c8617..0e84a25 100644 --- a/src/components/shared/ProfileInfo.tsx +++ b/src/components/shared/ProfileInfo.tsx @@ -44,7 +44,7 @@ export default function ProfileInfo() { )}
-
+

Made with by{" "} ) => { try { - await db.execute(sql`SELECT 1`); // Execute a simple query + const missingKeys = findMissingKeys(ENV, ['DB_USERNAME', 'DB_PASSWORD', 'DB_HOST', 'DB_PORT', 'DB_DATABASE_NAME']); + if (!ENV.DATABASE_URL && missingKeys.length > 0) { + throw new APIError({ + message: `Some database credentials are missing: ${missingKeys.join(', ')}. Please add them to the .env file`, + status: 500, + }); + } else { + return await db.execute(sql`SELECT 1`); // Execute a simple query + } } catch (error: any) { throw new DatabaseConnectionError(error.message, "Database connection failed"); } diff --git a/src/handlers/serverUtils/user.utils.ts b/src/handlers/serverUtils/user.utils.ts index faf90db..6756e1a 100644 --- a/src/handlers/serverUtils/user.utils.ts +++ b/src/handlers/serverUtils/user.utils.ts @@ -1,6 +1,7 @@ import { appConfig } from "@/config/app.config" import { connectDB, db } from "@/config/db" import { ENV } from "@/config/environment" +import { APIError } from "@/lib/api" import { getCookie } from "@/lib/cookie" import { NextApiRequest } from "next" @@ -22,7 +23,19 @@ export const getCurrentUserFromAPIKey = () => { } }) } - return null + throw new APIError({ + message: "Invalid API key. Please check your API key variable `IMMICH_API_KEY` in the .env file", + status: 403, + }); + }) + .catch((error) => { + if (error instanceof APIError) { + throw error; + } + throw new APIError({ + message: "Failed to connect to Immich API: " + ENV.IMMICH_URL, + status: 500, + }); }) } @@ -37,6 +50,11 @@ export const getCurrentUserFromAccessToken = (token: string) => { return res.json() } return null + }).catch((error) => { + throw new APIError({ + message: "Failed to connect to Immich API: " + ENV.IMMICH_URL, + status: 500, + }); }) } diff --git a/src/helpers/data.helper.ts b/src/helpers/data.helper.ts index 939493a..b68bf17 100644 --- a/src/helpers/data.helper.ts +++ b/src/helpers/data.helper.ts @@ -17,4 +17,9 @@ export const removeNullOrUndefinedProperties = (obj: any) => { return v !== null && v !== undefined && v !== '' && v !== 'null' && v !== 'undefined' && v !== 'null' && v !== 'undefined' })); +} + +export const findMissingKeys = (obj: any, keys: string[]) => { + + return keys.filter((key) => !(key in obj) || obj[key] === '' || obj[key] === null || obj[key] === undefined); } \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index abc56cd..eee6dd2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,6 +2,21 @@ import type { AxiosError, AxiosRequestConfig, Method } from 'axios' import axios from 'axios' import qs from 'qs' +export class APIError extends Error { + status: number; + constructor({ + message, + status, + }: { + message: string; + status: number; + }) { + super(message); + this.name = 'APIError'; + this.message = message; + this.status = status; + } +} const handleErrors = (error: AxiosError | any) => { @@ -26,8 +41,6 @@ const handleErrors = (error: AxiosError | any) => { } export default class API { - - static setBaseURL(url: string) { if (url) { axios.defaults.baseURL = url diff --git a/src/pages/analytics/exif.tsx b/src/pages/analytics/exif.tsx index 59244ef..23da1c4 100644 --- a/src/pages/analytics/exif.tsx +++ b/src/pages/analytics/exif.tsx @@ -106,40 +106,42 @@ export default function ExifDataAnalytics() { }, []); return ( - +

-
- {["Total", "Images", "Videos"].map((type, i) => ( - +
+
+ {["Total", "Images", "Videos"].map((type, i) => ( + + + {type} + + + {loading ? "Loading..." : statistics[type.toLowerCase() as keyof typeof statistics].toLocaleString()} + + + ))} + - {type} + Live Photos - {loading ? "Loading..." : statistics[type.toLowerCase() as keyof typeof statistics].toLocaleString()} + {loading ? "Loading..." : livePhotoStatistics.total.toLocaleString()} - ))} - - - Live Photos - - - {loading ? "Loading..." : livePhotoStatistics.total.toLocaleString()} - - -
- -
- {exifCharts.map((chart) => ( - - ))} +
+ +
+ {exifCharts.map((chart) => ( + + ))} +
); diff --git a/src/pages/api/share-link/[token]/index.ts b/src/pages/api/share-link/[token]/index.ts index fca170d..d2a1d11 100644 --- a/src/pages/api/share-link/[token]/index.ts +++ b/src/pages/api/share-link/[token]/index.ts @@ -13,11 +13,15 @@ export default async function GET(req: NextApiRequest, res: NextApiResponse) { return res.status(404).json({ message: "Token not found" }); } + if (!ENV.IMMICH_SHARE_LINK_KEY) { + return res.status(401).json({ message: "Please check your link and try again. If you're the admin, check if you've enabled all the configurations in the Immich Power Tools in your environment variables" }); + } + const decoded = verify(token as string, ENV.JWT_SECRET); - console.log(decoded); + if (!decoded) { - return res.status(401).json({ message: "Unauthorized" }); + return res.status(401).json({ message: "Link is invalid. Please check your link and try again" }); } diff --git a/src/pages/api/share-link/generate.ts b/src/pages/api/share-link/generate.ts index b76b93c..139f7e9 100644 --- a/src/pages/api/share-link/generate.ts +++ b/src/pages/api/share-link/generate.ts @@ -15,11 +15,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "Unauthorized" }); } + if (!ENV.JWT_SECRET) { + return res.status(401).json({ message: "The JWT_SECRET is missing in the .env file. Please add it to the .env file for generating share links" }); + } + if (!ENV.IMMICH_SHARE_LINK_KEY) { - return res.status(401).json({ message: "Unauthorized" }); + return res.status(401).json({ message: "The IMMICH_SHARE_LINK_KEY is missing in the .env file. Please add it to the .env file for generating share links" }); } - console.log(expiresIn); + if (!ENV.POWER_TOOLS_ENDPOINT_URL) { + return res.status(401).json({ message: "The POWER_TOOLS_ENDPOINT_URL is missing in the .env file. Please add it to the .env file for generating share links" }); + } const token = sign(filters, ENV.JWT_SECRET, expiresIn !== "never" ? { expiresIn: expiresIn From 59fb16bc21b1c7d6fbbb65b91d901c179e1aa05e Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 19 Jan 2025 11:13:00 +0530 Subject: [PATCH 21/22] fix: removed unused components Signed-off-by: Varun Raj --- bun.lockb | Bin 478938 -> 477641 bytes package.json | 3 - src/components/layouts/RootLayout.tsx | 6 -- src/components/people/PeopleFilters.tsx | 1 - src/components/shared/common/AssetFilter.tsx | 57 ------------------- src/config/rQuery.ts | 5 -- 6 files changed, 72 deletions(-) delete mode 100644 src/components/shared/common/AssetFilter.tsx delete mode 100644 src/config/rQuery.ts diff --git a/bun.lockb b/bun.lockb index 9155ecc3b85bdc20453bb44d179830d4522c4b4d..741a0e00057189f2f84433246b224f78a70a8b9f 100755 GIT binary patch delta 93221 zcmeFadt6mj;`e{f(W7h(jm*^4)GU(<%PfJ=LuwYFly{{vMMObG3^b);pu5;GW;p8wO0%}*cl)ahOBdMYEQ zZAhQm<&%0=e|%x-^ zB3?%{Cmag3LmSDZGkS7DDAXP;tf(leC=7)jhb!LvvWf+T)fE+?Gr2nnFp&$1=)8h? zWhIm$1FwWD3rgpzM0ev4#NUW2!9O@HFRCb;dwx-9Gr9JJ{~1*}_oIqeTu@QAXkI83 zArs{@!(E?WGPAN;36BOG%!L}4;6fBsGuvrNMe3}%Wz$2gI@pA#!Ii<~cqKT>`2#7P z3Ov2Mps=VS6uJ_xc#WtE(lgm!ALimy!NYS1c?nFQ_OA-Qr&y>0Dh+ zPpC=@ii_q^?a(ae%TZNf*&+7spq?~IURT@J_oKS+4yTKXOA3p*UQ$uCxCl*Sgs3cu z-E2DZ3lmb;G*2k8uf4mxsI+WBN!h&eqQc^W@&y&G z{GqLThOhMtTKBBJHO1cZ{!vzE3`{L7n_E_XC0seIQs`5TV0_ub+A z)hL6ZX8y4@{v|Gcg}eR@Wm1!WYISVQ)2X)2-vX%3UqUs|Hubk{9nAdHl8U?Vv_j3M z<7^uQEh?Bfw`f7=>2zC#47l2QR$1BfqViDaDEvVh$L$F)ooi+k zEGSByIj^D>Uga$>OkKF3WNs+lR(npi{DI8i#+nX;L!mCj`zKuEm-r>~<}X};mKM!h z7#cXl#y=WW#kLVoo%lUoYtwnbaALWtirJd(K-HXYqpHCbSvG+xR0+Ham)1L7c8aZ8 z>!G$FRd{Md4d>6$Nc|UH8O@(17YbcI%=*Qs(l15Zr3MjF)$0|n4Y&QcctO!TrSLpn z5jUVJ*xkeZ2ip#+t~=dsBNa~1LN#xOI!!^Y6%$8Y0+ln3dBPR!lFe421^d*&9D^W%Wh0H}Rfy z{_`_z#_yo&@xMCVglcHq>-1))*P<#wMN#Sb8ZxsNsmtiGsblPg*^3GQv^2gW5#K;r zK5I&Yt__7ACSxV=8gW$9^6|ET8}O>Ylc=trG{Ih%rh^=?pS)mY%bIlv<=jw>p&JKn8 zqJ`&#LcP!j@oKN3Q$nG`(F5_S)^(`vyAbV$7NY9l@KhUr6kZGIAe3~6*YqKv8*iOv z>-QO|VqSx)Kvk%!@pXZ1hnaXK@Dl~p2;O~`e?emZv11v8x-AD)bL~Pk5K2o*i&AGy z550?5zdgXdrFaDumGcVclq|@AE1NkqzXs+G+Dh@>nCX9?*thx-wmXf#1GubQix_wF z7KB2(W`{yY;$KD&LmzOOg$B)lc7uOB*EUBLUdhiymCjgH=^lqF`%C88;<9<3AL6qI zPj6S(%$!@WU_sH0P)`@}nKGLJOI>g>Dkz^dzo5LL=rOp4&93=Y-$ykcFFen-N6D;t zW#!aeTYSOXiqO%FQMFT1#r*Oj_^r+t6qc2fcc@^t%E9>kU4$Qw3ZjUu07_s+QPF(1qtH|C#tmq|%L*5im&_|Fs?btg5t>;tZ$|3E`JvmH?aFYK z)A^}$%L-Y)(UQ`V1))_J*y}|V=PfKMuMDw8&z(a7axY}Wt45q4Q|X3T=aP|z^8AZJ zp(D_*(4J`eMgFj)zSZ3*tn!&rQXU)_Z^Wxy6>|$JbZ8n)^j`SBs0Qw#sCq0uWPaIT zKQj~6gKI9aLuNUup)wcMgcy!0oi{HFh5BfyJV-!ea+w7+rRd@K9P|kEXjJ9(P)*%$ zFSSD{Sg9!BX?QjC(j`{6qsm}G`H6$L9C`(>0=-K-RrH%$TY<`gg%`}#P+7Fpf_bQF zKHqK)q0fk@gy%0TWmJYj*I#85;t*NLtPbV;-WFsqstk@t)iOsojUPkf>2!lD{jzyQ z3z*&^=6FeAHT&_!u8GRXSs8J3UsN%dakh(_H~BxY-u;GqfK( zAFjzZaE)C^yQAuYsM8m2u?y>m=;82_tGPiXJPK7orrqXRV7aZ?4!ml3>g~2Ere|Kju>R9#(d2⁣bz0{1 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 delta 93649 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 BaeV*) diff --git a/package.json b/package.json index f5a144f..f9e2431 100644 --- a/package.json +++ b/package.json @@ -56,16 +56,13 @@ "next-themes": "^0.3.0", "pg": "^8.12.0", "qs": "^6.13.0", - "rc-mentions": "^2.18.0", "react": "^18", - "react-calendar-heatmap": "^1.9.0", "react-day-picker": "9.0.8", "react-dom": "^18", "react-grid-gallery": "^1.0.1", "react-hot-toast": "^2.5.1", "react-leaflet": "^4.2.1", "react-mentions": "^4.4.10", - "react-photo-album": "^3.0.2", "react-query": "^3.39.3", "recharts": "^2.12.7", "remotion": "4.0.232", diff --git a/src/components/layouts/RootLayout.tsx b/src/components/layouts/RootLayout.tsx index 692fc9a..f5bbbab 100644 --- a/src/components/layouts/RootLayout.tsx +++ b/src/components/layouts/RootLayout.tsx @@ -9,8 +9,6 @@ import { ArrowRight, TriangleAlert } from "lucide-react"; import Link from "next/link"; import { LoginForm } from "../auth/LoginForm"; import { useConfig } from "@/contexts/ConfigContext"; -import { queryClient } from "@/config/rQuery"; -import { QueryClientProvider } from "react-query"; import { Toaster } from "react-hot-toast"; import { useRouter } from "next/router"; import PageLayout from "./PageLayout"; @@ -91,18 +89,15 @@ export default function RootLayout({ children }: RootLayoutProps) { if (pathname.startsWith("/s/")) { return ( -
{children}
-
) } if (!user) return ; return ( -
- ); } diff --git a/src/components/people/PeopleFilters.tsx b/src/components/people/PeopleFilters.tsx index 66e8205..c64a39c 100644 --- a/src/components/people/PeopleFilters.tsx +++ b/src/components/people/PeopleFilters.tsx @@ -14,7 +14,6 @@ import { } from "../ui/dropdown-menu"; import { Input } from "../ui/input"; import { IPersonListFilters } from "@/handlers/api/people.handler"; -import AssetFilter from "../shared/common/AssetFilter"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; export function PeopleFilters() { diff --git a/src/components/shared/common/AssetFilter.tsx b/src/components/shared/common/AssetFilter.tsx deleted file mode 100644 index bff8f0e..0000000 --- a/src/components/shared/common/AssetFilter.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Combobox } from '@/components/ui/combobox'; -import { getFilters } from '@/handlers/api/common.handler'; -import React, { useMemo, useState } from 'react' -import { useQuery } from 'react-query'; - -const fields = [ - "make", - "model", - "lensModel", - "city", - "state", - "country", - "projectionType", - "colorspace", - "bitsPerSample", - "rating", -]; - -const formattedFields = fields.map(field => ({ - label: field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()), - value: field -})); - - -export default function AssetFilter() { - const [selectedField, setSelectedField] = useState(null); - - const { data } = useQuery('filters', getFilters) - - - const options = useMemo(() => { - if (!selectedField) return formattedFields; - const { filters } = data; - - if (!filters) return formattedFields; - const field = filters[selectedField]; - if (!field) return []; - return field.map((value: string) => ({ - label: value, - value - })); - }, [selectedField, data]); - - return ( - { - if (!selectedField) setSelectedField(value); - - }} - onOpenChange={(open) => { - if (!open) setSelectedField(null); - }} - /> - ) -} diff --git a/src/config/rQuery.ts b/src/config/rQuery.ts deleted file mode 100644 index 3118e51..0000000 --- a/src/config/rQuery.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { - QueryClient, -} from 'react-query' - -export const queryClient = new QueryClient() \ No newline at end of file From e7687b9b2b0fea487a022d0d1d7b55e0e4ca4a82 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 19 Jan 2025 11:18:37 +0530 Subject: [PATCH 22/22] fix: minor condition fix Signed-off-by: Varun Raj --- src/pages/albums/potential-albums.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/albums/potential-albums.tsx b/src/pages/albums/potential-albums.tsx index 8cbb392..e11536e 100644 --- a/src/pages/albums/potential-albums.tsx +++ b/src/pages/albums/potential-albums.tsx @@ -126,7 +126,7 @@ export default function PotentialAlbums() { {config.selectedIds.length} Selected

- {config.selectedIds.length && config.selectedIds.length === config.assets.length ? ( + {config.selectedIds.length && config.selectedIds.length > 0 ? (