From 21b053aac6bbfd73aab15de56b3c1ceebcffadb7 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Mon, 19 Aug 2024 23:43:45 +0530 Subject: [PATCH] feat: potential albums feature Signed-off-by: Varun Raj --- bun.lockb | Bin 236055 -> 237802 bytes package.json | 5 +- src/components/albums/AlbumSelectorDialog.tsx | 94 +++++++++ .../PotentialAlbumsAssets.tsx | 86 ++++++++ .../potential-albums/PotentialAlbumsDates.tsx | 45 ++++ .../potential-albums/PotentialDateItem.tsx | 26 +++ src/components/layouts/PageLayout.tsx | 21 +- src/components/layouts/RootLayout.tsx | 5 +- src/components/shared/Sidebar.tsx | 5 - src/components/ui/toast.tsx | 127 ++++++++++++ src/components/ui/toaster.tsx | 33 +++ src/components/ui/use-toast.ts | 192 ++++++++++++++++++ src/config/constants/sidebarNavs.tsx | 18 +- src/config/routes.ts | 8 + src/contexts/PotentialAlbumContext.tsx | 26 +++ src/handlers/api/album.handler.ts | 29 +++ src/helpers/asset.helper.ts | 13 ++ src/pages/albums/potential-albums.tsx | 77 +++++++ .../api/albums/potential-albums-assets.ts | 54 +++++ .../api/albums/potential-albums-dates.ts | 36 ++++ .../api/immich-proxy/asset/thumbnail/[id].ts | 47 +++++ src/types/album.d.ts | 28 +++ src/types/asset.d.ts | 26 +++ 23 files changed, 974 insertions(+), 27 deletions(-) create mode 100644 src/components/albums/AlbumSelectorDialog.tsx create mode 100644 src/components/albums/potential-albums/PotentialAlbumsAssets.tsx create mode 100644 src/components/albums/potential-albums/PotentialAlbumsDates.tsx create mode 100644 src/components/albums/potential-albums/PotentialDateItem.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/use-toast.ts create mode 100644 src/contexts/PotentialAlbumContext.tsx create mode 100644 src/handlers/api/album.handler.ts create mode 100644 src/helpers/asset.helper.ts create mode 100644 src/pages/albums/potential-albums.tsx create mode 100644 src/pages/api/albums/potential-albums-assets.ts create mode 100644 src/pages/api/albums/potential-albums-dates.ts create mode 100644 src/pages/api/immich-proxy/asset/thumbnail/[id].ts create mode 100644 src/types/album.d.ts create mode 100644 src/types/asset.d.ts diff --git a/bun.lockb b/bun.lockb index c453f14da596941eed92367bd588ad6205810da7..b0d72354f6144a8c26ffc6e6f33e4ac85f73c48b 100755 GIT binary patch delta 48151 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 delta 46901 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 diff --git a/package.json b/package.json index e53bc5d..e41f962 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-table": "^8.20.1", "@types/qs": "^6.9.15", @@ -40,10 +41,12 @@ "react": "^18", "react-day-picker": "9.0.8", "react-dom": "^18", + "react-grid-gallery": "^1.0.1", "recharts": "^2.12.7", "sass": "^1.77.8", "tailwind-merge": "^2.5.2", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "yet-another-react-lightbox": "^3.21.4" }, "devDependencies": { "@types/node": "^20", diff --git a/src/components/albums/AlbumSelectorDialog.tsx b/src/components/albums/AlbumSelectorDialog.tsx new file mode 100644 index 0000000..af49ef7 --- /dev/null +++ b/src/components/albums/AlbumSelectorDialog.tsx @@ -0,0 +1,94 @@ +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 } from "@/types/album"; +import { Input } from "../ui/input"; + +interface IProps { + onSelected: (album: IAlbum) => Promise; +} +export default function AlbumSelectorDialog({ onSelected }: 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 fetchData = () => { + return listAlbums() + .then(setAlbums) + .catch(setErrorMessage) + .finally(() => setLoading(false)); + }; + + const filteredAlbums = useMemo(() => { + return albums.filter((album) => + album.albumName.toLowerCase().includes(search.toLowerCase()) + ); + }, [albums, search]); + + const handleSelect = (album: IAlbum) => () => { + onSelected(album).then(() => { + setOpen(false); + setSearch(""); + }); + } + + useEffect(() => { + if (open && !albums.length) fetchData(); + }, [open]); + + const renderContent = () => { + if (loading) return

Loading...

; + if (errorMessage) return

{errorMessage}

; + return ( +
+ { + setSearch(e.target.value); + }} + /> +
+ {filteredAlbums.map((album) => ( +
+

{album.albumName}

+
+ ))} +
+
+ ); + }; + + return ( + + + + + + + Select Albums + + Select the albums you want to add the selected assets to + + + {renderContent()} + + + ); +} diff --git a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx new file mode 100644 index 0000000..9ba09db --- /dev/null +++ b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx @@ -0,0 +1,86 @@ +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 { Gallery } from "react-grid-gallery"; +import Lightbox from "yet-another-react-lightbox"; +import Captions from "yet-another-react-lightbox/plugins/captions"; + +export default function PotentialAlbumsAssets() { + const { startDate, selectedIds, assets, updateContext } = usePotentialAlbumContext(); + + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const [index, setIndex] = useState(-1); + + const fetchAssets = async () => { + setLoading(true); + updateContext({ + assets: [], + }) + return listPotentialAlbumsAssets({ startDate }) + .then((assets) => updateContext({ assets })) + .catch(setErrorMessage) + .finally(() => setLoading(false)); + }; + + const images = useMemo(() => { + return assets.map((p) => ({ + ...p, + src: p.url as string, + original: p.previewUrl as string, + width: p.exifImageWidth as number, + height: p.exifImageHeight as number, + isSelected: selectedIds.includes(p.id), + })); + }, [assets, selectedIds]); + + const slides = useMemo( + () => + images.map(({ original, width, height }) => ({ + src: original, + width, + height, + })), + [images] + ); + + const handleClick = (idx: number) => setIndex(idx); + + const handleSelect = (_idx: number, asset: IAsset) => { + const isPresent =selectedIds.includes(asset.id) + if(isPresent){ + updateContext({selectedIds:selectedIds.filter((id)=>id!==asset.id)}) + } + else{ + updateContext({selectedIds:[...selectedIds,asset.id]}) + } + + }; + + useEffect(() => { + if (startDate) fetchAssets(); + }, [startDate]); + + return ( + <> + = 0} + index={index} + close={() => setIndex(-1)} + /> +
+ +
+ + ); +} diff --git a/src/components/albums/potential-albums/PotentialAlbumsDates.tsx b/src/components/albums/potential-albums/PotentialAlbumsDates.tsx new file mode 100644 index 0000000..03a428c --- /dev/null +++ b/src/components/albums/potential-albums/PotentialAlbumsDates.tsx @@ -0,0 +1,45 @@ +import { + IPotentialAlbumsDatesResponse, + listPotentialAlbumsDates, +} from "@/handlers/api/album.handler"; +import React, { use, useEffect, useState } from "react"; +import PotentialDateItem from "./PotentialDateItem"; +import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; + +export default function PotentialAlbumsDates() { + const { updateContext } = usePotentialAlbumContext(); + const [dateRecords, setDateRecords] = React.useState< + IPotentialAlbumsDatesResponse[] + >([]); + + const [loading, setLoading] = useState(false); + + const [errorMessage, setErrorMessage] = useState(null); + + const fetchData = async () => { + return listPotentialAlbumsDates({}) + .then(setDateRecords) + .catch(setErrorMessage) + .finally(() => setLoading(false)); + }; + + const handleSelect = (date: string) => { + updateContext({ startDate: date }); + }; + + useEffect(() => { + fetchData(); + }, []); + + return ( +
+ {dateRecords.map((record) => ( + + ))} +
+ ); +} diff --git a/src/components/albums/potential-albums/PotentialDateItem.tsx b/src/components/albums/potential-albums/PotentialDateItem.tsx new file mode 100644 index 0000000..13cf45b --- /dev/null +++ b/src/components/albums/potential-albums/PotentialDateItem.tsx @@ -0,0 +1,26 @@ +import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext"; +import { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler"; +import { cn } from "@/lib/utils"; +import React from "react"; + +interface IProps { + record: IPotentialAlbumsDatesResponse; + onSelect: (date: string) => void; +} +export default function PotentialDateItem({ record, onSelect }: IProps) { + const { startDate } = usePotentialAlbumContext() + return ( +
onSelect(record.date)} + key={record.date} + className={ + cn("flex gap-1 flex-col p-2 py-1 rounded-lg hover:dark:bg-zinc-800 hover:bg-zinc-100 px-4", + startDate === record.date ? "bg-zinc-100" : "") + } + > +

{record.date}

+

{record.asset_count} Orphan Assets

+
+ ); +} diff --git a/src/components/layouts/PageLayout.tsx b/src/components/layouts/PageLayout.tsx index 5bfe866..28aa171 100644 --- a/src/components/layouts/PageLayout.tsx +++ b/src/components/layouts/PageLayout.tsx @@ -4,10 +4,13 @@ import { cn } from "@/lib/utils"; interface IProps { children: React.ReactNode[] | React.ReactNode; + className?: string; } -export default function PageLayout({ children }: IProps) { +export default function PageLayout({ children, className }: IProps) { const header = Array.isArray(children) - ? children.find((child) => React.isValidElement(child) && child.type === Header) + ? children.find( + (child) => React.isValidElement(child) && child.type === Header + ) : null; const childrenWithoutHeader = Array.isArray(children) @@ -19,11 +22,15 @@ export default function PageLayout({ children }: IProps) { return ( <> {header} -
+
{childrenWithoutHeader}
diff --git a/src/components/layouts/RootLayout.tsx b/src/components/layouts/RootLayout.tsx index 2270616..603d17b 100644 --- a/src/components/layouts/RootLayout.tsx +++ b/src/components/layouts/RootLayout.tsx @@ -1,6 +1,7 @@ import { cn } from "@/lib/utils" import Sidebar from "../shared/Sidebar" +import { Toaster } from "../ui/toaster" type RootLayoutProps = { @@ -9,11 +10,13 @@ type RootLayoutProps = { export default function RootLayout({ children }: RootLayoutProps) { return ( -
+
{children}
+ +
) } diff --git a/src/components/shared/Sidebar.tsx b/src/components/shared/Sidebar.tsx index a9c5fe5..aa63e6d 100644 --- a/src/components/shared/Sidebar.tsx +++ b/src/components/shared/Sidebar.tsx @@ -51,11 +51,6 @@ export default function Sidebar() { > {nav.icon} {nav.title} - {nav.badge && ( - - {nav.badge} - - )} ))} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..f79747a --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import { Cross2Icon } from "@radix-ui/react-icons" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..a2209ba --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +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/components/ui/use-toast.ts b/src/components/ui/use-toast.ts new file mode 100644 index 0000000..1671307 --- /dev/null +++ b/src/components/ui/use-toast.ts @@ -0,0 +1,192 @@ +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index 0299445..30e727f 100644 --- a/src/config/constants/sidebarNavs.tsx +++ b/src/config/constants/sidebarNavs.tsx @@ -1,5 +1,4 @@ -import { Home, Image, User } from "lucide-react"; - +import { GalleryVerticalEnd, Home, Image, User } from "lucide-react"; export const sidebarNavs = [ { @@ -13,15 +12,8 @@ export const sidebarNavs = [ icon: , }, { - title: "Statistics", - link: "/statistics", - icon: , - badge: "Comming Soon" + title: "Potential Albums", + link: "/albums/potential-albums", + icon: , }, - { - title: "Albums", - link: "/albums", - icon: , - badge: "Comming Soon" - }, -]; \ No newline at end of file +]; diff --git a/src/config/routes.ts b/src/config/routes.ts index c5fa764..836a79c 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -9,3 +9,11 @@ export const MERGE_PERSON_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/people/ export const EXIF_DISTRIBUTION_PATH = (column: string) => BASE_API_ENDPOINT + "/analytics/exif/" + column; + +// Albums +export const LIST_POTENTIAL_ALBUMS_DATES_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-dates"; +export const LIST_POTENTIAL_ALBUMS_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-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 LIST_ALBUMS_PATH = BASE_PROXY_ENDPOINT + "/albums"; +export const ADD_ASSETS_ALBUMS_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/albums/" + id + "/assets"; \ No newline at end of file diff --git a/src/contexts/PotentialAlbumContext.tsx b/src/contexts/PotentialAlbumContext.tsx new file mode 100644 index 0000000..a02d408 --- /dev/null +++ b/src/contexts/PotentialAlbumContext.tsx @@ -0,0 +1,26 @@ +import { IAsset } from "@/types/asset"; +import { createContext, useContext } from "react"; + +export interface IPotentialAlbumConfig { + startDate?: string; + selectedIds: string[]; + assets: IAsset[]; +} + +export interface IPotentialAlbumContext extends IPotentialAlbumConfig { + updateContext: (newConfig: Partial) => void; +} +const PotentialAlbumContext = createContext({ + startDate: undefined, + selectedIds: [], + assets: [], + updateContext: () => { }, +}); + +export default PotentialAlbumContext; + +const usePotentialAlbumContext = () => { + return useContext(PotentialAlbumContext) as IPotentialAlbumContext; +} +export { usePotentialAlbumContext }; + diff --git a/src/handlers/api/album.handler.ts b/src/handlers/api/album.handler.ts new file mode 100644 index 0000000..61ec92f --- /dev/null +++ b/src/handlers/api/album.handler.ts @@ -0,0 +1,29 @@ +import { ADD_ASSETS_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH } from "@/config/routes"; +import { cleanUpAsset } from "@/helpers/asset.helper"; +import API from "@/lib/api"; +import { IAsset } from "@/types/asset"; + +interface IPotentialAlbumsDatesFilters { + startDate?: string; + endDate?: string; +} +export interface IPotentialAlbumsDatesResponse { + date: string; + asset_count: number; +} + +export const listPotentialAlbumsDates = async (filters: IPotentialAlbumsDatesFilters): Promise => { + return API.get(LIST_POTENTIAL_ALBUMS_DATES_PATH, filters); +} + +export const listPotentialAlbumsAssets = async (filters: IPotentialAlbumsDatesFilters): Promise => { + return API.get(LIST_POTENTIAL_ALBUMS_ASSETS_PATH, filters).then((assets) => assets.map(cleanUpAsset)); +} + +export const listAlbums = async () => { + return API.get(LIST_ALBUMS_PATH); +} + +export const addAssetToAlbum = async (albumId: string, assetIds: string[]) => { + return API.put(ADD_ASSETS_ALBUMS_PATH(albumId), { ids: assetIds }); +} diff --git a/src/helpers/asset.helper.ts b/src/helpers/asset.helper.ts new file mode 100644 index 0000000..f505826 --- /dev/null +++ b/src/helpers/asset.helper.ts @@ -0,0 +1,13 @@ +import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH, PERSON_THUBNAIL_PATH } from "@/config/routes" +import { IPerson } from "@/types/person" +import { parseDate } from "./date.helper" +import { IAsset } from "@/types/asset" + + +export const cleanUpAsset = (asset: IAsset): IAsset => { + return { + ...asset, + url: ASSET_THUMBNAIL_PATH(asset.id), + previewUrl: ASSET_PREVIEW_PATH(asset.id), + } +} \ No newline at end of file diff --git a/src/pages/albums/potential-albums.tsx b/src/pages/albums/potential-albums.tsx new file mode 100644 index 0000000..958923d --- /dev/null +++ b/src/pages/albums/potential-albums.tsx @@ -0,0 +1,77 @@ +import AlbumSelectorDialog from "@/components/albums/AlbumSelectorDialog"; +import PotentialAlbumsAssets from "@/components/albums/potential-albums/PotentialAlbumsAssets"; +import PotentialAlbumsDates from "@/components/albums/potential-albums/PotentialAlbumsDates"; +import PageLayout from "@/components/layouts/PageLayout"; +import Header from "@/components/shared/Header"; +import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/components/ui/use-toast"; +import PotentialAlbumContext, { + IPotentialAlbumConfig, +} from "@/contexts/PotentialAlbumContext"; +import { addAssetToAlbum } from "@/handlers/api/album.handler"; +import { IAlbum } from "@/types/album"; +import React from "react"; + +export default function PotentialAlbums() { + const { toast } = useToast(); + + const [config, setConfig] = React.useState({ + startDate: undefined, + selectedIds: [], + assets: [], + }); + + const handleSelect = (album: IAlbum) => { + return addAssetToAlbum(album.id, config.selectedIds) + .then(() => { + setConfig({ ...config, selectedIds: [] }); + setConfig({ + ...config, + assets: config.assets.filter( + (asset) => !config.selectedIds.includes(asset.id) + ), + }); + }) + .then(() => { + toast({ + title: `Assets added to ${album.albumName}`, + description: `${config.selectedIds.length} assets added to album`, + }); + }) + .catch(() => { + toast({ + title: "Error", + description: "Failed to add assets album", + variant: "destructive", + }); + }); + }; + + return ( + +
+ + {config.selectedIds.length} Selected + + + + } + /> + ) => + setConfig({ ...config, ...newConfig }), + }} + > +
+ + +
+
+ + ); +} diff --git a/src/pages/api/albums/potential-albums-assets.ts b/src/pages/api/albums/potential-albums-assets.ts new file mode 100644 index 0000000..3151c23 --- /dev/null +++ b/src/pages/api/albums/potential-albums-assets.ts @@ -0,0 +1,54 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { CHART_COLORS } from "@/config/constants/chart.constant"; +import { db } from "@/config/db"; +import { exif } from "@/schema"; +import { count, desc, isNotNull, ne, sql } from "drizzle-orm"; +import type { NextApiRequest, NextApiResponse } from "next"; + +const SELECT_ORPHAN_PHOTOS = (date: string) => + sql.raw(` + SELECT + a."id", + a."ownerId", + a."deviceId", + a."type", + a."originalPath", + a."previewPath", + a."isFavorite", + a."duration", + a."thumbnailPath", + a."encodedVideoPath", + a."originalFileName", + a."sidecarPath", + a."thumbhash", + a."deletedAt", + a."localDateTime", + e."exifImageWidth", + e."exifImageHeight" + FROM + assets a + LEFT JOIN + albums_assets_assets aaa + ON a.id = aaa."assetsId" + LEFT JOIN + exif e + ON a.id = e."assetId" + WHERE + aaa."albumsId" IS NULL + AND a."localDateTime"::date = '${date}'; +`); + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + const { startDate } = req.query as { startDate: string }; + const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(startDate)); + return res.status(200).json(rows); + } catch (error: any) { + res.status(500).json({ + error: error?.message, + }); + } +} diff --git a/src/pages/api/albums/potential-albums-dates.ts b/src/pages/api/albums/potential-albums-dates.ts new file mode 100644 index 0000000..41434cf --- /dev/null +++ b/src/pages/api/albums/potential-albums-dates.ts @@ -0,0 +1,36 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { CHART_COLORS } from "@/config/constants/chart.constant"; +import { db } from "@/config/db"; +import { exif } from "@/schema"; +import { count, desc, isNotNull, ne, sql } from "drizzle-orm"; +import type { NextApiRequest, NextApiResponse } from "next"; + +const SELECT_ORPHAN_PHOTOS = sql` +SELECT + DATE(a."localDateTime") AS "date", + COUNT(a."id") AS "asset_count" +FROM + "assets" a +LEFT JOIN + "albums_assets_assets" aaa ON a."id" = aaa."assetsId" +WHERE + aaa."albumsId" IS NULL +GROUP BY + DATE(a."localDateTime") +ORDER BY + "asset_count" DESC; +`; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS); + return res.status(200).json(rows); + } catch (error: any) { + res.status(500).json({ + error: error?.message, + }); + } +} diff --git a/src/pages/api/immich-proxy/asset/thumbnail/[id].ts b/src/pages/api/immich-proxy/asset/thumbnail/[id].ts new file mode 100644 index 0000000..f57e1ba --- /dev/null +++ b/src/pages/api/immich-proxy/asset/thumbnail/[id].ts @@ -0,0 +1,47 @@ +// pages/api/proxy.js + +import { ENV } from '@/config/environment'; +import { NextApiRequest, NextApiResponse } from 'next' + +export const config = { + api: { + bodyParser: false, + }, +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ message: 'Method Not Allowed' }) + } + + const { id, size } = req.query; + const targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/thumbnail?size=${size || 'thumbnail'}`; + + try { + // Forward the request to the target API + const response = await fetch(targetUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/octet-stream', + 'x-api-key': ENV.IMMICH_API_KEY, + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + // Get the image data from the response + const imageBuffer = await response.arrayBuffer() + + // Set the appropriate headers for the image response + res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png') + res.setHeader('Content-Length', imageBuffer.byteLength) + + // Send the image data + res.send(Buffer.from(imageBuffer)) + } catch (error) { + console.error('Error:', error) + res.status(500).json({ message: 'Internal Server Error' }) + } +} \ No newline at end of file diff --git a/src/types/album.d.ts b/src/types/album.d.ts new file mode 100644 index 0000000..00c430b --- /dev/null +++ b/src/types/album.d.ts @@ -0,0 +1,28 @@ +export interface IAlbum { + albumName: string; + description: string; + albumThumbnailAssetId: string; + createdAt: Date; + updatedAt: Date; + id: string; + ownerId: string; + owner: IAlbumOwner; + albumUsers: any[]; + shared: boolean; + hasSharedLink: boolean; + startDate: Date; + endDate: Date; + assets: any[]; + assetCount: number; + isActivityEnabled: boolean; + order: string; + lastModifiedAssetTimestamp: Date; +} + +export interface IAlbumOwner { + id: string; + email: string; + name: string; + profileImagePath: string; + avatarColor: string; +} diff --git a/src/types/asset.d.ts b/src/types/asset.d.ts new file mode 100644 index 0000000..d9ee2f5 --- /dev/null +++ b/src/types/asset.d.ts @@ -0,0 +1,26 @@ +export interface IAsset { + id: string; + ownerId: string; + deviceId: string; + type: string; + originalPath: string; + previewPath: string; + isFavorite: boolean; + duration: null; + thumbnailPath: string; + encodedVideoPath: string; + originalFileName: string; + sidecarPath: null; + thumbhash: IAssetThumbhash; + deletedAt: null; + localDateTime: string; + exifImageWidth: number; + exifImageHeight: number; + url: string; + previewUrl: string; +} + +export interface IAssetThumbhash { + type: string; + data: number[]; +}