feat: people selection in share link

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2025-01-18 13:41:35 +05:30
parent 34f32e4906
commit 1fea9ccaee
16 changed files with 322 additions and 68 deletions
@@ -195,7 +195,6 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr
/>
)}
<LazyImage
role="button"
className={
cn("cursor-pointer h-10 w-10 min-w-10 rounded-full border-2",
@@ -8,6 +8,7 @@ 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'
import ShareAssetsTrigger from '@/components/shared/ShareAssetsTrigger';
export interface IAlbumShareDialogProps {
@@ -169,15 +170,7 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded
Generate For {selectedAlbums.length} albums
</Button>
</AlertDialog>
<AlertDialog
title="Generate a single share link"
description="Immich Power Tools will work like a proxy for all your albums using single share link."
onConfirm={handleGenerateGlobalShareLink}
>
<Button disabled={generating}>
Generate 1 Share Link
</Button>
</AlertDialog>
<ShareAssetsTrigger filters={{ albumIds: selectedAlbums.map((album) => album.id) }} />
</div>
)}
</>
-2
View File
@@ -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,
+48
View File
@@ -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 (
<div className="flex flex-col overflow-x-hidden gap-2">
{people.map((person) => (
<div
className={cn(
'flex items-center gap-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700 rounded-md p-1',
isSelected(person) ? "bg-gray-300 dark:bg-gray-700" : ""
)}
key={person.id}
onClick={() => onSelect(person)}
>
<LazyImage
role="button"
className={
cn("cursor-pointer h-10 w-10 min-w-10 rounded-full border-2",
!!person.name ? "border-green-500" : "border-gray-500",
)
}
src={person.thumbnailPath} alt={person.name} />
<div className='flex flex-col text-sm'>
<p>{person.name || "No Name"}</p>
<p className={
cn("text-xs text-gray-500 dark:text-gray-400")
}>{person.assetCount} photos</p>
</div>
</div>
))}
</div>
)
}
+21 -4
View File
@@ -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<string | null>(null);
const [copied, setCopied] = useState(false);
const [config, setConfig] = useState<Partial<ShareLinkFilters>>({});
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
<DialogTitle>Share Assets</DialogTitle>
</DialogHeader>
<DialogDescription>
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.
</DialogDescription>
{errorMessage && <div className="text-red-500">{errorMessage}</div>}
{generatedLink ? <div className="flex flex-col gap-2">
@@ -59,9 +62,23 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets
<p className='text-xs text-muted-foreground'>
This is a stateless link, it will not work if you leave the page. Which means it cannot be expired.
</p>
<Button onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</Button>
<div className="flex gap-2">
<Button className="w-full" onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</Button>
<Button className="w-full" variant="outline" onClick={handleGenerate}>Generate New Link</Button>
</div>
</div> : (
<Button onClick={handleGenerate} disabled={loading}>Generate Share Link</Button>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-1">
<div className="flex flex-col gap-1">
<Label className="text-sm m-0">Show People</Label>
<p className="text-xs text-muted-foreground m-0">
Show the list of people in the shared photos
</p>
</div>
<Switch checked={config.p} onCheckedChange={(checked) => setConfig({ ...config, p: !!checked })} />
</div>
<Button onClick={handleGenerate} disabled={loading}>Generate Share Link</Button>
</div>
)}
</DialogContent>
</Dialog>
+4 -1
View File
@@ -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";
+12 -4
View File
@@ -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));
}
+4 -4
View File
@@ -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 }),
}
}
@@ -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
@@ -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
});
}
+21
View File
@@ -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);
}
@@ -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);
}
+1 -1
View File
@@ -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}` });
}
-1
View File
@@ -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
+100 -23
View File
@@ -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<IAsset[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [people, setPeople] = useState<IPerson[]>([])
const [peopleLoading, setPeopleLoading] = useState(false)
const [config, setConfig] = useState<ShareLinkFilters>({})
const [filters, setFilters] = useState<ShareLinkFilters | null>(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 <Loader />
}
else if (errorMessage) {
return <div>{errorMessage}</div>
}
const renderContent = () => {
if (loading) return <Loader />
else if (errorMessage) return <div>{errorMessage}</div>
return (
<AssetGrid
assets={assets}
isInternal={false}
/>
<div className="flex gap-1 max-h-full">
{config.p && (
<div className="w-1/6 overflow-y-auto sticky top-0">
{peopleLoading ? <Loader /> : <PeopleList
people={people}
onSelect={handleSelectPerson}
selectedIds={query.personIds as string[]}
/>
}
</div>
)}
<div className={clsx("overflow-y-auto sticky top-0", config.p ? "w-5/6" : "w-full")}>
{loading ? <Loader /> : <AssetGrid
assets={assets}
isInternal={false}
/>}
</div>
</div>
)
}
return (
+1
View File
@@ -3,4 +3,5 @@ export interface ShareLinkFilters {
albumIds?: string[];
startDate?: string;
endDate?: string;
p?: boolean;
}