fix: unified asset grid across the tool

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2025-08-23 16:50:01 +05:30
parent d443da7df2
commit f1e0130232
5 changed files with 85 additions and 319 deletions
+9 -1
View File
@@ -28,4 +28,12 @@ You can find the API route here `pages/api/immich-proxy/[...path].ts`
Since the `thumbnailPath` of Immich API doesnt give us a direct link to the image, I've created a proxy api that fetches the image and sends it back as a response. Since the `thumbnailPath` of Immich API doesnt give us a direct link to the image, I've created a proxy api that fetches the image and sends it back as a response.
**For Person's Thumbnail** **For Person's Thumbnail**
You can find the API route here `pages/api/immich-proxy/thumbnail/[id].ts` You can find the API route here `pages/api/immich-proxy/thumbnail/[id].ts`
### Branching Strategy
- Always create a new branch for your changes
- Always rebase your branch before creating a PR
- Always squash your commits before merging
- Always merge your branch into the `prerelease` branch
- Always delete your branch after merging
+3 -1
View File
@@ -61,6 +61,8 @@ GEMINI_API_KEY= # Gemini API Key
``` ```
Refer here for obtaining Immich API Key: https://immich.app/docs/features/command-line-interface#obtain-the-api-key Refer here for obtaining Immich API Key: https://immich.app/docs/features/command-line-interface#obtain-the-api-key
> [!NOTE] When creating the API key, make sure you select all the permissions for the API key.
#### Method 2 - Portainer #### Method 2 - Portainer
If you're using portainer, run the docker using `docker run` and add the power tools to the same network as immich. If you're using portainer, run the docker using `docker run` and add the power tools to the same network as immich.
@@ -149,4 +151,4 @@ Google Gemini 1.5 Flash model is used for parsing your search query in "Find" pa
## Contributing ## 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. 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.
+30 -104
View File
@@ -1,19 +1,15 @@
import "yet-another-react-lightbox/styles.css";
import { listAlbumAssets } from "@/handlers/api/album.handler"; import { listAlbumAssets } from "@/handlers/api/album.handler";
import { useConfig } from "@/contexts/ConfigContext"; import { useConfig } from "@/contexts/ConfigContext";
import { IAlbum, IAlbumPerson } from "@/types/album"; import { IAlbum, IAlbumPerson } from "@/types/album";
import { IAsset } from "@/types/asset"; import { IAsset } from "@/types/asset";
import React, { useEffect, useMemo, useRef, useState, MouseEvent } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Hourglass } from "lucide-react"; import { Hourglass } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Gallery } from "react-grid-gallery";
import Lightbox from "yet-another-react-lightbox";
import Captions from "yet-another-react-lightbox/plugins/captions";
import LazyGridImage from "@/components/ui/lazy-grid-image";
import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext";
import { PERSON_THUBNAIL_PATH } from "@/config/routes"; import { PERSON_THUBNAIL_PATH } from "@/config/routes";
import Image from "next/image"; import Image from "next/image";
import AssetGrid from "@/components/shared/AssetGrid";
interface AlbumImagesProps { interface AlbumImagesProps {
album: IAlbum; album: IAlbum;
@@ -40,8 +36,6 @@ export default function AlbumImages({ album, selectedPerson }: AlbumImagesProps)
page: 1, page: 1,
}); });
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [index, setIndex] = useState(-1);
const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
const fetchAssets = async () => { const fetchAssets = async () => {
setLoading(true); setLoading(true);
@@ -58,74 +52,24 @@ export default function AlbumImages({ album, selectedPerson }: AlbumImagesProps)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
const images = useMemo(() => { const processedAssets = useMemo(() => {
return assets.map((p) => ({ return assets.map((asset) => ({
...p, ...asset,
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),
orientation: 1,
tags: [ tags: [
{ {
title: "Immich Link", title: "Immich Link",
value: ( value: (
<a href={exImmichUrl + "/photos/" + p.id} target="_blank" rel="noopener noreferrer"> <a href={exImmichUrl + "/photos/" + asset.id} target="_blank" rel="noopener noreferrer">
Open in Immich Open in Immich
</a> </a>
), ),
}, },
], ],
})); }));
}, [assets, selectedIds, exImmichUrl]); }, [assets, exImmichUrl]);
const slides = useMemo( const handleSelectionChange = (ids: string[]) => {
() => updateContext({ selectedIds: ids });
images.map(({ original, width, height }) => ({
src: original,
width,
height,
})),
[images]
);
const handleClick = (idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
if (selectedIds.length > 0) {
handleSelect(idx, asset, event);
} else {
setIndex(idx);
}
};
const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
const isPresent = selectedIds.includes(asset.id);
if (isPresent) {
updateContext({
selectedIds: selectedIds.filter((id) => id !== asset.id),
});
} else {
const clickedIndex = images.findIndex((image) => {
return image.id === asset.id;
});
if (event.shiftKey && lastSelectedIndex !== -1) {
const startIndex = Math.min(clickedIndex, lastSelectedIndex);
const endIndex = Math.max(clickedIndex, lastSelectedIndex);
if (startIndex >= 0 && endIndex < images.length) {
const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id);
const allSelectedIds = [...selectedIds, ...newSelectedIds];
const uniqueSelectedIds = [...new Set(allSelectedIds)];
updateContext({ selectedIds: uniqueSelectedIds });
} else {
console.warn("Shift-select index out of bounds");
updateContext({ selectedIds: [...selectedIds, asset.id] });
}
} else {
updateContext({ selectedIds: [...selectedIds, asset.id] });
}
setLastSelectedIndex(clickedIndex);
}
}; };
useEffect(() => { useEffect(() => {
@@ -160,46 +104,28 @@ export default function AlbumImages({ album, selectedPerson }: AlbumImagesProps)
} }
return ( return (
<> <div className="w-full p-2 overflow-y-auto max-h-[calc(100vh-60px)]">
<AssetGrid
<Lightbox assets={processedAssets}
slides={slides} isInternal={true}
plugins={[Captions]} selectable={true}
open={index >= 0} onSelectionChange={handleSelectionChange}
index={index}
close={() => setIndex(-1)}
/> />
<div className="w-full p-2 overflow-y-auto max-h-[calc(100vh-60px)]"> {hasMore && (
<Gallery <Button
images={images} variant="outline"
onClick={handleClick} className="w-full mt-4"
enableImageSelection={true} onClick={() => {
onSelect={handleSelect} setFilters({
thumbnailImageComponent={LazyGridImage} ...filters,
tagStyle={{ page: filters.page + 1,
color: "white", });
fontSize: "12px",
backgroundColor: "rgba(0, 0, 0)",
padding: "2px",
borderRadius: "5px",
}} }}
/> disabled={loading}
{hasMore && ( >
<Button {loading ? "Loading..." : "Load More"}
variant="outline" </Button>
className="w-full mt-4" )}
onClick={() => { </div>
setFilters({
...filters,
page: filters.page + 1,
});
}}
disabled={loading}
>
{loading ? "Loading..." : "Load More"}
</Button>
)}
</div>
</>
); );
} }
@@ -1,15 +1,10 @@
import "yet-another-react-lightbox/styles.css";
import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext";
import { listPotentialAlbumsAssets } from "@/handlers/api/album.handler"; import { listPotentialAlbumsAssets } from "@/handlers/api/album.handler";
import type { IAsset } from "@/types/asset"; import type { IAsset } from "@/types/asset";
import React, { type MouseEvent, useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } 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 { CalendarArrowUp, Hourglass } from "lucide-react"; import { CalendarArrowUp, Hourglass } from "lucide-react";
import Video from "yet-another-react-lightbox/plugins/video";
import { useConfig } from "@/contexts/ConfigContext"; import { useConfig } from "@/contexts/ConfigContext";
import LazyGridImage from "@/components/ui/lazy-grid-image"; import AssetGrid from "@/components/shared/AssetGrid";
export default function PotentialAlbumsAssets() { export default function PotentialAlbumsAssets() {
const { exImmichUrl } = useConfig(); const { exImmichUrl } = useConfig();
@@ -24,9 +19,6 @@ export default function PotentialAlbumsAssets() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [index, setIndex] = useState(-1);
const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
const fetchAssets = async () => { const fetchAssets = async () => {
if (!startDate) return; if (!startDate) return;
setLoading(true); setLoading(true);
@@ -40,7 +32,7 @@ export default function PotentialAlbumsAssets() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
const images = useMemo(() => { const processedAssets = useMemo(() => {
const sortedAssets = [...assets].sort((a, b) => { const sortedAssets = [...assets].sort((a, b) => {
try { try {
return new Date(a.dateTimeOriginal ?? 0).getTime() - new Date(b.dateTimeOriginal ?? 0).getTime(); return new Date(a.dateTimeOriginal ?? 0).getTime() - new Date(b.dateTimeOriginal ?? 0).getTime();
@@ -49,90 +41,23 @@ export default function PotentialAlbumsAssets() {
} }
}); });
return sortedAssets.map((p) => ({ return sortedAssets.map((asset) => ({
...p, ...asset,
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),
orientation: 1,
tags: [ tags: [
{ {
title: "Immich Link", title: "Immich Link",
value: ( value: (
<a href={`${exImmichUrl}/photos/${p.id}`} target="_blank" rel="noreferrer"> <a href={`${exImmichUrl}/photos/${asset.id}`} target="_blank" rel="noreferrer">
Open in Immich Open in Immich
</a> </a>
), ),
}, },
], ],
})); }));
}, [assets, selectedIds, exImmichUrl]); }, [assets, exImmichUrl]);
const slides = useMemo(
() =>
images.map(({ original, width, height, type, videoURL}) => ({
src: original,
width,
height,
type: (type === "VIDEO" ? "video" : "image") as any,
sources:
type === "VIDEO"
? [
{
src: videoURL,
type: "video/mp4",
},
]
: [],
})),
[images]
);
const handleClick = (idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
if (selectedIds.length > 0 && (event.ctrlKey || event.metaKey || event.shiftKey)) {
handleSelect(idx, asset, event);
} else {
setIndex(idx);
}
};
const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
const isPresent = selectedIds.includes(asset.id);
if (isPresent) {
if (event.ctrlKey || event.metaKey || selectedIds.length === 1) {
updateContext({
selectedIds: selectedIds.filter((id) => id !== asset.id),
});
} else if (!event.shiftKey) {
updateContext({ selectedIds: [asset.id] });
}
} else {
const clickedIndex = images.findIndex((image) => image.id === asset.id);
if (event.shiftKey && lastSelectedIndex !== -1) {
const startIndex = Math.min(clickedIndex, lastSelectedIndex);
const endIndex = Math.max(clickedIndex, lastSelectedIndex);
if (startIndex >= 0 && endIndex < images.length) {
const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id);
const combined = event.ctrlKey || event.metaKey ? [...selectedIds, ...newSelectedIds] : newSelectedIds;
const uniqueSelectedIds = [...new Set(combined)];
updateContext({ selectedIds: uniqueSelectedIds });
} else {
updateContext({ selectedIds: [...selectedIds, asset.id] });
}
} else if (event.ctrlKey || event.metaKey) {
updateContext({ selectedIds: [...selectedIds, asset.id] });
} else {
updateContext({ selectedIds: [asset.id] });
}
setLastSelectedIndex(clickedIndex);
}
const handleSelectionChange = (ids: string[]) => {
updateContext({ selectedIds: ids });
}; };
useEffect(() => { useEffect(() => {
@@ -158,33 +83,15 @@ export default function PotentialAlbumsAssets() {
</p> </p>
</div> </div>
); );
return ( return (
<> <div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]">
<Lightbox <AssetGrid
slides={slides} assets={processedAssets}
plugins={[Captions, Video]} isInternal={true}
open={index >= 0} selectable={true}
index={index} onSelectionChange={handleSelectionChange}
close={() => setIndex(-1)}
/> />
<div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]"> </div>
<Gallery
images={images}
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
thumbnailImageComponent={LazyGridImage}
rowHeight={220}
margin={3}
tagStyle={{
color: "white",
fontSize: "12px",
backgroundColor: "rgba(0, 0, 0, 0.7)",
padding: "2px 4px",
borderRadius: "3px",
}}
/>
</div>
</>
); );
} }
@@ -1,9 +1,5 @@
import "yet-another-react-lightbox/styles.css";
import { IAsset } from "@/types/asset"; import { IAsset } from "@/types/asset";
import React, { useEffect, useMemo, useState, MouseEvent } from "react"; import React, { useEffect, useMemo, useState } 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 { import {
CalendarArrowUp, CalendarArrowUp,
Hourglass, Hourglass,
@@ -11,7 +7,7 @@ import {
import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext";
import { listMissingLocationAssets } from "@/handlers/api/asset.handler"; import { listMissingLocationAssets } from "@/handlers/api/asset.handler";
import { useConfig } from "@/contexts/ConfigContext"; import { useConfig } from "@/contexts/ConfigContext";
import LazyGridImage from "@/components/ui/lazy-grid-image"; import AssetGrid from "@/components/shared/AssetGrid";
interface IProps { interface IProps {
groupBy: "date" | "album"; groupBy: "date" | "album";
@@ -30,9 +26,6 @@ export default function MissingLocationAssets({ groupBy }: IProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [index, setIndex] = useState(-1);
const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
const fetchAssets = async () => { const fetchAssets = async () => {
if (!startDate && !albumId) return; if (!startDate && !albumId) return;
setLoading(true); setLoading(true);
@@ -46,7 +39,7 @@ export default function MissingLocationAssets({ groupBy }: IProps) {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
const images = useMemo(() => { const processedAssets = useMemo(() => {
let sortedAssets: IAsset[]; let sortedAssets: IAsset[];
try { try {
if (sortOrder === "desc") if (sortOrder === "desc")
@@ -58,76 +51,23 @@ export default function MissingLocationAssets({ groupBy }: IProps) {
sortedAssets = [...assets]; sortedAssets = [...assets];
} }
return sortedAssets. return sortedAssets.map((asset) => ({
map((p) => ({ ...asset,
...p, tags: [
src: p.url as string, {
original: p.previewUrl as string, title: "Immich Link",
width: p.exifImageWidth as number, value: (
height: p.exifImageHeight as number, <a href={exImmichUrl + "/photos/" + asset.id} target="_blank" rel="noopener noreferrer">
isSelected: selectedIds.includes(p.id), Open in Immich
orientation: 1, </a>
tags: [ ),
{ },
title: "Immich Link", ],
value: ( }));
<a href={exImmichUrl + "/photos/" + p.id} target="_blank" rel="noopener noreferrer"> }, [assets, sortOrder, exImmichUrl]);
Open in Immich
</a>
),
},
],
}));
}, [assets, selectedIds, sortOrder, exImmichUrl]);
const slides = useMemo( const handleSelectionChange = (ids: string[]) => {
() => updateContext({ selectedIds: ids });
images.map(({ original, width, height }) => ({
src: original,
width,
height,
})),
[images]
);
const handleClick = (idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
if (selectedIds.length > 0) {
handleSelect(idx, asset, event);
}
else
{
setIndex(idx);
}
};
const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
const isPresent = selectedIds.includes(asset.id);
if (isPresent) {
updateContext({
selectedIds: selectedIds.filter((id) => id !== asset.id),
});
} else {
const clickedIndex = images.findIndex((image) => {
return image.id === asset.id;
});
if (event.shiftKey && lastSelectedIndex !== -1) {
const startIndex = Math.min(clickedIndex, lastSelectedIndex);
const endIndex = Math.max(clickedIndex, lastSelectedIndex);
if (startIndex >= 0 && endIndex < images.length) {
const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id);
const allSelectedIds = [...selectedIds, ...newSelectedIds];
const uniqueSelectedIds = [...new Set(allSelectedIds)];
updateContext({ selectedIds: uniqueSelectedIds });
} else {
console.warn("Shift-select index out of bounds");
updateContext({ selectedIds: [...selectedIds, asset.id] });
}
} else {
updateContext({ selectedIds: [...selectedIds, asset.id] });
}
setLastSelectedIndex(clickedIndex);
}
}; };
useEffect(() => { useEffect(() => {
@@ -155,30 +95,13 @@ export default function MissingLocationAssets({ groupBy }: IProps) {
); );
return ( return (
<> <div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]">
<Lightbox <AssetGrid
slides={slides} assets={processedAssets}
plugins={[Captions]} isInternal={true}
open={index >= 0} selectable={true}
index={index} onSelectionChange={handleSelectionChange}
close={() => setIndex(-1)}
/> />
<div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]"> </div>
<Gallery
images={images}
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
thumbnailImageComponent={LazyGridImage}
tagStyle={{
color: "white",
fontSize: "12px",
backgroundColor: "rgba(0, 0, 0)",
padding: "2px",
borderRadius: "5px",
}}
/>
</div>
</>
); );
} }