feat: added options to sort albums
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { humanizeNumber } from '@/helpers/string.helper';
|
||||
import { humanizeBytes, humanizeNumber, pluralize } from '@/helpers/string.helper';
|
||||
import LazyImage from '@/components/ui/lazy-image';
|
||||
import { ASSET_THUMBNAIL_PATH } from '@/config/routes';
|
||||
import { IAlbum } from '@/types/album';
|
||||
@@ -11,7 +11,7 @@ import { formatDate } from '@/helpers/date.helper';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { FaceIcon } from '@radix-ui/react-icons';
|
||||
import { Camera, Image, User } from 'lucide-react';
|
||||
import { Calendar, Camera, Image, User } from 'lucide-react';
|
||||
|
||||
interface IAlbumThumbnailProps {
|
||||
album: IAlbum;
|
||||
@@ -23,8 +23,8 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps
|
||||
}, [album.firstPhotoDate, album.lastPhotoDate]);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden shadow-lg relative">
|
||||
<label className="block relative">
|
||||
<div className="border rounded-lg overflow-hidden shadow-lg relative group">
|
||||
<label className="block relative ">
|
||||
<LazyImage
|
||||
src={ASSET_THUMBNAIL_PATH(album.albumThumbnailAssetId)}
|
||||
alt={album.albumName}
|
||||
@@ -35,7 +35,7 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-0 w-full bg-gray-800/70 text-white text-center text-xs font-bold py-1">
|
||||
<div className="absolute bottom-0 w-full bg-gray-800/70 text-white text-center text-xs font-bold py-1 group-hover:hidden">
|
||||
{formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy')} - {formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy')}
|
||||
</div>
|
||||
<Checkbox
|
||||
@@ -43,21 +43,26 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps
|
||||
className="absolute hidden top-2 left-2 w-6 h-6 rounded-full border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 absolute top-2 right-2 ">
|
||||
<div className="bg-gray-800 flex items-center gap-1 text-white text-xs font-bold py-1 px-2 rounded">
|
||||
{humanizeNumber(album.assetCount)} <Camera size={12} />
|
||||
<div className="items-center gap-2 absolute top-2 right-2 group-hover:hidden flex ">
|
||||
<div className="bg-gray-800 flex items-center gap-1 text-white text-xs py-1 px-2 rounded">
|
||||
|
||||
<Camera size={12} />
|
||||
{humanizeNumber(album.assetCount)}
|
||||
</div>
|
||||
<p className="bg-gray-800 flex items-center gap-1 text-white text-xs font-bold py-1 px-2 rounded">
|
||||
{album.faceCount} <User size={12} />
|
||||
<p className="bg-gray-800 flex items-center gap-1 text-white text-xs py-1 px-2 rounded">
|
||||
{numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`} <Calendar size={12} />
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<Link href={`/albums/${album.id}`}>
|
||||
<h2 className="text-md font-semibold truncate">{album.albumName}</h2>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-500">{
|
||||
numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`
|
||||
}</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-gray-700 dark:text-gray-500">{humanizeNumber(album.faceCount)} {
|
||||
pluralize(album.faceCount, 'person', 'people')
|
||||
} </p>
|
||||
<p className="text-xs text-gray-700 dark:text-gray-500">{humanizeBytes(parseInt(album.size))}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ export const sidebarNavs = [
|
||||
icon: <MapPinX className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Albums",
|
||||
title: "Manage Albums",
|
||||
link: "/albums",
|
||||
icon: <GalleryHorizontal className="h-4 w-4" />,
|
||||
},
|
||||
|
||||
@@ -23,8 +23,8 @@ export const listPotentialAlbumsAssets = async (filters: IPotentialAlbumsDatesFi
|
||||
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 listAlbums = async (filters?: { sortBy?: string, sortOrder?: string }) => {
|
||||
return API.get(LIST_ALBUMS_PATH, filters);
|
||||
}
|
||||
export const getAlbumInfo = async (id: string) => {
|
||||
return API.get(ALBUM_INFO_PATH(id));
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
export const humanizeNumber = (number: number) => {
|
||||
return number.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
export const pluralize = (number: number, word: string, pluralWord: string) => {
|
||||
return number === 1 ? word : pluralWord
|
||||
}
|
||||
|
||||
export const humanizeBytes = (bytes: number) => {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} bytes`
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
@@ -7,20 +7,42 @@ import { listAlbums } from '@/handlers/api/album.handler'
|
||||
import { IAlbum } from '@/types/album'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'
|
||||
import { SortAsc, SortDesc } from 'lucide-react'
|
||||
|
||||
const SORT_BY_OPTIONS = [
|
||||
{ value: 'lastPhotoDate', label: 'Last Photo Date' },
|
||||
{ value: 'firstPhotoDate', label: 'First Photo Date' },
|
||||
{ value: 'assetCount', label: 'Asset Count' },
|
||||
{ value: 'createdAt', label: 'Created At' },
|
||||
{ value: 'updatedAt', label: 'Updated At' },
|
||||
{ value: 'albumName', label: 'Album Name' },
|
||||
{ value: 'albumSize', label: 'Album Size' },
|
||||
{ value: 'faceCount', label: 'Number of People' },
|
||||
]
|
||||
export default function AlbumListPage() {
|
||||
const { exImmichUrl } = useConfig()
|
||||
const router = useRouter()
|
||||
const { query, pathname } = router
|
||||
const { sortBy, sortOrder } = query as { sortBy: string, sortOrder: string }
|
||||
const [albums, setAlbums] = useState<IAlbum[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [selectedAlbums, setSelectedAlbums] = useState<string[]>([])
|
||||
|
||||
const selectedSortBy = useMemo(() => SORT_BY_OPTIONS.find((option) => option.value === sortBy), [sortBy])
|
||||
|
||||
const fetchAlbums = async () => {
|
||||
setLoading(true)
|
||||
listAlbums()
|
||||
listAlbums({
|
||||
sortBy,
|
||||
sortOrder
|
||||
})
|
||||
.then(setAlbums)
|
||||
.catch((error) => {
|
||||
setErrorMessage(error.message)
|
||||
@@ -32,7 +54,7 @@ export default function AlbumListPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlbums()
|
||||
}, [])
|
||||
}, [sortBy, sortOrder])
|
||||
|
||||
const handleSelect = (checked: boolean, albumId: string) => {
|
||||
if (checked) {
|
||||
@@ -42,6 +64,7 @@ export default function AlbumListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const renderContent = () => {
|
||||
|
||||
if (loading) {
|
||||
@@ -62,6 +85,38 @@ export default function AlbumListPage() {
|
||||
<PageLayout className="!p-0 !mb-0">
|
||||
<Header
|
||||
leftComponent="Albums"
|
||||
rightComponent={
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
defaultValue={selectedSortBy?.value}
|
||||
onValueChange={(value) => router.push({
|
||||
pathname,
|
||||
query: {
|
||||
...query,
|
||||
sortBy: value,
|
||||
}
|
||||
|
||||
})}>
|
||||
<SelectTrigger className="px-2">
|
||||
<SelectValue placeholder="Sort by">{selectedSortBy?.label || 'Sort by'}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_BY_OPTIONS.map((option) => (
|
||||
<SelectItem value={option.value} key={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button className="px-2" variant={"secondary"} onClick={() => router.push({
|
||||
pathname,
|
||||
query: {
|
||||
...query,
|
||||
sortOrder: sortOrder === 'asc' ? 'desc' : 'asc',
|
||||
}
|
||||
})}>
|
||||
{sortOrder === 'asc' ? <SortAsc /> : <SortDesc />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{renderContent()}
|
||||
</PageLayout>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from "@/config/db";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { NextApiResponse } from "next";
|
||||
import { albums } from "@/schema/albums.schema";
|
||||
import { count, desc, eq, min, max, sql, and } from "drizzle-orm";
|
||||
import { count, desc, eq, min, max, sql, and, sum } from "drizzle-orm";
|
||||
import { assets } from "@/schema/assets.schema";
|
||||
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
|
||||
import { users } from "@/schema/users.schema";
|
||||
@@ -28,6 +28,15 @@ const sortAlbums = (albums: IAlbum[], sortBy: string, sortOrder: string) => {
|
||||
if (sortBy === 'assetCount') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.assetCount - b.assetCount : b.assetCount - a.assetCount);
|
||||
}
|
||||
if (sortBy === 'albumName') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.albumName.localeCompare(b.albumName) : b.albumName.localeCompare(a.albumName));
|
||||
}
|
||||
if (sortBy === 'albumSize') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? parseInt(a.size) - parseInt(b.size) : parseInt(b.size) - parseInt(a.size));
|
||||
}
|
||||
if (sortBy === 'faceCount') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.faceCount - b.faceCount : b.faceCount - a.faceCount);
|
||||
}
|
||||
return albums;
|
||||
}
|
||||
export default async function handler(
|
||||
@@ -49,6 +58,12 @@ export default async function handler(
|
||||
assetCount: count(assets.id),
|
||||
firstPhotoDate: min(exif.dateTimeOriginal),
|
||||
lastPhotoDate: max(exif.dateTimeOriginal),
|
||||
size: sum(exif.fileSizeInByte),
|
||||
order: albums.order,
|
||||
isActivityEnabled: albums.isActivityEnabled,
|
||||
ownerId: albums.ownerId,
|
||||
description: albums.description,
|
||||
lastModifiedAssetTimestamp: max(exif.dateTimeOriginal),
|
||||
faceCount: count(sql<string>`DISTINCT ${assetFaces.personId}`), // Ensure unique personId
|
||||
})
|
||||
.from(albums)
|
||||
|
||||
Vendored
+8
-7
@@ -6,13 +6,13 @@ export interface IAlbum {
|
||||
updatedAt: Date;
|
||||
id: string;
|
||||
ownerId: string;
|
||||
owner: IAlbumOwner;
|
||||
albumUsers: any[];
|
||||
shared: boolean;
|
||||
hasSharedLink: boolean;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
assets: any[];
|
||||
owner?: IAlbumOwner;
|
||||
albumUsers?: any[];
|
||||
shared?: boolean;
|
||||
hasSharedLink?: boolean;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
assets?: any[];
|
||||
assetCount: number;
|
||||
isActivityEnabled: boolean;
|
||||
order: string;
|
||||
@@ -20,6 +20,7 @@ export interface IAlbum {
|
||||
firstPhotoDate: Date;
|
||||
lastPhotoDate: Date;
|
||||
faceCount: number;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface IAlbumOwner {
|
||||
|
||||
Reference in New Issue
Block a user