feat: potential albums feature

Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
Varun Raj
2024-08-19 23:43:45 +05:30
parent 6c8c1cc3fb
commit 21b053aac6
23 changed files with 974 additions and 27 deletions
BIN
View File
Binary file not shown.
+4 -1
View File
@@ -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",
@@ -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<void>;
}
export default function AlbumSelectorDialog({ onSelected }: IProps) {
const [open, setOpen] = useState(false);
const [albums, setAlbums] = useState<IAlbum[]>([]);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 <p>Loading...</p>;
if (errorMessage) return <p>{errorMessage}</p>;
return (
<div>
<Input
placeholder="Search Albums"
onChange={(e) => {
setSearch(e.target.value);
}}
/>
<div className="max-h-[500px] min-h-[500px] overflow-y-auto flex flex-col gap-2 py-2">
{filteredAlbums.map((album) => (
<div
key={album.id}
role="button"
onClick={handleSelect(album)}
className={
"flex gap-1 flex-col p-2 py-1 rounded-lg hover:dark:bg-zinc-800 hover:bg-zinc-100 px-4"
}
>
<p className="font-mono text-sm">{album.albumName}</p>
</div>
))}
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Select Album</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Select Albums</DialogTitle>
<DialogDescription>
Select the albums you want to add the selected assets to
</DialogDescription>
</DialogHeader>
{renderContent()}
</DialogContent>
</Dialog>
);
}
@@ -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<string | null>(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 (
<>
<Lightbox
slides={slides}
plugins={[Captions]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
/>
<div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]">
<Gallery
images={images}
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
/>
</div>
</>
);
}
@@ -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<string | null>(null);
const fetchData = async () => {
return listPotentialAlbumsDates({})
.then(setDateRecords)
.catch(setErrorMessage)
.finally(() => setLoading(false));
};
const handleSelect = (date: string) => {
updateContext({ startDate: date });
};
useEffect(() => {
fetchData();
}, []);
return (
<div className="overflow-y-auto min-w-[170px] py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
{dateRecords.map((record) => (
<PotentialDateItem
key={record.date}
record={record}
onSelect={handleSelect}
/>
))}
</div>
);
}
@@ -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 (
<div
role="button"
onClick={() => 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" : "")
}
>
<p className="font-mono text-sm">{record.date}</p>
<p className="text-xs">{record.asset_count} Orphan Assets</p>
</div>
);
}
+14 -7
View File
@@ -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}
<main className={
cn("flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 p-4 lg:gap-6 lg:p-6 overflow-y-auto", {
'mb-14': !!header,
})
}>
<main
className={cn(
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 p-4 lg:gap-6 lg:p-6 overflow-y-auto",
{
"mb-14": !!header,
},
className
)}
>
{childrenWithoutHeader}
</main>
</>
+4 -1
View File
@@ -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 (
<div className="grid max-h-screen min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
<div className="grid max-h-screen min-h-screen w-full md:grid-cols-[200px_1fr] lg:grid-cols-[240px_1fr]">
<Sidebar />
<div className="flex flex-col">
{children}
</div>
<Toaster />
</div>
)
}
-5
View File
@@ -51,11 +51,6 @@ export default function Sidebar() {
>
{nav.icon}
{nav.title}
{nav.badge && (
<Badge variant="outline" className="rounded-full text-nowrap">
{nav.badge}
</Badge>
)}
</Link>
))}
</nav>
+127
View File
@@ -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<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
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<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+33
View File
@@ -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 (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+192
View File
@@ -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<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
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<ToasterToast, "id">
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<State>(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 }
+5 -13
View File
@@ -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: <Image className="h-4 w-4" />,
},
{
title: "Statistics",
link: "/statistics",
icon: <Home className="h-4 w-4" />,
badge: "Comming Soon"
title: "Potential Albums",
link: "/albums/potential-albums",
icon: <GalleryVerticalEnd className="h-4 w-4" />,
},
{
title: "Albums",
link: "/albums",
icon: <Home className="h-4 w-4" />,
badge: "Comming Soon"
},
];
];
+8
View File
@@ -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";
+26
View File
@@ -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<IPotentialAlbumContext>) => void;
}
const PotentialAlbumContext = createContext<IPotentialAlbumContext>({
startDate: undefined,
selectedIds: [],
assets: [],
updateContext: () => { },
});
export default PotentialAlbumContext;
const usePotentialAlbumContext = () => {
return useContext(PotentialAlbumContext) as IPotentialAlbumContext;
}
export { usePotentialAlbumContext };
+29
View File
@@ -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<IPotentialAlbumsDatesResponse[]> => {
return API.get(LIST_POTENTIAL_ALBUMS_DATES_PATH, filters);
}
export const listPotentialAlbumsAssets = async (filters: IPotentialAlbumsDatesFilters): Promise<IAsset[]> => {
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 });
}
+13
View File
@@ -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),
}
}
+77
View File
@@ -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<IPotentialAlbumConfig>({
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 (
<PageLayout className="!p-0 !mb-0">
<Header
leftComponent="Potential Albums"
rightComponent={
<>
<Badge variant={"outline"}>
{config.selectedIds.length} Selected
</Badge>
<AlbumSelectorDialog onSelected={handleSelect} />
</>
}
/>
<PotentialAlbumContext.Provider
value={{
...config,
updateContext: (newConfig: Partial<IPotentialAlbumConfig>) =>
setConfig({ ...config, ...newConfig }),
}}
>
<div className="flex divide-y">
<PotentialAlbumsDates />
<PotentialAlbumsAssets />
</div>
</PotentialAlbumContext.Provider>
</PageLayout>
);
}
@@ -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,
});
}
}
@@ -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,
});
}
}
@@ -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' })
}
}
+28
View File
@@ -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;
}
+26
View File
@@ -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[];
}