diff --git a/.env.sample b/.env.sample index 2e8a180..1971f20 100644 --- a/.env.sample +++ b/.env.sample @@ -11,4 +11,8 @@ SECURE_COOKIE=false # Set to true to enable secure cookies # Optional GOOGLE_MAPS_API_KEY="" # Google Maps API Key for heatmap -GEMINI_API_KEY="" # Gemini API Key for parsing search query in "Find" \ No newline at end of file +GEMINI_API_KEY="" # Gemini API Key for parsing search query in "Find" + +# Immich Share Link +IMMICH_SHARE_LINK_KEY="" # Share link key for Immich +POWER_TOOLS_ENDPOINT_URL="" # URL of the Power Tools endpoint (Used for share links) \ No newline at end of file diff --git a/README.md b/README.md index 00e6057..92ceefe 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,21 @@ 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) +### πŸŽ’Features +- **Manage people data in bulk πŸ‘«**: Options to update people data in bulk, and with advance filters +- **People Merge Suggestion 🎭**: Option to bulk merge people with suggested faces based on similarity. +- **Update Missing Locations πŸ“**: Find assets in your library those are without location and update them with the location of the asset. +- **Potential Albums πŸ–ΌοΈ**: Find albums that are potential to be created based on the assets and people in your library. +- **Analytics πŸ“ˆ**: Get analytics on your library like assets over time, exif data, etc. +- **Smart Search πŸ”Ž**: Search your library with natural language, supports queries like "show me all my photos from 2024 of " +- **Bulk Date Offset πŸ“…**: Offset the date of selected assets by a given amount of time. Majorly used to fix the date of assets that are out of sync with the actual date. + +### 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 +66,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 +109,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 +147,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 2ad086a..741a0e0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ced96fc..f9e2431 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@google/generative-ai": "^0.21.0", "@radix-ui/react-accordion": "^1.2.1", + "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.1", @@ -47,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", @@ -54,12 +56,11 @@ "next-themes": "^0.3.0", "pg": "^8.12.0", "qs": "^6.13.0", - "rc-mentions": "^2.18.0", "react": "^18", - "react-calendar-heatmap": "^1.9.0", "react-day-picker": "9.0.8", "react-dom": "^18", "react-grid-gallery": "^1.0.1", + "react-hot-toast": "^2.5.1", "react-leaflet": "^4.2.1", "react-mentions": "^4.4.10", "react-query": "^3.39.3", diff --git a/src/components/albums/AlbumCreateDialog.tsx b/src/components/albums/AlbumCreateDialog.tsx deleted file mode 100644 index 619cfde..0000000 --- a/src/components/albums/AlbumCreateDialog.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../ui/dialog"; -import { Button } from "../ui/button"; -import { listAlbums } from "@/handlers/api/album.handler"; -import { IAlbum, IAlbumCreate } from "@/types/album"; -import { Input } from "../ui/input"; -import { Label } from "../ui/label"; -import { useToast } from "../ui/use-toast"; - -interface IProps { - onCreated?: (album: IAlbum) => Promise; - onSubmit?: (data: IAlbumCreate) => Promise; - assetIds?: string[]; -} -export default function AlbumCreateDialog({ onSubmit, assetIds }: IProps) { - const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [formData, setFormData] = useState({ - albumName: "", - }); - const { toast } = useToast(); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!onSubmit) return; - setLoading(true); - onSubmit(formData) - .then(() => { - toast({ - title: "Album Created", - description: "Album created successfully", - }); - setFormData({ albumName: "" }); - setOpen(false); - }) - .catch((e) => { - toast({ - title: "Error", - description: "Failed to create album", - variant: "destructive", - }); - }) - .finally(() => { - setLoading(false); - }); - }; - - return ( - - - - - - - Create Album - - Create and add photos to a new album - - -
-
- - { - setFormData({ ...formData, albumName: e.target.value }); - }} - /> -
-
- -
-
-
-
- ); -} diff --git a/src/components/albums/AlbumSelectorDialog.tsx b/src/components/albums/AlbumSelectorDialog.tsx index af49ef7..c8c006f 100644 --- a/src/components/albums/AlbumSelectorDialog.tsx +++ b/src/components/albums/AlbumSelectorDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Dialog, DialogContent, @@ -9,18 +9,28 @@ import { } from "../ui/dialog"; import { Button } from "../ui/button"; import { listAlbums } from "@/handlers/api/album.handler"; -import { IAlbum } from "@/types/album"; +import { IAlbum, IAlbumCreate } from "@/types/album"; import { Input } from "../ui/input"; +import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import toast from "react-hot-toast"; +import { Label } from "../ui/label"; interface IProps { onSelected: (album: IAlbum) => Promise; + onCreated?: (album: IAlbum) => Promise; + onSubmit?: (data: IAlbumCreate) => Promise; } -export default function AlbumSelectorDialog({ onSelected }: IProps) { +export default function AlbumSelectorDialog({ onSelected, onCreated, onSubmit }: IProps) { const [open, setOpen] = useState(false); const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [search, setSearch] = useState(""); + const { selectedIds, assets } = usePotentialAlbumContext(); + const [formData, setFormData] = useState({ + albumName: "", + }); const fetchData = () => { return listAlbums() @@ -42,11 +52,31 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) { }); } + + const handleSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + if (!onSubmit) return; + setLoading(true); + onSubmit(formData) + .then(() => { + toast.success("Album created successfully"); + setFormData({ albumName: "" }); + setOpen(false); + }) + .catch((e) => { + toast.error("Failed to create album"); + }) + .finally(() => { + setLoading(false); + }); + }, [onSubmit, formData]); + + useEffect(() => { if (open && !albums.length) fetchData(); }, [open]); - const renderContent = () => { + const renderContent = useCallback(() => { if (loading) return

Loading...

; if (errorMessage) return

{errorMessage}

; return ( @@ -73,12 +103,36 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) { ); - }; + }, [albums, filteredAlbums, handleSelect, loading, errorMessage]); + + const renderCreateContent = useCallback(() => { + return ( +
+

+ Create a new album and add the selected assets to it +

+
+ + { + setFormData({ ...formData, albumName: e.target.value }); + }} + /> +
+
+ +
+
+ ) + }, [loading, formData, handleSubmit]); return ( - + @@ -87,7 +141,18 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) { Select the albums you want to add the selected assets to - {renderContent()} + + + Albums + Create + + + {renderContent()} + + + {renderCreateContent()} + + ); diff --git a/src/components/albums/info/AlbumImages.tsx b/src/components/albums/info/AlbumImages.tsx index 3bb99af..c040931 100644 --- a/src/components/albums/info/AlbumImages.tsx +++ b/src/components/albums/info/AlbumImages.tsx @@ -125,22 +125,21 @@ export default function AlbumImages({ album }: AlbumImagesProps) { index={index} close={() => setIndex(-1)} /> -
+
{images.map((image) => (
handleClick(images.indexOf(image))} /> diff --git a/src/components/albums/info/AlbumPeople.tsx b/src/components/albums/info/AlbumPeople.tsx index 2def649..cd4dc33 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 @@ -99,7 +101,7 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) { } return ( -
+
{selectedPerson && (
@@ -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 && ( +
+ +
+ )} + )}
@@ -188,7 +195,6 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) { /> )} void; + selected: boolean; } -export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps) { +export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThumbnailProps) { + const [isSelected, setIsSelected] = useState(selected); + const numberOfDays = useMemo(() => { return differenceInDays(album.lastPhotoDate, album.firstPhotoDate); }, [album.firstPhotoDate, album.lastPhotoDate]); @@ -39,6 +39,7 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps {formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy')} - {formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy')}
diff --git a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx index ebe6870..0e4752f 100644 --- a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx +++ b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx @@ -1,8 +1,8 @@ import "yet-another-react-lightbox/styles.css"; import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; import { listPotentialAlbumsAssets } from "@/handlers/api/album.handler"; -import { IAsset } from "@/types/asset"; -import React, { useEffect, useMemo, useState } from "react"; +import type { IAsset } from "@/types/asset"; +import React, { type MouseEvent, useEffect, useMemo, useState } from "react"; import { Gallery } from "react-grid-gallery"; import Lightbox, { SlideImage, SlideTypes } from "yet-another-react-lightbox"; import Captions from "yet-another-react-lightbox/plugins/captions"; @@ -20,6 +20,7 @@ export default function PotentialAlbumsAssets() { const [errorMessage, setErrorMessage] = useState(null); const [index, setIndex] = useState(-1); + const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); const fetchAssets = async () => { setLoading(true); @@ -40,18 +41,20 @@ 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", value: ( - + Open in Immich ), }, ], })); - }, [assets, selectedIds]); + }, [assets, selectedIds, exImmichUrl]); + const slides = useMemo( () => @@ -73,17 +76,31 @@ export default function PotentialAlbumsAssets() { [images] ); - const handleClick = (idx: number) => setIndex(idx); - const handleSelect = (_idx: number, asset: IAsset) => { + const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent) => { + event.stopPropagation(); const isPresent = selectedIds.includes(asset.id); if (isPresent) { updateContext({ selectedIds: selectedIds.filter((id) => id !== asset.id), }); } else { - updateContext({ selectedIds: [...selectedIds, asset.id] }); + const clickedIndex = images.findIndex((image) => { + return image.id === asset.id + }); + if (event.shiftKey) { + const startIndex = Math.min(clickedIndex, lastSelectedIndex); + const endIndex = Math.max(clickedIndex, lastSelectedIndex); + const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); + const allSelectedIds = [...selectedIds, ...newSelectedIds]; + const uniqueSelectedIds = [...new Set(allSelectedIds)]; + updateContext({ selectedIds: uniqueSelectedIds }); + } else { + updateContext({ selectedIds: [...selectedIds, asset.id] }); + } + setLastSelectedIndex(clickedIndex); } + }; useEffect(() => { @@ -121,7 +138,7 @@ export default function PotentialAlbumsAssets() {
+
+ +
+
+ + )} + {generated ? ( + <> +

Share links all generated

+ + ) : ( + <> + {generating ?
+

Generating share links...

+
: ( +
+ + + + album.id) }} /> +
+ )} + + )} + +
+ + + ) +}) + +AlbumShareDialog.displayName = "AlbumShareDialog"; + +export default AlbumShareDialog; diff --git a/src/components/analytics/exif/AssetHeatMap.tsx b/src/components/analytics/exif/AssetHeatMap.tsx index efb3259..3352dd5 100644 --- a/src/components/analytics/exif/AssetHeatMap.tsx +++ b/src/components/analytics/exif/AssetHeatMap.tsx @@ -58,7 +58,7 @@ export default function AssetHeatMap() { const threshold2 = minCount + range * 0.4; const threshold3 = minCount + range * 0.6; const threshold4 = minCount + range * 0.8; - if (count === 0) return "bg-zinc-800"; + if (count === 0) return "bg-zinc-200 dark:bg-zinc-800"; if (count <= threshold1) return "bg-green-200"; if (count <= threshold2) return "bg-green-400"; if (count <= threshold3) return "bg-green-500"; diff --git a/src/components/assets/assets-options/AssetOffsetDialog.tsx b/src/components/assets/assets-options/AssetOffsetDialog.tsx index 882ca3f..bc21585 100644 --- a/src/components/assets/assets-options/AssetOffsetDialog.tsx +++ b/src/components/assets/assets-options/AssetOffsetDialog.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { IAsset } from '@/types/asset'; -import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog'; +import { Dialog, DialogTitle, DialogHeader, DialogContent, DialogTrigger } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -11,12 +11,12 @@ import { useToast } from '@/components/ui/use-toast'; interface IProps { assets: IAsset[]; - open: boolean; - toggleOpen: (open: boolean) => void; + onComplete: () => void; } -export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: IProps) { +export default function AssetOffsetDialog({ assets: _assets, onComplete }: IProps) { const { toast } = useToast(); + const [open, setOpen] = useState(false); const [assets, setAssets] = useState(_assets); const [offsetData, setOffsetData] = useState<{ days: number, hours: number, minutes: number, seconds: number, years: number }>({ days: 0, @@ -43,7 +43,7 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: }) .then(() => { setLoading(false); - toggleOpen(false); + setOpen(false); }) .catch((error) => { setLoading(false); @@ -52,7 +52,8 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: }); await Promise.all(promises); setLoading(false); - toggleOpen(false); + setOpen(false); + onComplete(); toast({ title: 'Asset dates offset', description: 'Asset dates have been offset', @@ -65,12 +66,17 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: }, [_assets]); return ( - + + + + Offset Asset Dates -
+
handleChange("years", parseInt(e.target.value))} /> diff --git a/src/components/assets/assets-options/AssetsOptions.tsx b/src/components/assets/assets-options/AssetsOptions.tsx deleted file mode 100644 index cb65e53..0000000 --- a/src/components/assets/assets-options/AssetsOptions.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Button } from '@/components/ui/button' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { IAsset } from '@/types/asset'; -import { DotsVerticalIcon, HamburgerMenuIcon } from '@radix-ui/react-icons'; -import { Clock, PlusIcon } from 'lucide-react' -import React, { useState } from 'react' -import AssetOffsetDialog from './AssetOffsetDialog'; - -interface IProps { - onAdd?: () => void; - assets: IAsset[]; -} -export default function AssetsOptions({ onAdd, assets }: IProps) { - const [open, setOpen] = useState(false); - - return ( - <> - - - - - - setOpen(true)}> - Offset Asset Dates - - - - {open && } - - ) -} diff --git a/src/components/assets/missing-location/MissingLocationAssets.tsx b/src/components/assets/missing-location/MissingLocationAssets.tsx index 56bc251..e5545e4 100644 --- a/src/components/assets/missing-location/MissingLocationAssets.tsx +++ b/src/components/assets/missing-location/MissingLocationAssets.tsx @@ -1,43 +1,41 @@ import "yet-another-react-lightbox/styles.css"; -import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; -import { listPotentialAlbumsAssets } from "@/handlers/api/album.handler"; import { IAsset } from "@/types/asset"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState, MouseEvent } from "react"; import { Gallery } from "react-grid-gallery"; import Lightbox from "yet-another-react-lightbox"; import Captions from "yet-another-react-lightbox/plugins/captions"; import { - ArrowUpRight, - CalendarArrowDown, CalendarArrowUp, Hourglass, - Link, } from "lucide-react"; import { useMissingLocationContext } from "@/contexts/MissingLocationContext"; import { listMissingLocationAssets } from "@/handlers/api/asset.handler"; -import { formatDate, parseDate } from "@/helpers/date.helper"; -import { addDays } from "date-fns"; import { useConfig } from "@/contexts/ConfigContext"; import LazyGridImage from "@/components/ui/lazy-grid-image"; -export default function MissingLocationAssets() { +interface IProps { + groupBy: "date" | "album"; +} +export default function MissingLocationAssets({ groupBy }: IProps) { const { exImmichUrl } = useConfig(); - const { startDate, selectedIds, assets, sortOrder, sort, updateContext } = + const { startDate, albumId, selectedIds, sortOrder, sort, assets, updateContext } = useMissingLocationContext(); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [index, setIndex] = useState(-1); + const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); const fetchAssets = async () => { - if (!startDate) return; + if (!startDate && !albumId) return; setLoading(true); updateContext({ assets: [], }); - return listMissingLocationAssets({ startDate }) + const filters = groupBy === "date" ? { startDate } : { albumId } + return listMissingLocationAssets(filters) .then((assets) => updateContext({ assets })) .catch(setErrorMessage) .finally(() => setLoading(false)); @@ -59,6 +57,7 @@ export default function MissingLocationAssets() { width: p.exifImageWidth as number, height: p.exifImageHeight as number, isSelected: selectedIds.includes(p.id), + orientation: 1, tags: [ { title: "Immich Link", @@ -84,20 +83,34 @@ export default function MissingLocationAssets() { const handleClick = (idx: number) => setIndex(idx); - const handleSelect = (_idx: number, asset: IAsset) => { + const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent) => { + event.stopPropagation(); const isPresent = selectedIds.includes(asset.id); if (isPresent) { updateContext({ selectedIds: selectedIds.filter((id) => id !== asset.id), }); } else { - updateContext({ selectedIds: [...selectedIds, asset.id] }); + const clickedIndex = images.findIndex((image) => { + return image.id === asset.id; + }); + if (event.shiftKey) { + const startIndex = Math.min(clickedIndex, lastSelectedIndex); + const endIndex = Math.max(clickedIndex, lastSelectedIndex); + const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); + const allSelectedIds = [...selectedIds, ...newSelectedIds]; + const uniqueSelectedIds = [...new Set(allSelectedIds)]; + updateContext({ selectedIds: uniqueSelectedIds }); + } else { + updateContext({ selectedIds: [...selectedIds, asset.id] }); + } + setLastSelectedIndex(clickedIndex); } }; useEffect(() => { - if (startDate) fetchAssets(); - }, [startDate]); + if (startDate || albumId) fetchAssets(); + }, [startDate, albumId]); if (loading) return ( @@ -107,7 +120,7 @@ export default function MissingLocationAssets() {
); - if (!startDate) + if (!startDate && !albumId) return (
diff --git a/src/components/assets/missing-location/MissingLocationDateItem.tsx b/src/components/assets/missing-location/MissingLocationDateItem.tsx index dde352b..4e4378e 100644 --- a/src/components/assets/missing-location/MissingLocationDateItem.tsx +++ b/src/components/assets/missing-location/MissingLocationDateItem.tsx @@ -8,24 +8,34 @@ import React, { useMemo } from "react"; interface IProps { record: IMissingLocationDatesResponse; onSelect: (date: string) => void; + groupBy: "date" | "album"; } -export default function MissingLocationDateItem({ record, onSelect }: IProps) { +export default function MissingLocationDateItem({ record, onSelect, groupBy }: IProps) { const { startDate } = useMissingLocationContext() + const dateLabel = useMemo(() => { - if (!record.date) return "Unknown" - return formatDate(parseDate(record.date, "yyyy-MM-dd").toISOString(), "do MMM yyyy") - }, [record.date]) + if (groupBy === "album") { + return record.label + } + if (!record.label) return "Unknown" + try { + return formatDate(parseDate(record.label, "yyyy-MM-dd").toISOString(), "do MMM yyyy") + } catch (e) { + return record.label + } + }, [record.label, groupBy]) + return (
onSelect(record.date)} - key={record.date} + onClick={() => onSelect(record.value)} + key={record.label} className={ - cn("flex gap-1 flex-col p-2 py-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100 px-4", - startDate === record.date ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "") + cn("flex gap-1 flex-col p-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100", + startDate === record.label ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "") } > -

{dateLabel}

+

{dateLabel}

{record.asset_count} Orphan Assets

); diff --git a/src/components/assets/missing-location/MissingLocationDates.tsx b/src/components/assets/missing-location/MissingLocationDates.tsx index d7c6fad..e9a24e3 100644 --- a/src/components/assets/missing-location/MissingLocationDates.tsx +++ b/src/components/assets/missing-location/MissingLocationDates.tsx @@ -1,6 +1,6 @@ import React, { use, useEffect, useState } from "react"; import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; -import { IMissingLocationDatesResponse, listMissingLocationDates } from "@/handlers/api/asset.handler"; +import { IMissingLocationDatesResponse, listMissingLocationAlbums, listMissingLocationDates } from "@/handlers/api/asset.handler"; import MissingLocationDateItem from "./MissingLocationDateItem"; import { useMissingLocationContext } from "@/contexts/MissingLocationContext"; import { useRouter } from "next/router"; @@ -8,7 +8,11 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrig import { Button } from "@/components/ui/button"; import { SortAsc, SortDesc } from "lucide-react"; -export default function MissingLocationDates() { +interface IMissingLocationDatesProps { + groupBy: string; + +} +export default function MissingLocationDates({ groupBy }: IMissingLocationDatesProps) { const { updateContext } = useMissingLocationContext(); const router = useRouter(); const [dateRecords, setDateRecords] = React.useState< @@ -20,7 +24,9 @@ export default function MissingLocationDates() { const [errorMessage, setErrorMessage] = useState(null); const fetchData = async () => { - return listMissingLocationDates({ + const func = groupBy === "album" ? listMissingLocationAlbums : listMissingLocationDates + setDateRecords([]) + return func({ sortBy: filters.sortBy, sortOrder: filters.sortOrder, }) @@ -30,23 +36,41 @@ export default function MissingLocationDates() { }; const handleSelect = (date: string) => { - updateContext({ startDate: date }); - router.push({ - pathname: router.pathname, - query: { startDate: date }, - }); + if (groupBy === "album") { + updateContext({ albumId: date }); + router.push({ + pathname: router.pathname, + query: { + ...router.query, + albumId: date, + groupBy: "album", + startDate: undefined + }, + }); + } else { + updateContext({ startDate: date }); + router.push({ + pathname: router.pathname, + query: { + ...router.query, + startDate: date, + groupBy: "date", + albumId: undefined + }, + }); + } }; - - useEffect(() => { fetchData(); - }, [filters]); + }, [filters, groupBy]); return ( -
+
- setFilters({ ...filters, sortBy: value })} > @@ -60,21 +84,27 @@ export default function MissingLocationDates() { Asset Count - + )}
-
- - {dateRecords.map((record) => ( - - ))} +
+ {dateRecords.map((record) => ( + + ))} +
); } diff --git a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx index 765796e..d100f3b 100644 --- a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx +++ b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx @@ -12,8 +12,8 @@ import { IPlace } from "@/types/common"; import React, { useState } from "react"; import TagMissingLocationSearchAndAdd from "./TagMissingLocationSearchAndAdd"; import TagMissingLocationSearchLatLong from "./TagMissingLocationSearchLatLong"; -import { MapPinCheck } from "lucide-react"; import dynamic from "next/dynamic"; +import { useMissingLocationContext } from "@/contexts/MissingLocationContext"; const LazyMap = dynamic(() => import("./Map"), { ssr: false @@ -25,19 +25,19 @@ interface ITagMissingLocationDialogProps { export default function TagMissingLocationDialog({ onSubmit, }: ITagMissingLocationDialogProps) { - + const {selectedIds} = useMissingLocationContext(); const [open, setOpen] = useState(false); const [mapPosition,setMapPosition] = useState({ latitude: 48.0, longitude: 16.0, - name: "home1" + name: "home" }); return ( - - + + @@ -46,12 +46,11 @@ export default function TagMissingLocationDialog({ Tagging a location will add the location to the selected assets. - - + + Search and Pick - Latitude and Longitude - + Lat & Long Map @@ -63,7 +62,7 @@ export default function TagMissingLocationDialog({ onOpenChange={setOpen} location={mapPosition} onLocationChange={setMapPosition} /> -
+
diff --git a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationSearchLatLong.tsx b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationSearchLatLong.tsx index 7c0b8ae..eccee6b 100644 --- a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationSearchLatLong.tsx +++ b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationSearchLatLong.tsx @@ -110,7 +110,7 @@ export default function TagMissingLocationSearchLatLong( return (
-
+
handleBlur()} />
-
+
diff --git a/src/components/layouts/RootLayout.tsx b/src/components/layouts/RootLayout.tsx index 3eab240..f5bbbab 100644 --- a/src/components/layouts/RootLayout.tsx +++ b/src/components/layouts/RootLayout.tsx @@ -1,6 +1,5 @@ import { IUser } from "@/types/user"; import Sidebar from "../shared/Sidebar"; -import { Toaster } from "../ui/toaster"; import { ReactNode, useEffect, useState } from "react"; import { getMe } from "@/handlers/api/user.handler"; import UserContext from "@/contexts/CurrentUserContext"; @@ -10,8 +9,9 @@ import { ArrowRight, TriangleAlert } from "lucide-react"; import Link from "next/link"; import { LoginForm } from "../auth/LoginForm"; 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 +20,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,10 +86,18 @@ export default function RootLayout({ children }: RootLayoutProps) {
); + + if (pathname.startsWith("/s/")) { + return ( +
+
{children}
+ +
+ ) + } if (!user) return ; return ( -
- ); } diff --git a/src/components/people/PeopleFilters.tsx b/src/components/people/PeopleFilters.tsx index b73c705..c64a39c 100644 --- a/src/components/people/PeopleFilters.tsx +++ b/src/components/people/PeopleFilters.tsx @@ -14,12 +14,11 @@ import { } from "../ui/dropdown-menu"; import { Input } from "../ui/input"; import { IPersonListFilters } from "@/handlers/api/people.handler"; -import AssetFilter from "../shared/common/AssetFilter"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; export function PeopleFilters() { const router = useRouter(); - const { updateContext, page, maximumAssetCount, type = "all" } = usePeopleFilterContext(); + const { updateContext, page, maximumAssetCount, type = "all", query ="" } = usePeopleFilterContext(); const handleChange = (data: Partial) => { updateContext(data); @@ -40,6 +39,15 @@ export function PeopleFilters() { return (
+ { + handleChange({ query: e.target.value }); + }} + /> 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..bd5a74b --- /dev/null +++ b/src/components/shared/AssetGrid.tsx @@ -0,0 +1,114 @@ +import "yet-another-react-lightbox/styles.css"; + +import { IAsset } from '@/types/asset'; +import React, { useEffect, 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; + onSelectionChange?: (ids: string[]) => void; +} + +export default function AssetGrid({ assets, isInternal = true, selectable = false, onSelectionChange }: AssetGridProps) { + const [index, setIndex] = useState(-1); + const [selectedIds, setSelectedIds] = useState([]); + const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); + + const handleSelect = (_idx: number, asset: IAsset, event: React.MouseEvent) => { + + event.stopPropagation(); + const isPresent = selectedIds.includes(asset.id); + if (isPresent) { + setSelectedIds(selectedIds.filter((id) => id !== asset.id)); + onSelectionChange?.(selectedIds.filter((id) => id !== asset.id)); + } else { + const clickedIndex = images.findIndex((image) => { + return image.id === asset.id; + }); + if (event.shiftKey) { + const startIndex = Math.min(clickedIndex, lastSelectedIndex); + const endIndex = Math.max(clickedIndex, lastSelectedIndex); + const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id); + const allSelectedIds = [...selectedIds, ...newSelectedIds]; + const uniqueSelectedIds = [...new Set(allSelectedIds)]; + setSelectedIds(uniqueSelectedIds); + onSelectionChange?.(uniqueSelectedIds); + } else { + setSelectedIds([...selectedIds, asset.id]); + onSelectionChange?.([...selectedIds, asset.id]); + } + setLastSelectedIndex(clickedIndex); + } + }; + + 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]); + + 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, + isSelected: selectedIds.includes(p.id), + })); + }, [assets, selectedIds]); + + const handleEsc = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setSelectedIds([]); + onSelectionChange?.([]); + } + }; + + useEffect(() => { + // Listen for esc key press and unselect all images + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [images]); + + return ( +
+ = 0} + index={index} + close={() => setIndex(-1)} + /> + +
+ ); +} diff --git a/src/components/shared/AssetsBulkDeleteButton.tsx b/src/components/shared/AssetsBulkDeleteButton.tsx new file mode 100644 index 0000000..85418b6 --- /dev/null +++ b/src/components/shared/AssetsBulkDeleteButton.tsx @@ -0,0 +1,36 @@ +import React, { useMemo } from 'react' +import { Button } from '../ui/button'; +import { AlertDialog } from '../ui/alert-dialog'; +import { deleteAssets } from '@/handlers/api/asset.handler'; + +interface AssetsBulkDeleteButtonProps { + selectedIds: string[]; + onDelete: (ids: string[]) => void; +} + +export default function AssetsBulkDeleteButton({ selectedIds, onDelete }: AssetsBulkDeleteButtonProps) { + + const ids = useMemo(() => { + return selectedIds + }, [selectedIds]); + + + const handleDelete = () => { + return deleteAssets(ids).then(() => { + onDelete(ids); + }) + } + + return ( + + + + ) +} diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx new file mode 100644 index 0000000..73e3aa7 --- /dev/null +++ b/src/components/shared/FloatingBar.tsx @@ -0,0 +1,16 @@ +import { Button } from '@/components/ui/button' +import React from 'react' + +interface FloatingBarProps { + children: React.ReactNode; +} + +export default function FloatingBar({ children }: FloatingBarProps) { + return ( +
+
+ {children} +
+
+ ) +} diff --git a/src/components/shared/PeopleList.tsx b/src/components/shared/PeopleList.tsx new file mode 100644 index 0000000..8a6084a --- /dev/null +++ b/src/components/shared/PeopleList.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react' +import { IPerson } from '@/types/person'; +import { cn } from '@/lib/utils'; +import LazyImage from '../ui/lazy-image'; +import { PERSON_THUBNAIL_PATH } from '@/config/routes'; + +interface PeopleListProps { + people: IPerson[]; + onSelect: (person: IPerson) => void; + selectedIds?: string[]; +} + +export default function PeopleList({ people, onSelect, selectedIds }: PeopleListProps) { + + const isSelected = useCallback((person: IPerson) => { + return selectedIds?.includes(person.id) + }, [selectedIds]) + + return ( +
+ {people.map((person) => ( +
onSelect(person)} + > + +
+

{person.name || "No Name"}

+

{person.assetCount} photos

+
+
+ ))} +
+ ) +} diff --git a/src/components/shared/ProfileInfo.tsx b/src/components/shared/ProfileInfo.tsx index c6c8617..0e84a25 100644 --- a/src/components/shared/ProfileInfo.tsx +++ b/src/components/shared/ProfileInfo.tsx @@ -44,7 +44,7 @@ export default function ProfileInfo() { )}
-
+

Made with by{" "} (null); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [copied, setCopied] = useState(false); + const [config, setConfig] = useState>({ + expiresIn: "never" + }); + + const handleReset = () => { + setConfig({ expiresIn: "never" }); + setGeneratedLink(null); + } + + const handleGenerate = async () => { + setLoading(true); + setErrorMessage(null); + return generateShareLink({ ...filters, ...config }).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 + + + Generate a share link for the selected assets (either bunch of albums or bunch of people) and share it 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. +

+
+ + +
+
: ( +
+
+
+ +

+ Show the list of people in the shared photos +

+
+ setConfig({ ...config, p: !!checked })} /> +
+
+
+ +

+ Should the link expire after the selected time +

+
+ +
+ +
+ )} +
+
+ ) +} diff --git a/src/components/shared/common/AssetFilter.tsx b/src/components/shared/common/AssetFilter.tsx deleted file mode 100644 index bff8f0e..0000000 --- a/src/components/shared/common/AssetFilter.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Combobox } from '@/components/ui/combobox'; -import { getFilters } from '@/handlers/api/common.handler'; -import React, { useMemo, useState } from 'react' -import { useQuery } from 'react-query'; - -const fields = [ - "make", - "model", - "lensModel", - "city", - "state", - "country", - "projectionType", - "colorspace", - "bitsPerSample", - "rating", -]; - -const formattedFields = fields.map(field => ({ - label: field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()), - value: field -})); - - -export default function AssetFilter() { - const [selectedField, setSelectedField] = useState(null); - - const { data } = useQuery('filters', getFilters) - - - const options = useMemo(() => { - if (!selectedField) return formattedFields; - const { filters } = data; - - if (!filters) return formattedFields; - const field = filters[selectedField]; - if (!field) return []; - return field.map((value: string) => ({ - label: value, - value - })); - }, [selectedField, data]); - - return ( - { - if (!selectedField) setSelectedField(value); - - }} - onOpenChange={(open) => { - if (!open) setSelectedField(null); - }} - /> - ) -} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..35c8942 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,209 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import * as React from 'react' + +import { ButtonProps, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useImperativeHandle, useState } from 'react' + +const AlertDialogRoot = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + variant?: ButtonProps['variant'] + } +>(({ className, variant, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + + +export interface INewChangelogButtonActions { + open: () => void +} +interface AlertDialogProps extends AlertDialogPrimitive.AlertDialogProps { + children: React.ReactNode + title: string + description: string + onConfirm: () => void | Promise + onCancel: () => void | Promise + variant?: ButtonProps['variant'] + asChild?: boolean + disabled?: boolean +} + +const AlertDialog = React.forwardRef>(({ + children, + title, + description, + onConfirm, + onCancel, + variant, + asChild, + disabled, + ...props +}, ref) => { + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + + useImperativeHandle(ref, () => ({ + open: () => setOpen(true) + })) + + const handleConfirm = async () => { + setLoading(true) + if (onConfirm) { + await onConfirm() + } + setLoading(false) + setOpen(false) + } + + return ( + + {children} + + + {title} + {description} + + + Cancel + + Confirm + + + + + ) +}) + +AlertDialog.displayName = 'AlertDialog' + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx deleted file mode 100644 index a2209ba..0000000 --- a/src/components/ui/toaster.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from "@/components/ui/toast" -import { useToast } from "@/components/ui/use-toast" - -export function Toaster() { - const { toasts } = useToast() - - return ( - - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && ( - {description} - )} -
- {action} - -
- ) - })} - -
- ) -} diff --git a/src/config/db.ts b/src/config/db.ts index cb903ae..f39e4e1 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -4,10 +4,18 @@ import { Client, Pool } from "pg"; import { ENV } from "./environment"; import * as schema from "@/schema"; import { sql } from 'drizzle-orm'; +import { findMissingKeys } from '@/helpers/data.helper'; +import { APIError } from '@/lib/api'; -const pool = new Pool({ +const pool = ENV.DATABASE_URL ? new Pool({ connectionString: ENV.DATABASE_URL, keepAlive: true, +}) : new Pool({ + user: ENV.DB_USERNAME, + password: ENV.DB_PASSWORD, + host: ENV.DB_HOST, + port: parseInt(ENV.DB_PORT), + database: ENV.DB_DATABASE_NAME, }); class DatabaseConnectionError extends Error { @@ -20,7 +28,15 @@ class DatabaseConnectionError extends Error { } export const connectDB = async (db: NodePgDatabase) => { try { - await db.execute(sql`SELECT 1`); // Execute a simple query + const missingKeys = findMissingKeys(ENV, ['DB_USERNAME', 'DB_PASSWORD', 'DB_HOST', 'DB_PORT', 'DB_DATABASE_NAME']); + if (!ENV.DATABASE_URL && missingKeys.length > 0) { + throw new APIError({ + message: `Some database credentials are missing: ${missingKeys.join(', ')}. Please add them to the .env file`, + status: 500, + }); + } else { + return await db.execute(sql`SELECT 1`); // Execute a simple query + } } catch (error: any) { throw new DatabaseConnectionError(error.message, "Database connection failed"); } diff --git a/src/config/environment.ts b/src/config/environment.ts index 692c635..488dfc9 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -2,12 +2,19 @@ export const ENV = { IMMICH_URL: (process.env.IMMICH_URL || 'http://immich_server:2283') as string, EXTERNAL_IMMICH_URL: (process.env.EXTERNAL_IMMICH_URL || process.env.IMMICH_URL) as string, IMMICH_API_KEY: process.env.IMMICH_API_KEY as string, - DATABASE_URL: (process.env.DATABASE_URL || `postgresql://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${(process.env.DB_HOST || 'immich_postgres')}:${(process.env.DB_PORT || '5432')}/${process.env.DB_DATABASE_NAME}`), + DATABASE_URL: process.env.DATABASE_URL as string, + DB_USERNAME: process.env.DB_USERNAME as string, + DB_PASSWORD: process.env.DB_PASSWORD as string, + DB_HOST: process.env.DB_HOST as string, + DB_PORT: process.env.DB_PORT as string, + DB_DATABASE_NAME: process.env.DB_DATABASE_NAME as string, JWT_SECRET: process.env.JWT_SECRET as string, SECURE_COOKIE: process.env.SECURE_COOKIE === 'true', 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/rQuery.ts b/src/config/rQuery.ts deleted file mode 100644 index 3118e51..0000000 --- a/src/config/rQuery.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { - QueryClient, -} from 'react-query' - -export const queryClient = new QueryClient() \ No newline at end of file diff --git a/src/config/routes.ts b/src/config/routes.ts index a57bd36..d92ef88 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -21,21 +21,28 @@ 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"; +export const DELETE_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/delete"; // Assets export const LIST_MISSING_LOCATION_DATES_PATH = BASE_API_ENDPOINT + "/assets/missing-location-dates"; +export const LIST_MISSING_LOCATION_ALBUMS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-albums"; export const LIST_MISSING_LOCATION_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-assets"; export const UPDATE_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/assets"; - export const ASSET_THUMBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id; export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id + "?size=preview"; 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, size, token, isPeople }: { id: string, size: string, token: string, isPeople: boolean } +) => BASE_PROXY_ENDPOINT + "/asset/share-thumbnail/" + id + "?size=" + size + "&token=" + token + "&p=" + isPeople; + // Location export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places"; @@ -55,3 +62,10 @@ 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"; +export const SHARE_LINK_PEOPLE_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/people"; \ No newline at end of file diff --git a/src/contexts/MissingLocationContext.tsx b/src/contexts/MissingLocationContext.tsx index a439831..2e77eb2 100644 --- a/src/contexts/MissingLocationContext.tsx +++ b/src/contexts/MissingLocationContext.tsx @@ -3,6 +3,7 @@ import { createContext, useContext } from "react"; export interface IMissingLocationConfig { startDate?: string; + albumId?: string; selectedIds: string[]; assets: IAsset[]; sort: "fileOriginalDate"; @@ -14,6 +15,7 @@ export interface MissingLocationContext extends IMissingLocationConfig { } const MissingLocationContext = createContext({ startDate: undefined, + albumId: undefined, selectedIds: [], assets: [], updateContext: () => { }, diff --git a/src/handlers/api/album.handler.ts b/src/handlers/api/album.handler.ts index 2e39f7e..6cb08d7 100644 --- a/src/handlers/api/album.handler.ts +++ b/src/handlers/api/album.handler.ts @@ -1,4 +1,4 @@ -import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH } from "@/config/routes"; +import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, DELETE_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, SHARE_ALBUMS_PATH } from "@/config/routes"; import { cleanUpAsset } from "@/helpers/asset.helper"; import API from "@/lib/api"; import { IAlbumCreate } from "@/types/album"; @@ -45,3 +45,11 @@ export const addAssetToAlbum = async (albumId: string, assetIds: string[]) => { export const createAlbum = async (albumData: IAlbumCreate) => { return API.post(CREATE_ALBUM_PATH, albumData); } + +export const shareAlbums = async (albums: { albumId: string, allowDownload: boolean, allowUpload: boolean, showMetadata: boolean }[]) => { + return API.post(SHARE_ALBUMS_PATH, { albums }); +} + +export const deleteAlbums = async (albumIds: string[]) => { + return API.delete(DELETE_ALBUMS_PATH, { albumIds }); +} \ No newline at end of file diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index 279f0f7..4ea921a 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -1,8 +1,7 @@ import { - ADD_ASSETS_ALBUMS_PATH, ASSET_GEO_HEATMAP_PATH, FIND_ASSETS, - LIST_ALBUMS_PATH, + LIST_MISSING_LOCATION_ALBUMS_PATH, LIST_MISSING_LOCATION_ASSETS_PATH, LIST_MISSING_LOCATION_DATES_PATH, UPDATE_ASSETS_PATH, @@ -18,16 +17,25 @@ interface IMissingAssetAlbumsFilters { sortOrder?: string; } export interface IMissingLocationDatesResponse { - date: string; + label: string; asset_count: number; + value: string; + createdAt?: string; } + export const listMissingLocationDates = async ( filters: IMissingAssetAlbumsFilters ): Promise => { return API.get(LIST_MISSING_LOCATION_DATES_PATH, filters); }; +export const listMissingLocationAlbums = async ( + filters: IMissingAssetAlbumsFilters +): Promise => { + return API.get(LIST_MISSING_LOCATION_ALBUMS_PATH, filters); +}; + export const listMissingLocationAssets = async ( filters: IMissingAssetAlbumsFilters ): Promise => { @@ -55,4 +63,8 @@ export const findAssets = async (query: string) => { export const getAssetGeoHeatmap = async () => { return API.get(ASSET_GEO_HEATMAP_PATH); -} \ No newline at end of file +} + +export const deleteAssets = async (ids: string[]) => { + return API.delete(UPDATE_ASSETS_PATH, { ids }); +} \ No newline at end of file diff --git a/src/handlers/api/people.handler.ts b/src/handlers/api/people.handler.ts index 7e85646..bd901c6 100644 --- a/src/handlers/api/people.handler.ts +++ b/src/handlers/api/people.handler.ts @@ -13,6 +13,7 @@ export interface IPersonListFilters { sort?: ISortField; sortOrder?: "asc" | "desc"; type?: string; + query?: string; } export const listPeople = (filters: IPersonListFilters): Promise => { return API.get(LIST_PEOPLE_PATH, filters).then((response) => { diff --git a/src/handlers/api/shareLink.handler.ts b/src/handlers/api/shareLink.handler.ts new file mode 100644 index 0000000..a6886a9 --- /dev/null +++ b/src/handlers/api/shareLink.handler.ts @@ -0,0 +1,25 @@ + +import { + SHARE_LINK_ASSETS_PATH, + SHARE_LINK_GENERATE_PATH, + SHARE_LINK_PATH, + SHARE_LINK_PEOPLE_PATH +} from "@/config/routes"; +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, filters: ShareLinkFilters) => { + return API.get(SHARE_LINK_ASSETS_PATH(token), filters); +} + +export const generateShareLink = async (filters: ShareLinkFilters) => { + return API.post(SHARE_LINK_GENERATE_PATH, filters); +} + +export const getShareLinkPeople = async (token: string) => { + return API.get(SHARE_LINK_PEOPLE_PATH(token)); +} \ No newline at end of file diff --git a/src/handlers/serverUtils/user.utils.ts b/src/handlers/serverUtils/user.utils.ts index faf90db..6756e1a 100644 --- a/src/handlers/serverUtils/user.utils.ts +++ b/src/handlers/serverUtils/user.utils.ts @@ -1,6 +1,7 @@ import { appConfig } from "@/config/app.config" import { connectDB, db } from "@/config/db" import { ENV } from "@/config/environment" +import { APIError } from "@/lib/api" import { getCookie } from "@/lib/cookie" import { NextApiRequest } from "next" @@ -22,7 +23,19 @@ export const getCurrentUserFromAPIKey = () => { } }) } - return null + throw new APIError({ + message: "Invalid API key. Please check your API key variable `IMMICH_API_KEY` in the .env file", + status: 403, + }); + }) + .catch((error) => { + if (error instanceof APIError) { + throw error; + } + throw new APIError({ + message: "Failed to connect to Immich API: " + ENV.IMMICH_URL, + status: 500, + }); }) } @@ -37,6 +50,11 @@ export const getCurrentUserFromAccessToken = (token: string) => { return res.json() } return null + }).catch((error) => { + throw new APIError({ + message: "Failed to connect to Immich API: " + ENV.IMMICH_URL, + status: 500, + }); }) } diff --git a/src/helpers/asset.helper.ts b/src/helpers/asset.helper.ts index b36bc3d..706ec50 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({ id: asset.id, size: "thumbnail", token, isPeople: false }), + downloadUrl: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "original", token, isPeople: false }), + previewUrl: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "preview", token, isPeople: false }), + videoURL: ASSET_SHARE_THUMBNAIL_PATH({ id: asset.id, size: "video", token, isPeople: false }), + } +} + +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/data.helper.ts b/src/helpers/data.helper.ts index 939493a..b68bf17 100644 --- a/src/helpers/data.helper.ts +++ b/src/helpers/data.helper.ts @@ -17,4 +17,9 @@ export const removeNullOrUndefinedProperties = (obj: any) => { return v !== null && v !== undefined && v !== '' && v !== 'null' && v !== 'undefined' && v !== 'null' && v !== 'undefined' })); +} + +export const findMissingKeys = (obj: any, keys: string[]) => { + + return keys.filter((key) => !(key in obj) || obj[key] === '' || obj[key] === null || obj[key] === undefined); } \ No newline at end of file diff --git a/src/helpers/gemini.helper.ts b/src/helpers/gemini.helper.ts index d75a58e..bfe853e 100644 --- a/src/helpers/gemini.helper.ts +++ b/src/helpers/gemini.helper.ts @@ -57,7 +57,8 @@ const responseSchema = { export const parseFindQuery = async (query: string): Promise => { const prompt = ` - Parse the following query and return the query and tags: ${query} + Parse the following query and return the query and tags: ${query}. + Dont include any information that are not intentionally provided in the query. Additional Information For Parsing: today's date is ${new Date().toISOString().split('T')[0]} `; 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/lib/api.ts b/src/lib/api.ts index abc56cd..eee6dd2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,6 +2,21 @@ import type { AxiosError, AxiosRequestConfig, Method } from 'axios' import axios from 'axios' import qs from 'qs' +export class APIError extends Error { + status: number; + constructor({ + message, + status, + }: { + message: string; + status: number; + }) { + super(message); + this.name = 'APIError'; + this.message = message; + this.status = status; + } +} const handleErrors = (error: AxiosError | any) => { @@ -26,8 +41,6 @@ const handleErrors = (error: AxiosError | any) => { } export default class API { - - static setBaseURL(url: string) { if (url) { axios.defaults.baseURL = url diff --git a/src/pages/404.tsx b/src/pages/404.tsx new file mode 100644 index 0000000..5343444 --- /dev/null +++ b/src/pages/404.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +export default function FourOFour() { + return ( +
+

404

+

Page not found

+
+ ) +} \ No newline at end of file diff --git a/src/pages/albums/[albumId].tsx b/src/pages/albums/[albumId].tsx index d529562..3b40c0d 100644 --- a/src/pages/albums/[albumId].tsx +++ b/src/pages/albums/[albumId].tsx @@ -1,20 +1,16 @@ -import { ASSET_THUMBNAIL_PATH } from '@/config/routes' import PageLayout from '@/components/layouts/PageLayout' import Header from '@/components/shared/Header' import Loader from '@/components/ui/loader' import { useConfig } from '@/contexts/ConfigContext' -import { getAlbumInfo, listAlbums } from '@/handlers/api/album.handler' +import { getAlbumInfo } from '@/handlers/api/album.handler' import { IAlbum } from '@/types/album' -import Image from 'next/image' import Link from 'next/link' import React, { useEffect, useState } from 'react' -import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail' -import { Button } from '@/components/ui/button' import { useRouter } from 'next/router' import AlbumPeople from '@/components/albums/info/AlbumPeople' import AlbumImages from '@/components/albums/info/AlbumImages' -import { Camera, ExternalLink, LinkIcon, Users } from 'lucide-react' -import { humanizeNumber, pluralize } from '@/helpers/string.helper' +import { Camera, ExternalLink, Users } from 'lucide-react' +import { humanizeNumber } from '@/helpers/string.helper' export default function AlbumListPage() { const { exImmichUrl } = useConfig() diff --git a/src/pages/albums/index.tsx b/src/pages/albums/index.tsx index 05857d7..1cc340b 100644 --- a/src/pages/albums/index.tsx +++ b/src/pages/albums/index.tsx @@ -1,20 +1,19 @@ -import { ASSET_THUMBNAIL_PATH } from '@/config/routes' import PageLayout from '@/components/layouts/PageLayout' import Header from '@/components/shared/Header' import Loader from '@/components/ui/loader' import { useConfig } from '@/contexts/ConfigContext' -import { listAlbums } from '@/handlers/api/album.handler' +import { deleteAlbums, listAlbums } from '@/handlers/api/album.handler' import { IAlbum } from '@/types/album' -import Image from 'next/image' -import Link from 'next/link' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail' import { Button } from '@/components/ui/button' import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select' import { useRouter } from 'next/router' -import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons' -import { SortAsc, SortDesc } from 'lucide-react' +import { Share, SortAsc, SortDesc, Trash } from 'lucide-react' import { Input } from '@/components/ui/input' +import AlbumShareDialog, { IAlbumShareDialogRef } from '@/components/albums/share/AlbumShareDialog' +import { AlertDialog } from '@/components/ui/alert-dialog' +import toast from 'react-hot-toast' const SORT_BY_OPTIONS = [ { value: 'lastPhotoDate', label: 'Last Photo Date' }, @@ -27,7 +26,6 @@ const SORT_BY_OPTIONS = [ { value: 'faceCount', label: 'Number of People' }, ] export default function AlbumListPage() { - const { exImmichUrl } = useConfig() const router = useRouter() const [search, setSearch] = useState('') const { query, pathname } = router @@ -35,11 +33,14 @@ export default function AlbumListPage() { const [albums, setAlbums] = useState([]) const [loading, setLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') - const [selectedAlbums, setSelectedAlbums] = useState([]) + const [selectedAlbumsIds, setSelectedAlbumsIds] = useState([]) + const albumShareDialogRef = useRef(null); + const [deleting, setDeleting] = useState(false) const selectedSortBy = useMemo(() => SORT_BY_OPTIONS.find((option) => option.value === sortBy), [sortBy]) - const searchedAlbums = useMemo(() => albums.filter((album) => album.albumName.toLowerCase().includes(search.toLowerCase())), [albums, search]) + const searchedAlbums = useMemo(() => albums.filter((album) => album.albumName.toLowerCase().includes(search.toLowerCase())), [albums, search]) + const fetchAlbums = async () => { setLoading(true) listAlbums({ @@ -55,18 +56,33 @@ export default function AlbumListPage() { }) } + const selectedAlbums = useMemo(() => albums.filter((album) => selectedAlbumsIds.includes(album.id)), [albums, selectedAlbumsIds]) + useEffect(() => { fetchAlbums() }, [sortBy, sortOrder]) + const handleSelect = (checked: boolean, albumId: string) => { if (checked) { - setSelectedAlbums([...selectedAlbums, albumId]) + setSelectedAlbumsIds([...selectedAlbumsIds, albumId]) } else { - setSelectedAlbums(selectedAlbums.filter((id) => id !== albumId)) + setSelectedAlbumsIds(selectedAlbumsIds.filter((id) => id !== albumId)) } } + const handleDeleteAlbums = async () => { + setDeleting(true) + return deleteAlbums(selectedAlbumsIds).then(() => { + setSelectedAlbumsIds([]) + setAlbums(albums.filter((album) => !selectedAlbumsIds.includes(album.id))) + toast.success(`Deleted ${selectedAlbumsIds.length} albums`) + }).catch((error) => { + toast.error(error.message) + }).finally(() => { + setDeleting(false) + }) + } const renderContent = () => { if (loading) { @@ -78,7 +94,12 @@ export default function AlbumListPage() { return (
{searchedAlbums.map((album) => ( - handleSelect(checked, album.id)} /> + handleSelect(checked, album.id)} + /> ))}
) @@ -89,17 +110,45 @@ export default function AlbumListPage() { leftComponent="Manage Albums" rightComponent={
+ {!!selectedAlbumsIds.length && ( + <> + + + + + + + )} setSearch(e.target.value)} /> { + push({ + pathname: "/assets/missing-locations", + query: { + ...query, + groupBy: value, + startDate: undefined, + albumId: undefined, + }, + }); + }}> + + + + + Album + Date + +
- - { }} /> - +
} />
- - + +
+ +
+

+ {config.selectedIds.length} Selected +

+
+ {config.selectedIds.length === config.assets.length ? ( + + ) : ( + + )} + {/* Seperator */} + + + +
+ + + +
+
+
); diff --git a/src/pages/find/index.tsx b/src/pages/find/index.tsx index 758e97d..a714348 100644 --- a/src/pages/find/index.tsx +++ b/src/pages/find/index.tsx @@ -7,21 +7,38 @@ import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH } from '@/config/routes'; import { useConfig } from '@/contexts/ConfigContext'; import { findAssets } from '@/handlers/api/asset.handler'; import { IAsset } from '@/types/asset'; -import { Captions, Megaphone, Search, Speaker, TriangleAlert, WandSparkles } from 'lucide-react'; +import { ArrowUpRight, Captions, Megaphone, Search, Speaker, TriangleAlert, WandSparkles } from 'lucide-react'; import Image from 'next/image'; import React, { useMemo, useState } from 'react' import Lightbox from 'yet-another-react-lightbox'; import { Button } from "@/components/ui/button"; import Link from "next/link"; +import AssetGrid from "@/components/shared/AssetGrid"; +import FloatingBar from "@/components/shared/FloatingBar"; +import AssetsBulkDeleteButton from "@/components/shared/AssetsBulkDeleteButton"; +interface IFindFilters { + [key: string]: string; +} +const FILTER_KEY_MAP = { + "city": "City", + "state": "State", + "country": "Country", + "takenAfter": "Taken After", + "takenBefore": "Taken Before", + "size": "Size", + "model": "Model", + "personIds": "People", +} export default function FindPage() { - const [index, setIndex] = useState(-1); + const [selectedIds, setSelectedIds] = useState([]); const { geminiEnabled, exImmichUrl } = useConfig(); const [query, setQuery] = useState(''); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState({}); const slides = useMemo( () => @@ -33,19 +50,61 @@ export default function FindPage() { [assets] ); - + const appliedFilters: { + label: string; + value: string; + }[] = useMemo(() => { + return Object.entries(filters) + .filter(([_key, value]) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== undefined && value !== null && value !== ''; + }) + .map(([key, value]) => ({ + label: key, + value: Array.isArray(value) ? value.join(', ') : value, + })) + .filter((filter) => filter.label !== "query"); + }, [filters]); + const handleSearch = (query: string) => { setQuery(query); setLoading(true); findAssets(query) - .then(({ assets }) => { + .then(({ assets, filters: _filters }) => { setAssets(assets); + setFilters(_filters); }) .finally(() => { setLoading(false); }); } + const handleSelectionChange = (ids: string[]) => { + setSelectedIds(ids); + } + + const handleDelete = (ids: string[]) => { + setSelectedIds([]); + setAssets(assets.filter((asset) => !ids.includes(asset.id))); + + } + + const renderFilters = () => { + if (appliedFilters.length === 0) return null; + return ( +
+ {appliedFilters.map((filter) => ( +
+

{FILTER_KEY_MAP[filter.label as keyof typeof FILTER_KEY_MAP] || filter.label}

+

{filter.value}

+
+ ))} +
+ ) + } + const renderContent = () => { if (loading) { return
@@ -53,52 +112,45 @@ export default function FindPage() {
} else if (query.length === 0) { - return
- -

Search for photos in natural language

-

- Example: Sunset photos from last week. Use @ to search for photos of a specific person. -

-

- Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini. -

-
+ return ( +
+ +

Search for photos in natural language

+

+ Example: Sunset photos from last week. Use @ to search for photos of a specific person. +

+

+ Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini. +

+
+ ) } else if (assets.length === 0) { - return
- No results found + return
+ +

No results found for the below filters

+

+ {renderFilters()} +

} + return ( <> - = 0} - index={index} - close={() => setIndex(-1)} - /> -
- {assets.map((asset, idx) => ( -
- - Open in Immich - - {asset.id} setIndex(idx)} + {renderFilters()} +
+ + {selectedIds.length > 0 && ( + +

+ {selectedIds.length} Selected +

+ -
- ))} + + )}
) diff --git a/src/pages/s/[token].tsx b/src/pages/s/[token].tsx new file mode 100644 index 0000000..0abe807 --- /dev/null +++ b/src/pages/s/[token].tsx @@ -0,0 +1,154 @@ +import PageLayout from '@/components/layouts/PageLayout' +import Header from '@/components/shared/Header' +import Loader from '@/components/ui/loader' +import React, { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'next/router' +import { Camera } from 'lucide-react' +import { humanizeNumber } from '@/helpers/string.helper' +import { getShareLinkAssets, getShareLinkInfo, getShareLinkPeople } from '@/handlers/api/shareLink.handler' +import AssetGrid from '@/components/shared/AssetGrid' +import { IAsset } from '@/types/asset' +import { IPerson } from '@/types/person' +import PeopleList from '@/components/shared/PeopleList' +import { ShareLinkFilters } from '@/types/shareLink' +import clsx from 'clsx' +import { LinkBreak2Icon } from '@radix-ui/react-icons' + +export default function AlbumListPage() { + const router = useRouter() + const { pathname, query } = router + const { token, personIds } = query as { token: string, personIds: string[] } + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [people, setPeople] = useState([]) + const [peopleLoading, setPeopleLoading] = useState(false) + const [config, setConfig] = useState({}) + const [filters, setFilters] = useState(null) + + + const handleSelectPerson = (person: IPerson) => { + const personIds = query.personIds as string[] + let currentPersonIds = personIds + if (!personIds) { + currentPersonIds = [] + } else { + currentPersonIds = Array.isArray(personIds) ? personIds : [personIds] + } + const isPersonSelected = currentPersonIds.includes(person.id); + const newPersonIds = isPersonSelected ? currentPersonIds.filter((id) => id !== person.id) : [...currentPersonIds, person.id]; + + router.push({ + pathname: pathname, + query: { + ...query, + personIds: newPersonIds + } + }) + setFilters({ + personIds: newPersonIds + }) + } + + const fetchAssets = async () => { + if (!filters) return + setLoading(true) + getShareLinkAssets(token, filters) + .then(({ assets }) => { + setAssets(assets) + }) + .catch((error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setLoading(false) + }) + } + + const fetchAlbumInfo = async () => { + setLoading(true) + getShareLinkInfo(token) + .then((conf) => { + setConfig(conf) + if (conf.p) { + fetchPeople() + } + setFilters({ + personIds: query.personIds as string[] + }) + }) + .catch((error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setLoading(false) + }) + } + + const fetchPeople = async () => { + setPeopleLoading(true) + getShareLinkPeople(token) + .then(setPeople) + .catch((error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setPeopleLoading(false) + }) + } + + useEffect(() => { + fetchAlbumInfo() + }, []) + + useEffect(() => { + if (!filters) return + fetchAssets() + }, [filters]) + + const renderContent = () => { + if (loading) return + else if (errorMessage) return
+ +

{errorMessage}

+
+ return ( +
+ {config.p && ( +
+ {peopleLoading ? : + } +
+ )} +
+ {loading ? : } +
+
+ ) + } + return ( + +
+

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

+
+ ) + } + /> + {renderContent()} + + ) +} diff --git a/src/schema/person.schema.ts b/src/schema/person.schema.ts index bf8d448..0559c22 100644 --- a/src/schema/person.schema.ts +++ b/src/schema/person.schema.ts @@ -11,4 +11,6 @@ export const person = pgTable("person", { isHidden: boolean("isHidden").notNull().default(false), birthDate: date("birthDate", { mode: "date" }), faceAssetId: uuid("faceAssetId"), -}); \ No newline at end of file +}); + +export type Person = typeof person.$inferSelect; \ No newline at end of file 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..bb691ca --- /dev/null +++ b/src/types/shareLink.d.ts @@ -0,0 +1,8 @@ +export interface ShareLinkFilters { + personIds?: string[]; + albumIds?: string[]; + startDate?: string; + endDate?: string; + p?: boolean; + expiresIn?: string; +} \ No newline at end of file