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 70% 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,61 @@ 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 fetchAlbumInfo = async () => { + + 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) - getShareLinkInfo(token) - .then(setAssets) + getShareLinkAssets(token, filters) + .then(({ assets }) => { + setAssets(assets) + }) .catch((error) => { setErrorMessage(error.message) }) @@ -33,23 +64,69 @@ export default function AlbumListPage() { }) } + const fetchAlbumInfo = async () => { + setLoading(true) + getShareLinkInfo(token) + .then((conf) => { + setConfig(conf) + if (conf.p) { + fetchPeople() + } + setFilters({ + personIds: query.personIds as string[] + }) + }) + .catch((error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setLoading(false) + }) + } + + 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