diff --git a/README.md b/README.md
index 00e6057..1eb62e4 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,12 @@ A unofficial immich client to provide better tools to organize and manage your i
[](https://www.loom.com/embed/13aa90d8ab2e4acab0993bdc8703a750?sid=71498690-b745-473f-b239-a7bdbe6efc21)
+### Support me 🙏
+
+If you find this tool useful, please consider supporting me by buying me a coffee.
+
+[](https://www.buymeacoffee.com/varunraj)
+
## 💭 Back story
Recently I've migrated my entire Google photos library to Immich, I was able to successfully migrate all my assets along with its albums to immich. But there were few things like people match that was lacking. I loved Immich UI on the whole but for organizing content I felt its quite restricted and I had to do a lot of things in bulk instead of opening each asset and doing it. Hence I built this tool (continuing to itereate) to make my life and any other Immich user's life easier.
@@ -51,7 +57,11 @@ Refer here for obtaining Immich API Key: https://immich.app/docs/features/comman
If you're using portainer, run the docker using `docker run` and add the power tools to the same network as immich.
```bash
+# Run the power tools from docker
docker run -d --name immich_power_tools -p 8001:3000 --env-file .env ghcr.io/varun-raj/immich-power-tools:latest
+
+# Add Power tools to the same network as immich
+docker network connect immich_default immich_power_tools
```
@@ -90,7 +100,8 @@ bun run dev
- [x] Manage People
- [x] Smart Merge
- [x] Manage Albums
- - [ ] Bulk Delete
+ - [x] Bulk Delete
+ - [x] Bulk Share
- [ ] Bulk Edit
- [ ] Filters
- [x] Potential Albums
@@ -127,7 +138,6 @@ Google Gemini 1.5 Flash model is used for parsing your search query in "Find" pa
> Code where Gemini is used: [src/helpers/gemini.helper.ts](./src/helpers/gemini.helper.ts)
-
## 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.
\ No newline at end of file
diff --git a/bun.lockb b/bun.lockb
index 3d7d613..9155ecc 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index d6f629f..f5a144f 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"cookie": "^0.6.0",
"date-fns": "^3.6.0",
"drizzle-orm": "^0.33.0",
+ "jsonwebtoken": "^9.0.2",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "0.1.2",
"lucide-react": "^0.428.0",
@@ -64,6 +65,7 @@
"react-hot-toast": "^2.5.1",
"react-leaflet": "^4.2.1",
"react-mentions": "^4.4.10",
+ "react-photo-album": "^3.0.2",
"react-query": "^3.39.3",
"recharts": "^2.12.7",
"remotion": "4.0.232",
diff --git a/src/components/albums/info/AlbumPeople.tsx b/src/components/albums/info/AlbumPeople.tsx
index b37a69b..3610c07 100644
--- a/src/components/albums/info/AlbumPeople.tsx
+++ b/src/components/albums/info/AlbumPeople.tsx
@@ -21,14 +21,16 @@ import { Checkbox } from '@/components/ui/checkbox'
interface AlbumPeopleProps {
album: IAlbum
onSelect: (personId: string) => void
+ readOnly?: boolean
}
interface IAlbumPerson {
id: string
name: string
numberOfPhotos: number
+
}
-export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) {
+export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeopleProps) {
const { exImmichUrl } = useConfig()
const router = useRouter()
const { query, pathname } = router
@@ -117,51 +119,56 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) {
{selectedPerson.name || "No Name"}
-
-
{
- router.push({
- pathname,
- query: {
- ...query,
- faceId: mergedPerson.id
- }
- })
- }} />
- handleHidePerson(selectedPerson.id)}>
- Hide
-
-
+ {!readOnly && (
+
+
{
+ router.push({
+ pathname,
+ query: {
+ ...query,
+ faceId: mergedPerson.id
+ }
+ })
+ }} />
+ handleHidePerson(selectedPerson.id)}>
+ Hide
+
+
+ )}
)}
People
-
- {!selectedPerson && (
-
{
- setSelectionMode(!selectionMode)
- setSelectedPeople([])
- }}>
- {selectionMode ? "Cancel" : "Select"}
-
- )}
- {selectedPeople.length > 0 && (
-
-
- Hide {selectedPeople.length} people
-
-
+ {!readOnly && (
+ <>
+ {!selectedPerson && (
+
{
+ setSelectionMode(!selectionMode)
+ setSelectedPeople([])
+ }}>
+ {selectionMode ? "Cancel" : "Select"}
+
+ )}
+ {selectedPeople.length > 0 && (
+
+
+ Hide {selectedPeople.length} people
+
+
+ )}
+ >
)}
diff --git a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx
index 6fc44f8..0e4752f 100644
--- a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx
+++ b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx
@@ -41,6 +41,7 @@ export default function PotentialAlbumsAssets() {
width: p.exifImageWidth as number,
height: p.exifImageHeight as number,
isSelected: selectedIds.includes(p.id),
+ orientation: 1,
tags: [
{
title: "Immich Link",
diff --git a/src/components/albums/share/AlbumShareDialog.tsx b/src/components/albums/share/AlbumShareDialog.tsx
index e82ee73..03ea878 100644
--- a/src/components/albums/share/AlbumShareDialog.tsx
+++ b/src/components/albums/share/AlbumShareDialog.tsx
@@ -1,8 +1,11 @@
+import { AlertDialog } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
+import { Input } from '@/components/ui/input';
import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
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'
@@ -28,6 +31,10 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded
const [generating, setGenerating] = useState(false);
const [error, setError] = useState
(null);
const [generated, setGenerated] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [generatedLink, setGeneratedLink] = useState(null);
+ const [copied, setCopied] = useState(false);
const handleGenerateShareLink = async () => {
setGenerating(true);
@@ -52,10 +59,32 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded
});
}
+ const handleGenerateGlobalShareLink = async () => {
+ setLoading(true);
+ return generateShareLink({
+ albumIds: selectedAlbums.map((album) => album.id),
+ }).then(({ link }) => {
+ setGeneratedLink(link);
+ }).catch((err) => {
+ setErrorMessage(err.message);
+ }).finally(() => {
+ setLoading(false);
+ });
+ }
const handleAllowPropertyChange = (albumId: string, property: string, checked: boolean) => {
setSelectedAlbums((prevAlbums) => prevAlbums.map((album) => album.id === albumId ? { ...album, [property]: checked } : album));
}
+ const handleCopy = () => {
+ if (generatedLink) {
+ navigator.clipboard.writeText(generatedLink);
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ }
+ }
+
useImperativeHandle(ref, () => ({
open: (selectedAlbums: IAlbum[]) => {
@@ -109,23 +138,51 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded
))}
+ {generatedLink && (
+
+
Share links all generated
+
+
+
+ {copied ? 'Copied' : 'Copy'}
+
+
+
+
+ )}
{generated ? (
<>
- Share links all generated
+ Share links all generated
>
) : (
<>
- {generating ?
-
Generating share links...
-
:
-
- Generate For {selectedAlbums.length} albums
-
-
-
}
- >
+ {generating ?
+
Generating share links...
+
: (
+
+
+
+ Generate For {selectedAlbums.length} albums
+
+
+
+
+ Generate 1 Share Link
+
+
+
+ )}
+ >
)}
-
+
diff --git a/src/components/assets/missing-location/MissingLocationAssets.tsx b/src/components/assets/missing-location/MissingLocationAssets.tsx
index 5c6d81f..c8b36b7 100644
--- a/src/components/assets/missing-location/MissingLocationAssets.tsx
+++ b/src/components/assets/missing-location/MissingLocationAssets.tsx
@@ -56,6 +56,7 @@ export default function MissingLocationAssets({ groupBy }: IProps) {
width: p.exifImageWidth as number,
height: p.exifImageHeight as number,
isSelected: selectedIds.includes(p.id),
+ orientation: 1,
tags: [
{
title: "Immich Link",
diff --git a/src/components/layouts/RootLayout.tsx b/src/components/layouts/RootLayout.tsx
index 8f56340..692fc9a 100644
--- a/src/components/layouts/RootLayout.tsx
+++ b/src/components/layouts/RootLayout.tsx
@@ -12,6 +12,8 @@ import { useConfig } from "@/contexts/ConfigContext";
import { queryClient } from "@/config/rQuery";
import { QueryClientProvider } from "react-query";
import { Toaster } from "react-hot-toast";
+import { useRouter } from "next/router";
+import PageLayout from "./PageLayout";
type RootLayoutProps = {
children: ReactNode;
@@ -20,6 +22,8 @@ type RootLayoutProps = {
export default function RootLayout({ children }: RootLayoutProps) {
const { immichURL, exImmichUrl } = useConfig();
+ const router = useRouter();
+ const { pathname } = router;
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<{
@@ -84,6 +88,17 @@ export default function RootLayout({ children }: RootLayoutProps) {
);
+
+ if (pathname.startsWith("/s/")) {
+ return (
+
+
+
+ )
+ }
if (!user) return ;
return (
diff --git a/src/components/people/PersonItem.tsx b/src/components/people/PersonItem.tsx
index cd19efa..facbbbd 100644
--- a/src/components/people/PersonItem.tsx
+++ b/src/components/people/PersonItem.tsx
@@ -11,6 +11,7 @@ import { useConfig } from "@/contexts/ConfigContext";
import { useToast } from "../ui/use-toast";
import { Badge } from "../ui/badge";
import { Button } from "@/components/ui/button";
+import ShareAssetsTrigger from "../shared/ShareAssetsTrigger";
interface IProps {
person: IPerson;
onRemove: (person: IPerson) => void;
@@ -108,6 +109,7 @@ export default function PersonItem({ person, onRemove }: IProps) {
}}>
Hide
+
{!editMode ? (
diff --git a/src/components/shared/AssetGrid.tsx b/src/components/shared/AssetGrid.tsx
new file mode 100644
index 0000000..56625aa
--- /dev/null
+++ b/src/components/shared/AssetGrid.tsx
@@ -0,0 +1,71 @@
+import "yet-another-react-lightbox/styles.css";
+
+import { IAsset } from '@/types/asset';
+import React, { useMemo, useState } from 'react'
+import Lightbox from 'yet-another-react-lightbox';
+import { Gallery } from "react-grid-gallery";
+import LazyGridImage from "../ui/lazy-grid-image";
+import Download from "yet-another-react-lightbox/plugins/download";
+
+
+interface AssetGridProps {
+ assets: IAsset[];
+ isInternal?: boolean;
+ selectable?: boolean;
+}
+
+export default function AssetGrid({ assets, isInternal = true, selectable = false }: AssetGridProps) {
+ const [index, setIndex] = useState(-1);
+
+ const slides = useMemo(() => {
+ return assets.map((asset) => ({
+ ...asset,
+ orientation: 1,
+ src: asset.previewUrl as string,
+ type: (asset.type === "VIDEO" ? "video" : "image") as any,
+ sources:
+ asset.type === "VIDEO"
+ ? [
+ {
+ src: asset.downloadUrl as string,
+ type: "video/mp4",
+ },
+ ]
+ : undefined,
+ height: asset.exifImageHeight as number,
+ width: asset.exifImageWidth as number,
+ downloadUrl: asset.downloadUrl as string,
+ }));
+ }, [assets]);
+
+ console.log(slides);
+
+ const images = useMemo(() => {
+ return assets.map((p) => ({
+ ...p,
+ src: p.url as string,
+ original: p.previewUrl as string,
+ width: p.exifImageWidth / 10 as number,
+ height: p.exifImageHeight / 10 as number,
+ orientation: 1
+ }));
+ }, [assets]);
+
+ return (
+
+ = 0}
+ index={index}
+ close={() => setIndex(-1)}
+ />
+
+
+ );
+}
diff --git a/src/components/shared/ShareAssetsTrigger.tsx b/src/components/shared/ShareAssetsTrigger.tsx
new file mode 100644
index 0000000..cfd8a5b
--- /dev/null
+++ b/src/components/shared/ShareAssetsTrigger.tsx
@@ -0,0 +1,69 @@
+import React, { useState } from 'react'
+import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogTrigger, DialogDescription } from '../ui/dialog';
+import { Button, ButtonProps } from '../ui/button';
+import { ShareLinkFilters } from '@/types/shareLink';
+import { generateShareLink } from '@/handlers/api/shareLink.handler';
+import { Input } from '../ui/input';
+import { Label } from '@radix-ui/react-label';
+
+
+interface ShareAssetsTriggerProps {
+ filters: ShareLinkFilters
+ buttonProps?: ButtonProps
+}
+
+export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssetsTriggerProps) {
+ const [generatedLink, setGeneratedLink] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const handleGenerate = async () => {
+ setLoading(true);
+ return generateShareLink(filters).then(({ link }) => {
+ setGeneratedLink(link);
+ }).catch((err) => {
+ setErrorMessage(err.message);
+ }).finally(() => {
+ setLoading(false);
+ });
+ }
+
+ const handleCopy = () => {
+ if (generatedLink) {
+ navigator.clipboard.writeText(generatedLink);
+ setCopied(true);
+ setTimeout(() => {
+ setCopied(false);
+ }, 2000);
+ }
+ }
+
+
+ return (
+
+
+ Share
+
+
+
+ Share Assets
+
+
+ Share your assets with your friends and family.
+
+ {errorMessage && {errorMessage}
}
+ {generatedLink ?
+
Share Link
+
+
+ This is a stateless link, it will not work if you leave the page. Which means it cannot be expired.
+
+
{copied ? 'Copied' : 'Copy'}
+
: (
+ Generate Share Link
+ )}
+
+
+ )
+}
diff --git a/src/config/environment.ts b/src/config/environment.ts
index d503f56..488dfc9 100644
--- a/src/config/environment.ts
+++ b/src/config/environment.ts
@@ -13,6 +13,8 @@ export const ENV = {
VERSION: process.env.VERSION,
GEMINI_API_KEY: process.env.GEMINI_API_KEY as string,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY as string,
+ IMMICH_SHARE_LINK_KEY: process.env.IMMICH_SHARE_LINK_KEY as string,
+ POWER_TOOLS_ENDPOINT_URL: process.env.POWER_TOOLS_ENDPOINT_URL as string,
};
diff --git a/src/config/routes.ts b/src/config/routes.ts
index ca1db88..9495a1c 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -21,8 +21,10 @@ export const LIST_POTENTIAL_ALBUMS_DATES_PATH = BASE_API_ENDPOINT + "/albums/pot
export const LIST_POTENTIAL_ALBUMS_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-assets";
export const LIST_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/list";
export const ALBUM_INFO_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/info";
+
export const ALBUM_PEOPLE_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/people";
export const ALBUM_ASSETS_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/assets";
+export const ALBUM_ASSETS_PATH_PUBLIC = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/public-assets";
export const CREATE_ALBUM_PATH = BASE_PROXY_ENDPOINT + "/albums";
export const ADD_ASSETS_ALBUMS_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/albums/" + id + "/assets";
export const SHARE_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/share";
@@ -38,6 +40,8 @@ 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;
+
// Location
export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
@@ -57,3 +61,9 @@ export const GET_PERSON_INFO = (personId: string) => BASE_API_ENDPOINT + "/peopl
export const REWIND_STATS = BASE_API_ENDPOINT + "/rewind/stats";
// Find
export const FIND_ASSETS = BASE_API_ENDPOINT + "/find/search";
+
+
+// Share Link
+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";
diff --git a/src/handlers/api/shareLink.handler.ts b/src/handlers/api/shareLink.handler.ts
new file mode 100644
index 0000000..7cdeb87
--- /dev/null
+++ b/src/handlers/api/shareLink.handler.ts
@@ -0,0 +1,17 @@
+
+import { SHARE_LINK_ASSETS_PATH, SHARE_LINK_GENERATE_PATH, SHARE_LINK_PATH } from "@/config/routes";
+import { cleanUpAssets } from "@/helpers/asset.helper";
+import API from "@/lib/api";
+import { ShareLinkFilters } from "@/types/shareLink";
+
+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 generateShareLink = async (filters: ShareLinkFilters) => {
+ return API.post(SHARE_LINK_GENERATE_PATH, filters);
+}
\ No newline at end of file
diff --git a/src/helpers/asset.helper.ts b/src/helpers/asset.helper.ts
index b36bc3d..f85ed3e 100644
--- a/src/helpers/asset.helper.ts
+++ b/src/helpers/asset.helper.ts
@@ -1,6 +1,4 @@
-import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH, ASSET_VIDEO_PATH, PERSON_THUBNAIL_PATH } from "@/config/routes"
-import { IPerson } from "@/types/person"
-import { parseDate } from "./date.helper"
+import { ASSET_PREVIEW_PATH, ASSET_SHARE_THUMBNAIL_PATH, ASSET_THUMBNAIL_PATH, ASSET_VIDEO_PATH, PERSON_THUBNAIL_PATH } from "@/config/routes"
import { IAsset } from "@/types/asset"
@@ -12,3 +10,31 @@ export const cleanUpAsset = (asset: IAsset): IAsset => {
videoURL: ASSET_VIDEO_PATH(asset.id),
}
}
+
+
+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),
+ }
+}
+
+export const cleanUpAssets = (assets: IAsset[]): IAsset[] => {
+ return assets.map(cleanUpAsset);
+}
+
+function isRotated90CW(orientation: number) {
+ return orientation === 5 || orientation === 6 || orientation === 90;
+}
+
+function isRotated270CW(orientation: number) {
+ return orientation === 7 || orientation === 8 || orientation === -90;
+}
+
+export function isFlipped(orientation?: string | null) {
+ const value = Number(orientation);
+ return value && (isRotated270CW(value) || isRotated90CW(value));
+}
diff --git a/src/helpers/user.helper.ts b/src/helpers/user.helper.ts
index fe805fd..6713b45 100644
--- a/src/helpers/user.helper.ts
+++ b/src/helpers/user.helper.ts
@@ -1,7 +1,11 @@
import { ENV } from "@/config/environment"
import { IUser } from "@/types/user"
-export const getUserHeaders = (user: IUser, otherHeaders?: {
+export const getUserHeaders = (user: {
+ isUsingAPIKey?: boolean,
+ isUsingShareKey?: boolean,
+ accessToken?: string
+}, otherHeaders?: {
'Content-Type': string
}) => {
let headers: {
@@ -11,11 +15,13 @@ export const getUserHeaders = (user: IUser, otherHeaders?: {
} = {
'Content-Type': 'application/json',
}
- if (user.isUsingAPIKey) {
+ if (user.isUsingShareKey) {
+ headers['x-api-key'] = ENV.IMMICH_SHARE_LINK_KEY
+ } else if (user.isUsingAPIKey) {
headers['x-api-key'] = ENV.IMMICH_API_KEY
- }
- else {
+ } else {
headers['Authorization'] = `Bearer ${user.accessToken}`
}
+
return {...headers, ...otherHeaders}
}
\ No newline at end of file
diff --git a/src/pages/api/albums/[id]/info.ts b/src/pages/api/albums/[id]/info.ts
index a84c6fd..4b1f2c1 100644
--- a/src/pages/api/albums/[id]/info.ts
+++ b/src/pages/api/albums/[id]/info.ts
@@ -4,12 +4,10 @@ import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next";
import { albums } from "@/schema/albums.schema";
-import { count, desc, eq, min, max, sql, and } from "drizzle-orm";
+import { count, eq, min, max, sql, and } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
-import { users } from "@/schema/users.schema";
import { assetFaces, exif, person } from "@/schema";
-import { IAlbum } from "@/types/album";
export default async function handler(
req: NextApiRequest,
diff --git a/src/pages/api/albums/[id]/public-info.ts b/src/pages/api/albums/[id]/public-info.ts
new file mode 100644
index 0000000..33333ab
--- /dev/null
+++ b/src/pages/api/albums/[id]/public-info.ts
@@ -0,0 +1,44 @@
+import { NextApiRequest } from "next";
+
+import { db } from "@/config/db";
+import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
+import { NextApiResponse } from "next";
+import { albums } from "@/schema/albums.schema";
+import { count, eq, min, max, sql, and } from "drizzle-orm";
+import { assets } from "@/schema/assets.schema";
+import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
+import { assetFaces, exif, person } from "@/schema";
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+
+ const { id } = req.query as { id: string };
+
+ const dbAlbums = await db.selectDistinctOn([albums.id], {
+ id: albums.id,
+ albumName: albums.albumName,
+ createdAt: albums.createdAt,
+ updatedAt: albums.updatedAt,
+ albumThumbnailAssetId: albums.albumThumbnailAssetId,
+ assetCount: count(assets.id),
+ firstPhotoDate: min(exif.dateTimeOriginal),
+ lastPhotoDate: max(exif.dateTimeOriginal),
+ faceCount: count(sql`DISTINCT ${person.id}`), // Ensure unique personId
+ })
+ .from(albums)
+ .leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId))
+ .leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id))
+ .leftJoin(exif, and(eq(assets.id, exif.assetId), eq(assets.isVisible, true)))
+ .leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
+ .leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false)))
+ .where(and(eq(albums.id, id)))
+ .groupBy(albums.id)
+ .limit(1);
+
+ if (dbAlbums.length === 0) {
+ return res.status(404).json({ error: "Album not found" });
+ }
+ res.status(200).json(dbAlbums[0]);
+}
\ No newline at end of file
diff --git a/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts b/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts
new file mode 100644
index 0000000..182a077
--- /dev/null
+++ b/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts
@@ -0,0 +1,63 @@
+// pages/api/proxy.js
+
+import { ENV } from '@/config/environment';
+import { getUserHeaders } from '@/helpers/user.helper';
+import { verify } from 'jsonwebtoken';
+import { NextApiRequest, NextApiResponse } from 'next'
+
+export const config = {
+ api: {
+ bodyParser: false,
+ },
+}
+
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ message: 'Method Not Allowed' })
+ }
+
+ const { id, size, token } = req.query;
+ if (!token) {
+ return res.status(401).json({ message: 'Unauthorized' })
+ }
+
+ try {
+ verify(token as string, ENV.JWT_SECRET);
+ } catch (error) {
+ 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`;
+ }
+
+ try {
+ // Forward the request to the target API
+ const response = await fetch(targetUrl, {
+ method: 'GET',
+ headers: getUserHeaders({ isUsingShareKey: true }, {
+ 'Content-Type': 'application/octet-stream',
+ }),
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error("Error fetching thumbnail " + error.message)
+ }
+
+ // Get the image data from the response
+ const imageBuffer = await response.arrayBuffer()
+
+ // Set the appropriate headers for the image response
+ res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png')
+ res.setHeader('Content-Length', imageBuffer.byteLength)
+
+ // Send the image data
+ res.send(Buffer.from(imageBuffer))
+ } catch (error: any) {
+ res.redirect("https://placehold.co/400")
+ console.error('Error:', error)
+ }
+}
\ No newline at end of file
diff --git a/src/pages/api/immich-proxy/asset/thumbnail/[id].ts b/src/pages/api/immich-proxy/asset/thumbnail/[id].ts
index aa29165..222ea7c 100644
--- a/src/pages/api/immich-proxy/asset/thumbnail/[id].ts
+++ b/src/pages/api/immich-proxy/asset/thumbnail/[id].ts
@@ -20,7 +20,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/thumbnail?size=${size || 'thumbnail'}`;
const user = await getCurrentUser(req);
-
if (!user) {
return res.status(403).json({ message: 'Unauthorized' })
}
diff --git a/src/pages/api/share-link/[token].ts b/src/pages/api/share-link/[token].ts
new file mode 100644
index 0000000..37cfe57
--- /dev/null
+++ b/src/pages/api/share-link/[token].ts
@@ -0,0 +1,89 @@
+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 { 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 { cleanUpAsset, cleanUpAssets, cleanUpShareAsset, isFlipped } from "@/helpers/asset.helper";
+import { IAsset } from "@/types/asset";
+import { parseDate } from "@/helpers/date.helper";
+
+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 dbAssets = await db.select({
+ id: assets.id,
+ deviceId: assets.deviceId,
+ type: assets.type,
+ originalPath: assets.originalPath,
+ isFavorite: assets.isFavorite,
+ duration: assets.duration,
+ encodedVideoPath: assets.encodedVideoPath,
+ originalFileName: assets.originalFileName,
+ sidecarPath: assets.sidecarPath,
+ deletedAt: assets.deletedAt,
+ localDateTime: assets.localDateTime,
+ exifImageWidth: exif.exifImageWidth,
+ exifImageHeight: exif.exifImageHeight,
+ ownerId: assets.ownerId,
+ dateTimeOriginal: exif.dateTimeOriginal,
+ url: assets.id,
+ previewUrl: assets.id,
+ videoURL: assets.id,
+ orientation: exif.orientation,
+ }).from(assets)
+ .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId))
+ .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
+ .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),
+ ))
+ .orderBy(desc(assets.createdAt));
+
+ const cleanedAssets = dbAssets.map((asset) => {
+ return {
+ ...asset,
+ localDateTime: new Date(asset.localDateTime),
+ duration: asset.duration ? parseInt(asset.duration) : 0,
+ exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0,
+ exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0,
+ dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(),
+ }
+ }).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string));
+
+ return res.status(200).json(cleanedAssets);
+}
\ No newline at end of file
diff --git a/src/pages/api/share-link/generate.ts b/src/pages/api/share-link/generate.ts
new file mode 100644
index 0000000..7adb506
--- /dev/null
+++ b/src/pages/api/share-link/generate.ts
@@ -0,0 +1,24 @@
+
+import { ENV } from "@/config/environment";
+import { SHARE_LINK_ASSETS_PATH } from "@/config/routes";
+import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
+import { sign } from "jsonwebtoken";
+import { NextApiRequest, NextApiResponse } from "next";
+
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const currentUser = await getCurrentUser(req);
+ const filters = req.body;
+
+ if (!currentUser) {
+ return res.status(401).json({ message: "Unauthorized" });
+ }
+
+ if (!ENV.IMMICH_SHARE_LINK_KEY) {
+ 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/s/[token].tsx b/src/pages/s/[token].tsx
new file mode 100644
index 0000000..4dd0f1f
--- /dev/null
+++ b/src/pages/s/[token].tsx
@@ -0,0 +1,73 @@
+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 { 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 { humanizeNumber } from '@/helpers/string.helper'
+import { getShareLinkInfo } from '@/handlers/api/shareLink.handler'
+import AssetGrid from '@/components/shared/AssetGrid'
+import { IAsset } from '@/types/asset'
+
+export default function AlbumListPage() {
+ const router = useRouter()
+ const { token } = router.query as { token: string }
+ const [assets, setAssets] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [errorMessage, setErrorMessage] = useState('')
+
+ const fetchAlbumInfo = async () => {
+ setLoading(true)
+ getShareLinkInfo(token)
+ .then(setAssets)
+ .catch((error) => {
+ setErrorMessage(error.message)
+ })
+ .finally(() => {
+ setLoading(false)
+ })
+ }
+
+ useEffect(() => {
+ fetchAlbumInfo()
+ }, [])
+
+ const renderContent = () => {
+
+ if (loading) {
+ return
+ }
+ else if (errorMessage) {
+ return {errorMessage}
+ }
+ return (
+
+ )
+ }
+ return (
+
+
+ )
+}
diff --git a/src/styles/globals.scss b/src/styles/globals.scss
index f8be68f..fc4c145 100644
--- a/src/styles/globals.scss
+++ b/src/styles/globals.scss
@@ -88,3 +88,8 @@
}
}
+
+#ReactGridGallery img {
+ max-width: none !important;
+ height: none !important;
+}
\ No newline at end of file
diff --git a/src/types/asset.d.ts b/src/types/asset.d.ts
index 545dfc5..71af4aa 100644
--- a/src/types/asset.d.ts
+++ b/src/types/asset.d.ts
@@ -5,19 +5,19 @@ export interface IAsset {
type: string;
originalPath: string;
isFavorite: boolean;
- duration: null;
- encodedVideoPath: string;
+ duration: number | null;
+ encodedVideoPath: string | null;
originalFileName: string;
- sidecarPath: null;
- thumbhash: IAssetThumbhash;
- deletedAt: null;
- localDateTime: string;
+ thumbhash?: IAssetThumbhash;
+ localDateTime: string | Date;
exifImageWidth: number;
exifImageHeight: number;
url: string;
previewUrl: string;
videoURL?: string;
dateTimeOriginal: string;
+ orientation?: number | null | string;
+ downloadUrl?: string;
}
export interface IAssetThumbhash {
diff --git a/src/types/shareLink.d.ts b/src/types/shareLink.d.ts
new file mode 100644
index 0000000..3e4a40d
--- /dev/null
+++ b/src/types/shareLink.d.ts
@@ -0,0 +1,6 @@
+export interface ShareLinkFilters {
+ personIds?: string[];
+ albumIds?: string[];
+ startDate?: string;
+ endDate?: string;
+}
\ No newline at end of file