diff --git a/bun.lockb b/bun.lockb index 2ad086a..3d7d613 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ced96fc..d6f629f 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", @@ -60,6 +61,7 @@ "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 index 619cfde..cd20447 100644 --- a/src/components/albums/AlbumCreateDialog.tsx +++ b/src/components/albums/AlbumCreateDialog.tsx @@ -55,7 +55,7 @@ export default function AlbumCreateDialog({ onSubmit, assetIds }: IProps) { return ( - + diff --git a/src/components/albums/AlbumSelectorDialog.tsx b/src/components/albums/AlbumSelectorDialog.tsx index af49ef7..d5dfc88 100644 --- a/src/components/albums/AlbumSelectorDialog.tsx +++ b/src/components/albums/AlbumSelectorDialog.tsx @@ -78,7 +78,7 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) { return ( - + 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..b37a69b 100644 --- a/src/components/albums/info/AlbumPeople.tsx +++ b/src/components/albums/info/AlbumPeople.tsx @@ -99,7 +99,7 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) { } return ( -
+
{selectedPerson && (
@@ -157,7 +157,7 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) { )} {selectedPeople.length > 0 && ( -
+
diff --git a/src/components/albums/list/AlbumThumbnail.tsx b/src/components/albums/list/AlbumThumbnail.tsx index a4fb7a1..2278ccd 100644 --- a/src/components/albums/list/AlbumThumbnail.tsx +++ b/src/components/albums/list/AlbumThumbnail.tsx @@ -1,23 +1,23 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' import Link from 'next/link'; -import { useConfig } from '@/contexts/ConfigContext'; import { humanizeBytes, humanizeNumber, pluralize } from '@/helpers/string.helper'; import LazyImage from '@/components/ui/lazy-image'; import { ASSET_THUMBNAIL_PATH } from '@/config/routes'; import { IAlbum } from '@/types/album'; -import { Badge } from '@/components/ui/badge'; import { formatDate } from '@/helpers/date.helper'; import { Checkbox } from '@/components/ui/checkbox'; import { differenceInDays } from 'date-fns'; -import { FaceIcon } from '@radix-ui/react-icons'; -import { Calendar, Camera, Image, User } from 'lucide-react'; +import { Calendar, Camera } from 'lucide-react'; interface IAlbumThumbnailProps { album: IAlbum; onSelect: (checked: boolean) => 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..6fc44f8 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); @@ -44,14 +45,15 @@ export default function PotentialAlbumsAssets() { { title: "Immich Link", value: ( - + Open in Immich ), }, ], })); - }, [assets, selectedIds]); + }, [assets, selectedIds, exImmichUrl]); + const slides = useMemo( () => @@ -73,17 +75,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 +137,7 @@ export default function PotentialAlbumsAssets() {
+
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..86856cf 100644 --- a/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx +++ b/src/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog.tsx @@ -12,7 +12,6 @@ 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"; const LazyMap = dynamic(() => import("./Map"), { @@ -30,14 +29,14 @@ export default function TagMissingLocationDialog({ const [mapPosition,setMapPosition] = useState({ latitude: 48.0, longitude: 16.0, - name: "home1" + name: "home" }); return ( - + @@ -46,12 +45,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 +61,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..8f56340 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"; @@ -12,6 +11,7 @@ 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"; type RootLayoutProps = { children: ReactNode; diff --git a/src/components/shared/FloatingBar.tsx b/src/components/shared/FloatingBar.tsx new file mode 100644 index 0000000..83d89db --- /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/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d898f67 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,207 @@ +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 +} + +const AlertDialog = React.forwardRef>(({ + children, + title, + description, + onConfirm, + onCancel, + variant, + asChild, + ...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..cdad784 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -5,9 +5,15 @@ import { ENV } from "./environment"; import * as schema from "@/schema"; import { sql } from 'drizzle-orm'; -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 { diff --git a/src/config/environment.ts b/src/config/environment.ts index 692c635..d503f56 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -2,7 +2,12 @@ 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, diff --git a/src/config/routes.ts b/src/config/routes.ts index a57bd36..ca1db88 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -25,9 +25,11 @@ export const ALBUM_PEOPLE_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" export const ALBUM_ASSETS_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/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"; diff --git a/src/contexts/MissingLocationContext.tsx b/src/contexts/MissingLocationContext.tsx index 5044a12..445d3f7 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[]; } @@ -12,6 +13,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..2b86b79 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -3,6 +3,7 @@ import { 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 +19,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 => { 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/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 ? ( + + ) : ( + + )} + +
+
+
); diff --git a/src/pages/find/index.tsx b/src/pages/find/index.tsx index 758e97d..83aaa13 100644 --- a/src/pages/find/index.tsx +++ b/src/pages/find/index.tsx @@ -7,14 +7,27 @@ 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"; +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); @@ -22,6 +35,7 @@ export default function FindPage() { const [query, setQuery] = useState(''); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState({}); const slides = useMemo( () => @@ -33,19 +47,46 @@ export default function FindPage() { [assets] ); - + const appliedFilters: { + label: string; + value: string; + }[] = useMemo(() => { + return Object.entries(filters) + .filter(([_key, value]) => value !== undefined) + .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 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,37 +94,46 @@ 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)} - /> + = 0} + index={index} + close={() => setIndex(-1)} + /> + {renderFilters()}
{assets.map((asset, idx) => ( -
- - Open in Immich +
+ + Open +