1 parent
1132a046f4
commit
34f32e4906
26 files changed
+667
-70
No files matched your search
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
</Link>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<PersonMergeDropdown
|
||||
person={{
|
||||
...selectedPerson,
|
||||
assetCount: selectedPerson.numberOfPhotos,
|
||||
birthDate: null,
|
||||
thumbnailPath: PERSON_THUBNAIL_PATH(selectedPerson.id),
|
||||
isHidden: false,
|
||||
updatedAt: new Date()
|
||||
}}
|
||||
triggerClassName='w-full flex-1'
|
||||
onComplete={(mergedPerson) => {
|
||||
router.push({
|
||||
pathname,
|
||||
query: {
|
||||
...query,
|
||||
faceId: mergedPerson.id
|
||||
}
|
||||
})
|
||||
}} />
|
||||
<Button disabled={hidingPerson} className="!py-0.5 !px-2 text-xs h-7 flex-1" variant="outline" onClick={() => handleHidePerson(selectedPerson.id)}>
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<PersonMergeDropdown
|
||||
person={{
|
||||
...selectedPerson,
|
||||
assetCount: selectedPerson.numberOfPhotos,
|
||||
birthDate: null,
|
||||
thumbnailPath: PERSON_THUBNAIL_PATH(selectedPerson.id),
|
||||
isHidden: false,
|
||||
updatedAt: new Date()
|
||||
}}
|
||||
triggerClassName='w-full flex-1'
|
||||
onComplete={(mergedPerson) => {
|
||||
router.push({
|
||||
pathname,
|
||||
query: {
|
||||
...query,
|
||||
faceId: mergedPerson.id
|
||||
}
|
||||
})
|
||||
}} />
|
||||
<Button disabled={hidingPerson} className="!py-0.5 !px-2 text-xs h-7 flex-1" variant="outline" onClick={() => handleHidePerson(selectedPerson.id)}>
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<p className="text-sm font-medium">People</p>
|
||||
|
||||
{!selectedPerson && (
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
|
||||
setSelectionMode(!selectionMode)
|
||||
setSelectedPeople([])
|
||||
}}>
|
||||
{selectionMode ? "Cancel" : "Select"}
|
||||
</Button>
|
||||
)}
|
||||
{selectedPeople.length > 0 && (
|
||||
<div className='absolute mx-auto bottom-0 w-full py-2 bg-white darl:bg-black -mx-2 px-2'>
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
|
||||
Hide {selectedPeople.length} people
|
||||
</Button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<>
|
||||
{!selectedPerson && (
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
|
||||
setSelectionMode(!selectionMode)
|
||||
setSelectedPeople([])
|
||||
}}>
|
||||
{selectionMode ? "Cancel" : "Select"}
|
||||
</Button>
|
||||
)}
|
||||
{selectedPeople.length > 0 && (
|
||||
<div className='absolute mx-auto bottom-0 w-full py-2 bg-white darl:bg-black -mx-2 px-2'>
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
|
||||
Hide {selectedPeople.length} people
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-x-hidden gap-2">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [generated, setGenerated] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [generatedLink, setGeneratedLink] = useState<string | null>(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
|
||||
|
||||
))}
|
||||
</ol>
|
||||
{generatedLink && (
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
<p className="text-sm py-2 text-muted-foreground text-center">Share links all generated</p>
|
||||
<div className="flex gap-2 w-full">
|
||||
<Input value={generatedLink} readOnly />
|
||||
<Button onClick={handleCopy} className='max-w-fit'>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{generated ? (
|
||||
<>
|
||||
<p className="text-sm py-2 text-muted-foreground text-center">Share links all generated</p>
|
||||
<p className="text-sm py-2 text-muted-foreground text-center">Share links all generated</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{generating ? <div className="flex justify-center gap-2">
|
||||
<p className="text-sm py-2 text-muted-foreground">Generating share links...</p>
|
||||
</div> : <div className="flex justify-center gap-2">
|
||||
<Button onClick={handleGenerateShareLink} disabled={generating}>
|
||||
Generate For {selectedAlbums.length} albums
|
||||
</Button>
|
||||
|
||||
</div>}
|
||||
</>
|
||||
{generating ? <div className="flex justify-center gap-2">
|
||||
<p className="text-sm py-2 text-muted-foreground">Generating share links...</p>
|
||||
</div> : (
|
||||
<div className="flex justify-center gap-2">
|
||||
<AlertDialog
|
||||
title="Generate Share Links"
|
||||
description="Generate share links for the selected albums."
|
||||
onConfirm={handleGenerateShareLink}
|
||||
>
|
||||
<Button disabled={generating}>
|
||||
Generate For {selectedAlbums.length} albums
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
<AlertDialog
|
||||
title="Generate a single share link"
|
||||
description="Immich Power Tools will work like a proxy for all your albums using single share link."
|
||||
onConfirm={handleGenerateGlobalShareLink}
|
||||
>
|
||||
<Button disabled={generating}>
|
||||
Generate 1 Share Link
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<IUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<{
|
||||
@@ -84,6 +88,17 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
if (pathname.startsWith("/s/")) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="grid max-h-screen min-h-screen w-full">
|
||||
<div className="flex flex-col">{children}</div>
|
||||
<Toaster />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
if (!user) return <LoginForm onLogin={handleLoginCompletion} />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
</Button>
|
||||
<ShareAssetsTrigger filters={{ personIds: [person.id] }} buttonProps={{ variant: "outline", className: "!py-0.5 !px-2 text-xs h-7" }} />
|
||||
</div>
|
||||
</div>
|
||||
{!editMode ? (
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Lightbox
|
||||
slides={slides}
|
||||
plugins={[Download]}
|
||||
open={index >= 0}
|
||||
index={index}
|
||||
close={() => setIndex(-1)}
|
||||
/>
|
||||
<Gallery
|
||||
images={images}
|
||||
onClick={setIndex}
|
||||
enableImageSelection={selectable}
|
||||
thumbnailImageComponent={LazyGridImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button {...buttonProps}>Share</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share Assets</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
Share your assets with your friends and family.
|
||||
</DialogDescription>
|
||||
{errorMessage && <div className="text-red-500">{errorMessage}</div>}
|
||||
{generatedLink ? <div className="flex flex-col gap-2">
|
||||
<Label className='text-sm'>Share Link</Label>
|
||||
<Input readOnly type="text" value={generatedLink} />
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
This is a stateless link, it will not work if you leave the page. Which means it cannot be expired.
|
||||
</p>
|
||||
<Button onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</Button>
|
||||
</div> : (
|
||||
<Button onClick={handleGenerate} disabled={loading}>Generate Share Link</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>`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]);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
@@ -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<IAsset[]>([])
|
||||
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 <Loader />
|
||||
}
|
||||
else if (errorMessage) {
|
||||
return <div>{errorMessage}</div>
|
||||
}
|
||||
return (
|
||||
<AssetGrid
|
||||
assets={assets}
|
||||
isInternal={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<PageLayout className="!p-0 !mb-0">
|
||||
<Header
|
||||
leftComponent={"Assets"}
|
||||
rightComponent={
|
||||
!loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-500">
|
||||
{humanizeNumber(assets.length || 0)}
|
||||
<Camera className="w-4 h-4" />
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{renderContent()}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -88,3 +88,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#ReactGridGallery img {
|
||||
max-width: none !important;
|
||||
height: none !important;
|
||||
}
|
||||
Vendored
+6
-6
@@ -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 {
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
export interface ShareLinkFilters {
|
||||
personIds?: string[];
|
||||
albumIds?: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
Reference in new issue
Block a user