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 [![Immich Power Tools](./screenshots/screenshot-1.png)](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. + +[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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 - } - }) - }} /> - -
+ {!readOnly && ( +
+ { + router.push({ + pathname, + query: { + ...query, + faceId: mergedPerson.id + } + }) + }} /> + +
+ )} )}

People

- - {!selectedPerson && ( - - )} - {selectedPeople.length > 0 && ( -
- -
+ {!readOnly && ( + <> + {!selectedPerson && ( + + )} + {selectedPeople.length > 0 && ( +
+ +
+ )} + )}
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

+
+ + +
+
+ + )} {generated ? ( <> -

Share links all generated

+

Share links all generated

) : ( <> - {generating ?
-

Generating share links...

-
:
- - -
} - + {generating ?
+

Generating share links...

+
: ( +
+ + + + + + +
+ )} + )} - +
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 ( + +
+
{children}
+ +
+
+ ) + } 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 Assets + + + Share your assets with your friends and family. + + {errorMessage &&
{errorMessage}
} + {generatedLink ?
+ + +

+ This is a stateless link, it will not work if you leave the page. Which means it cannot be expired. +

+ +
: ( + + )} +
+
+ ) +} 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 ( + +
+

+ {humanizeNumber(assets.length || 0)} + +

+ + ) + } + /> + {renderContent()} + + ) +} 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