diff --git a/bun.lockb b/bun.lockb index c453f14..b0d7235 100755 Binary files a/bun.lockb and b/bun.lockb differ 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[]; +}