feat: added image gallery to album assets
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ export default function AlbumListPage() {
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
|
||||
if (loading) {
|
||||
return <Loader />
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user