feat: added image gallery to album assets

This commit is contained in:
Varun Raj
2025-04-27 15:24:55 +05:30
parent 094a2a6e57
commit b0075082e2
5 changed files with 164 additions and 190 deletions
+76 -119
View File
@@ -1,110 +1,84 @@
import "yet-another-react-lightbox/styles.css";
import { listAlbumAssets } from '@/handlers/api/album.handler';
import { useConfig } from '@/contexts/ConfigContext';
import { IAlbum } from '@/types/album'
import { IAsset } from '@/types/asset'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { ExternalLink, Hourglass } from 'lucide-react';
import { useRouter } from 'next/router';
import Lightbox from 'yet-another-react-lightbox';
import Captions from 'yet-another-react-lightbox/plugins/captions';
import LazyImage from '@/components/ui/lazy-image';
import { listAlbumAssets } from "@/handlers/api/album.handler";
import { useConfig } from "@/contexts/ConfigContext";
import { IAlbum } from "@/types/album";
import { IAsset } from "@/types/asset";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Hourglass } from "lucide-react";
import { useRouter } from "next/router";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import AssetGrid from "@/components/shared/AssetGrid";
import PhotoSelectionContext, {
IPhotoSelectionContext,
} from "@/contexts/PhotoSelectionContext";
interface AlbumImagesProps {
album: IAlbum
album: IAlbum;
}
interface IAssetFilter {
faceId?: string
page: number
faceId?: string;
page: number;
}
export default function AlbumImages({ album }: AlbumImagesProps) {
const { exImmichUrl } = useConfig();
const router = useRouter();
const { faceId } = router.query as { faceId: string };
const [assets, setAssets] = useState<IAsset[]>([])
const [assets, setAssets] = useState<IAsset[]>([]);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [filters, setFilters] = useState<IAssetFilter>({
faceId,
page: 1
})
const [hasMore, setHasMore] = useState(true)
page: 1,
});
const [hasMore, setHasMore] = useState(true);
const [index, setIndex] = useState(-1);
// Initialize context state
const [contextState, setContextState] = useState<IPhotoSelectionContext>({
selectedIds: [],
assets: [],
config: {},
updateContext: (newConfig: Partial<IPhotoSelectionContext>) => {
setContextState((prevState) => ({
...prevState,
...newConfig,
// No deep merge needed for config here as it's simple
config: newConfig.config
? { ...prevState.config, ...newConfig.config }
: prevState.config,
}));
},
});
const fetchAssets = async () => {
setLoading(true);
return listAlbumAssets(album.id, filters)
.then((assets) => {
.then((newAssets) => {
if (filters.page === 1) {
setAssets(assets)
setAssets(newAssets);
} else {
setAssets([...assets, ...assets])
setAssets((prevAssets) => [...prevAssets, ...newAssets]);
}
setHasMore(assets.length >= 100)
setHasMore(newAssets.length === 100);
})
.catch(setErrorMessage)
.finally(() => setLoading(false));
};
const images = useMemo(() => {
return assets.map((p) => ({
...p,
src: p.url as string,
original: p.previewUrl as string,
width: p.exifImageWidth as number,
height: p.exifImageHeight as number,
// isSelected: selectedIds.includes(p.id),
tags: [
{
title: "Immich Link",
value: (
<a href={exImmichUrl + "/photos/" + p.id} target="_blank">
Open in Immich
</a>
),
},
],
}));
}, [assets]);
const slides = useMemo(
() =>
images.map(({ original, width, height }) => ({
src: original,
width,
height,
})),
[images]
);
const handleClick = (idx: number) => setIndex(idx);
// const handleSelect = (_idx: number, asset: IAsset) => {
// const isPresent = selectedIds.includes(asset.id);
// if (isPresent) {
// updateContext({
// selectedIds: selectedIds.filter((id) => id !== asset.id),
// });
// } else {
// updateContext({ selectedIds: [...selectedIds, asset.id] });
// }
// };
useEffect(() => {
fetchAssets();
if (album.id) {
fetchAssets();
}
}, [album.id, filters]);
useEffect(() => {
setFilters({
faceId,
page: 1
})
}, [faceId])
page: 1,
});
setAssets([]);
setHasMore(true);
}, [faceId]);
if (loading && filters.page === 1)
return (
@@ -114,53 +88,36 @@ export default function AlbumImages({ album }: AlbumImagesProps) {
</div>
);
return (
<div>
<Lightbox
slides={slides}
plugins={[Captions]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
/>
<div className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2 p-2">
{images.map((image) => (
<div
key={image.id}
className="w-full h-[180px] overflow-hidden relative"
>
<LazyImage
loading="lazy"
key={image.id}
src={image.original}
alt={image.originalFileName}
className='overflow-hidden max-h-[180px] max-w-[180px] min-h-[180px] min-w-[180px]'
style={{
objectPosition: 'center',
objectFit: 'cover'
}}
onClick={() => handleClick(images.indexOf(image))}
/>
<Link
className="absolute top-2 right-2 bg-white dark:bg-zinc-900 p-1 py-0.5 rounded-md flex items-center gap-1 text-xs"
target="_blank"
href={`${exImmichUrl}/photos/${image.id}`}>
<ExternalLink className="w-3 h-3" />
Open In Immich
</Link>
</div>
))}
if (errorMessage) {
return (
<div className="p-4 text-red-500">
Error loading images: {errorMessage}
</div>
{hasMore && <Button variant="outline" className='w-full' onClick={() => {
setFilters({
...filters,
page: filters.page + 1
})
}}
disabled={loading}
>
Load More
</Button>}
</div>
);
}
return (
<PhotoSelectionContext.Provider
value={{ ...contextState, updateContext: contextState.updateContext }}
>
<div className="w-full p-2">
<AssetGrid assets={assets} />
{hasMore && (
<Button
variant="outline"
className="w-full mt-4"
onClick={() => {
setFilters({
...filters,
page: filters.page + 1,
});
}}
disabled={loading}
>
{loading ? "Loading..." : "Load More"}
</Button>
)}
</div>
</PhotoSelectionContext.Provider>
);
}
-1
View File
@@ -66,7 +66,6 @@ export default function AlbumListPage() {
}
const renderContent = () => {
if (loading) {
return <Loader />
}
+17 -1
View File
@@ -7,6 +7,8 @@ import { and, eq, isNotNull } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { assetFaces, exif, person } from "@/schema";
import { isFlipped } from "@/helpers/asset.helper";
import { ASSET_VIDEO_PATH } from "@/config/routes";
export default async function handler(
req: NextApiRequest,
@@ -41,6 +43,7 @@ export default async function handler(
exifImageHeight: exif.exifImageHeight,
ownerId: assets.ownerId,
dateTimeOriginal: exif.dateTimeOriginal,
orientation: exif.orientation,
})
.from(albumsAssetsAssets)
.leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id))
@@ -56,5 +59,18 @@ export default async function handler(
.limit(100)
.offset(100 * (page - 1));
res.status(200).json(dbAssets);
const cleanedAssets = dbAssets.map((asset) => {
return {
...asset,
exifImageHeight: isFlipped(asset?.orientation)
? asset?.exifImageWidth
: asset?.exifImageHeight,
exifImageWidth: isFlipped(asset?.orientation)
? asset?.exifImageHeight
: asset?.exifImageWidth,
orientation: asset?.orientation,
downloadUrl: asset?.id ? ASSET_VIDEO_PATH(asset.id) : null,
};
});
res.status(200).json(cleanedAssets);
}
+70 -58
View File
@@ -1,70 +1,82 @@
import { db } from '@/config/db';
import { ENV } from '@/config/environment';
import { ASSET_VIDEO_PATH } from '@/config/routes';
import { getCurrentUser } from '@/handlers/serverUtils/user.utils';
import { cleanUpAsset, isFlipped } from '@/helpers/asset.helper';
import { parseFindQuery } from '@/helpers/gemini.helper';
import { person } from '@/schema';
import { Person } from '@/schema/person.schema';
import { inArray } from 'drizzle-orm';
import { NextApiRequest, NextApiResponse } from 'next';
import { db } from "@/config/db";
import { ENV } from "@/config/environment";
import { ASSET_VIDEO_PATH } from "@/config/routes";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { cleanUpAsset, isFlipped } from "@/helpers/asset.helper";
import { parseFindQuery } from "@/helpers/gemini.helper";
import { person } from "@/schema";
import { Person } from "@/schema/person.schema";
import { inArray } from "drizzle-orm";
import { NextApiRequest, NextApiResponse } from "next";
export default async function search(req: NextApiRequest, res: NextApiResponse) {
export default async function search(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const { query } = req.body;
const currentUser = await getCurrentUser(req);
const currentUser = await getCurrentUser(req);
const parsedQuery = await parseFindQuery(query as string);
const { personIds } = parsedQuery;
let dbPeople: Person[] = [];
if (personIds) {
dbPeople = await db
.select()
.from(person)
.where(inArray(person.id, personIds));
}
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, withExif: true }),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentUser?.accessToken}`
}
}).then(response => {
if (response.status !== 200) {
return res.json({
assets: [],
filters: parsedQuery,
error: "Failed to fetch assets",
errorData: response.json()
});
}
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,
downloadUrl: ASSET_VIDEO_PATH(item.id)
}
}).map(cleanUpAsset)
res.status(200).json({
assets: items,
filters: {
...parsedQuery,
personIds: dbPeople.map((person) => person.name)
return fetch(url, {
method: "POST",
body: JSON.stringify({ ...parsedQuery, withExif: true }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${currentUser?.accessToken}`,
},
});
}).catch(err => {
res.status(500).json({ message: err.message });
});
})
.then((response) => {
if (response.status !== 200) {
return res.json({
assets: [],
filters: parsedQuery,
error: "Failed to fetch assets",
errorData: response.json(),
});
}
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,
downloadUrl: ASSET_VIDEO_PATH(item.id),
};
})
.map(cleanUpAsset);
res.status(200).json({
assets: items,
filters: {
...parsedQuery,
personIds: dbPeople.map((person) => person.name),
},
});
})
.catch((err) => {
res.status(500).json({ message: err.message });
});
} catch (err: any) {
console.error(err);
res.status(500).json({ message: err.message || "Failed to parse query" });
}
}
}
+1 -11
View File
@@ -72,17 +72,7 @@ export default function FindPage() {
const updateContext = contextState.updateContext;
// Derive slides from contextState.assets
const slides = useMemo(
() =>
contextState.assets.map((asset) => ({
src: ASSET_PREVIEW_PATH(asset.id),
width: 1000,
height: 1000,
})),
[contextState.assets]
);
const appliedFilters: {
label: string;
value: string;