feat: unified album creation and option to delete assets from potential albums

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj committed 2025-01-18 14:44:04 +05:30
1 parent 098701b828
commit 72dcb928ef
9 files changed
+197 -185

No files matched your search

@@ -1,86 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { Button } from "../ui/button";
import { listAlbums } from "@/handlers/api/album.handler";
import { IAlbum, IAlbumCreate } from "@/types/album";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { useToast } from "../ui/use-toast";
interface IProps {
onCreated?: (album: IAlbum) => Promise<void>;
onSubmit?: (data: IAlbumCreate) => Promise<any>;
assetIds?: string[];
}
export default function AlbumCreateDialog({ onSubmit, assetIds }: IProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
albumName: "",
});
const { toast } = useToast();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!onSubmit) return;
setLoading(true);
onSubmit(formData)
.then(() => {
toast({
title: "Album Created",
description: "Album created successfully",
});
setFormData({ albumName: "" });
setOpen(false);
})
.catch((e) => {
toast({
title: "Error",
description: "Failed to create album",
variant: "destructive",
});
})
.finally(() => {
setLoading(false);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size={"sm"}>Create Album</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Album</DialogTitle>
<DialogDescription>
Create and add photos to a new album
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<Label>Album Name</Label>
<Input
placeholder="Album Name"
onChange={(e) => {
setFormData({ ...formData, albumName: e.target.value });
}}
/>
</div>
<div className="self-end">
<Button disabled={loading}>
{loading ? "Creating Album" : "Create Album"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+73 -7
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
@@ -9,18 +9,28 @@ import {
} from "../ui/dialog";
import { Button } from "../ui/button";
import { listAlbums } from "@/handlers/api/album.handler";
import { IAlbum } from "@/types/album";
import { IAlbum, IAlbumCreate } from "@/types/album";
import { Input } from "../ui/input";
import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import toast from "react-hot-toast";
import { Label } from "../ui/label";
interface IProps {
onSelected: (album: IAlbum) => Promise<void>;
onCreated?: (album: IAlbum) => Promise<void>;
onSubmit?: (data: IAlbumCreate) => Promise<any>;
}
export default function AlbumSelectorDialog({ onSelected }: IProps) {
export default function AlbumSelectorDialog({ onSelected, onCreated, onSubmit }: 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 { selectedIds, assets } = usePotentialAlbumContext();
const [formData, setFormData] = useState({
albumName: "",
});
const fetchData = () => {
return listAlbums()
@@ -42,11 +52,32 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) {
});
}
const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log(formData);
if (!onSubmit) return;
setLoading(true);
onSubmit(formData)
.then(() => {
toast.success("Album created successfully");
setFormData({ albumName: "" });
setOpen(false);
})
.catch((e) => {
toast.error("Failed to create album");
})
.finally(() => {
setLoading(false);
});
}, [onSubmit, formData]);
useEffect(() => {
if (open && !albums.length) fetchData();
}, [open]);
const renderContent = () => {
const renderContent = useCallback(() => {
if (loading) return <p>Loading...</p>;
if (errorMessage) return <p>{errorMessage}</p>;
return (
@@ -73,12 +104,36 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) {
</div>
</div>
);
};
}, [albums, filteredAlbums, handleSelect, loading, errorMessage]);
const renderCreateContent = useCallback(() => {
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-h-[500px] min-h-[500px]">
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Create a new album and add the selected assets to it
</p>
<div className="flex flex-col gap-2">
<Label>Album Name</Label>
<Input
placeholder="Album Name"
onChange={(e) => {
setFormData({ ...formData, albumName: e.target.value });
}}
/>
</div>
<div className="self-end">
<Button disabled={loading} type="submit">
{loading ? "Creating Album" : "Create Album"}
</Button>
</div>
</form>
)
}, [loading, formData, handleSubmit]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size={"sm"}>Select Album</Button>
<Button size={"sm"} disabled={!selectedIds.length}>Add to Album</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
@@ -87,7 +142,18 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) {
Select the albums you want to add the selected assets to
</DialogDescription>
</DialogHeader>
{renderContent()}
<Tabs defaultValue="albums" className="w-full">
<TabsList className="w-full">
<TabsTrigger className="w-full" value="albums">Albums</TabsTrigger>
<TabsTrigger className="w-full" value="create">Create</TabsTrigger>
</TabsList>
<TabsContent value="albums">
{renderContent()}
</TabsContent>
<TabsContent value="create">
{renderCreateContent()}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { IAsset } from '@/types/asset';
import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog';
import { Dialog, DialogTitle, DialogHeader, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -11,12 +11,12 @@ import { useToast } from '@/components/ui/use-toast';
interface IProps {
assets: IAsset[];
open: boolean;
toggleOpen: (open: boolean) => void;
onComplete: () => void;
}
export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: IProps) {
export default function AssetOffsetDialog({ assets: _assets, onComplete }: IProps) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [assets, setAssets] = useState<IAsset[]>(_assets);
const [offsetData, setOffsetData] = useState<{ days: number, hours: number, minutes: number, seconds: number, years: number }>({
days: 0,
@@ -43,7 +43,7 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }:
})
.then(() => {
setLoading(false);
toggleOpen(false);
setOpen(false);
})
.catch((error) => {
setLoading(false);
@@ -52,7 +52,8 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }:
});
await Promise.all(promises);
setLoading(false);
toggleOpen(false);
setOpen(false);
onComplete();
toast({
title: 'Asset dates offset',
description: 'Asset dates have been offset',
@@ -65,12 +66,17 @@ export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }:
}, [_assets]);
return (
<Dialog open={open} onOpenChange={toggleOpen}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={"default"} size={"sm"} onClick={() => setOpen(true)}>
Offset Dates
</Button>
</DialogTrigger>
<DialogContent className='max-w-[800px]'>
<DialogHeader>
<DialogTitle>Offset Asset Dates</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className='flex items-center gap-2'>
<form onSubmit={handleSubmit} className='flex items-end gap-2'>
<div>
<Label>Years</Label>
<Input type="number" value={offsetData.years} onChange={(e) => handleChange("years", parseInt(e.target.value))} />
@@ -1,31 +0,0 @@
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { IAsset } from '@/types/asset';
import { DotsVerticalIcon, HamburgerMenuIcon } from '@radix-ui/react-icons';
import { Clock, PlusIcon } from 'lucide-react'
import React, { useState } from 'react'
import AssetOffsetDialog from './AssetOffsetDialog';
interface IProps {
onAdd?: () => void;
assets: IAsset[];
}
export default function AssetsOptions({ onAdd, assets }: IProps) {
const [open, setOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild className='cursor-pointer'>
<HamburgerMenuIcon className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className='flex items-center gap-2' onSelect={() => setOpen(true)}>
<Clock className="w-4 h-4" /> Offset Asset Dates
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{open && <AssetOffsetDialog assets={assets} open={open} toggleOpen={setOpen} />}
</>
)
}
@@ -1,22 +1,15 @@
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, MouseEvent } from "react";
import { Gallery } from "react-grid-gallery";
import Lightbox from "yet-another-react-lightbox";
import Captions from "yet-another-react-lightbox/plugins/captions";
import {
ArrowUpRight,
CalendarArrowDown,
CalendarArrowUp,
Hourglass,
Link,
} from "lucide-react";
import { useMissingLocationContext } from "@/contexts/MissingLocationContext";
import { listMissingLocationAssets } from "@/handlers/api/asset.handler";
import { formatDate, parseDate } from "@/helpers/date.helper";
import { addDays } from "date-fns";
import { useConfig } from "@/contexts/ConfigContext";
import LazyGridImage from "@/components/ui/lazy-grid-image";
+1 -1
View File
@@ -8,7 +8,7 @@ interface FloatingBarProps {
export default function FloatingBar({ children }: FloatingBarProps) {
return (
<div className='absolute bottom-10 left-0 right-0'>
<div className='max-w-xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border'>
<div className='max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border'>
{children}
</div>
</div>
+88 -41
View File
@@ -1,10 +1,11 @@
import AlbumCreateDialog from "@/components/albums/AlbumCreateDialog";
import AlbumSelectorDialog from "@/components/albums/AlbumSelectorDialog";
import PotentialAlbumsAssets from "@/components/albums/potential-albums/PotentialAlbumsAssets";
import PotentialAlbumsDates from "@/components/albums/potential-albums/PotentialAlbumsDates";
import AssetsOptions from "@/components/assets/assets-options/AssetsOptions";
import AssetOffsetDialog from "@/components/assets/assets-options/AssetOffsetDialog";
import PageLayout from "@/components/layouts/PageLayout";
import FloatingBar from "@/components/shared/FloatingBar";
import Header from "@/components/shared/Header";
import { AlertDialog } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
@@ -13,6 +14,7 @@ import PotentialAlbumContext, {
IPotentialAlbumConfig,
} from "@/contexts/PotentialAlbumContext";
import { addAssetToAlbum, createAlbum } from "@/handlers/api/album.handler";
import { deleteAssets } from "@/handlers/api/asset.handler";
import { IAlbum, IAlbumCreate } from "@/types/album";
import { useRouter } from "next/router";
import React, { useMemo } from "react";
@@ -29,7 +31,7 @@ export default function PotentialAlbums() {
assets: [],
});
const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]) ;
const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]);
const handleSelect = (album: IAlbum) => {
return addAssetToAlbum(album.id, config.selectedIds)
@@ -66,50 +68,46 @@ export default function PotentialAlbums() {
role: "editor",
userId: id,
}]
}).then(() => {
toast({
title: "Album created",
description: "Album created successfully",
});
setConfig({ ...config, selectedIds: [], assets: config.assets.filter(a => !config.selectedIds.includes(a.id)) });
})
}
const handleDelete = () => {
return deleteAssets(config.selectedIds)
.then(() => {
setConfig({ ...config, selectedIds: [], assets: config.assets.filter(a => !config.selectedIds.includes(a.id)) });
})
.then(() => {
toast({
title: "Assets deleted",
description: "Assets deleted successfully",
});
})
.catch(() => {
toast({
title: "Error",
description: "Failed to delete assets",
variant: "destructive",
});
});
}
const handleOffsetComplete = () => {
setConfig({
...config,
selectedIds: [],
});
}
return (
<PageLayout className="!p-0 !mb-0">
<PageLayout className="!p-0 !mb-0 relative">
<Header
leftComponent="Potential Albums"
rightComponent={
<div className="flex gap-2 items-center">
<Badge variant={"outline"}>
{config.selectedIds.length} Selected
</Badge>
{config.selectedIds.length && config.selectedIds.length === config.assets.length ? (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
setConfig({
...config,
selectedIds: [],
})
}
>
Unselect all
</Button>
) : (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
setConfig({
...config,
selectedIds: config.assets.map((a) => a.id),
})
}
>
Select all
</Button>
)}
<AlbumSelectorDialog onSelected={handleSelect} />
<AlbumCreateDialog onSubmit={handleCreate} assetIds={config.assets.map((a) => a.id)} />
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
</div>
}
/>
<PotentialAlbumContext.Provider
value={{
@@ -122,6 +120,55 @@ export default function PotentialAlbums() {
<PotentialAlbumsDates />
<PotentialAlbumsAssets />
</div>
<FloatingBar>
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-sm text-muted-foreground">
{config.selectedIds.length} Selected
</p>
<div className="flex items-center gap-2">
{config.selectedIds.length && config.selectedIds.length === config.assets.length ? (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
setConfig({
...config,
selectedIds: [],
})
}
>
Unselect all
</Button>
) : (
<Button
variant={"outline"}
size={"sm"}
onClick={() =>
setConfig({
...config,
selectedIds: config.assets.map((a) => a.id),
})
}
>
Select all
</Button>
)}
<AlbumSelectorDialog onSelected={handleSelect} onSubmit={handleCreate}/>
<AssetOffsetDialog assets={selectedAssets} onComplete={handleOffsetComplete} />
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-800"></div>
<AlertDialog
title="Delete the selected assets?"
description="This action will delete the selected assets and cannot be undone."
onConfirm={handleDelete}>
<Button variant={"destructive"} size={"sm"} >
Delete
</Button>
</AlertDialog>
</div>
</div>
</FloatingBar>
</PotentialAlbumContext.Provider>
</PageLayout>
);
@@ -2,6 +2,7 @@
import { CHART_COLORS } from "@/config/constants/chart.constant";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { isFlipped } from "@/helpers/asset.helper";
import { exif } from "@/schema";
import { count, desc, isNotNull, ne, sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -23,7 +24,8 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
a."deletedAt",
e."exifImageWidth",
e."exifImageHeight",
e."dateTimeOriginal"
e."dateTimeOriginal",
e."orientation"
FROM
assets a
LEFT JOIN
@@ -47,7 +49,15 @@ export default async function handler(
const currentUser = await getCurrentUser(req)
const { startDate } = req.query as { startDate: string };
const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(startDate, currentUser.id));
return res.status(200).json(rows);
const cleanedRows = rows.map((row: any) => {
return {
...row,
exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth,
exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight,
};
});
return res.status(200).json(cleanedRows);
} catch (error: any) {
res.status(500).json({
error: error?.message,
+9 -2
View File
@@ -1,4 +1,3 @@
import AssetsOptions from "@/components/assets/assets-options/AssetsOptions";
import MissingLocationAssets from "@/components/assets/missing-location/MissingLocationAssets";
import MissingLocationDates from "@/components/assets/missing-location/MissingLocationDates";
import TagMissingLocationDialog from "@/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog";
@@ -18,6 +17,7 @@ import React, { useMemo } from "react";
import FloatingBar from "@/components/shared/FloatingBar";
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from "@/components/ui/select";
import { AlertDialog } from "@/components/ui/alert-dialog";
import AssetOffsetDialog from "@/components/assets/assets-options/AssetOffsetDialog";
export default function MissingLocations() {
const { query, push } = useRouter();
@@ -53,6 +53,13 @@ export default function MissingLocations() {
})
}
const handleOffsetComplete = () => {
setConfig({
...config,
selectedIds: [],
});
}
return (
<PageLayout className="!p-0 !mb-0 relative">
<Header
@@ -78,7 +85,7 @@ export default function MissingLocations() {
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
<AssetOffsetDialog assets={selectedAssets} onComplete={handleOffsetComplete} />
</div>
}
/>