feat: added option to offset bulk photos

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2024-11-22 10:06:59 +05:30
parent fbb74399d6
commit ac8db26b8b
14 changed files with 263 additions and 19 deletions
@@ -6,6 +6,9 @@ import React, { use, useEffect, useState } from "react";
import PotentialDateItem from "./PotentialDateItem";
import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext";
import { useRouter } from "next/router";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ArrowDown, ArrowUp, ArrowUpDownIcon, SortAsc, SortDesc } from "lucide-react";
export default function PotentialAlbumsDates() {
const router = useRouter();
@@ -13,13 +16,17 @@ export default function PotentialAlbumsDates() {
const [dateRecords, setDateRecords] = React.useState<
IPotentialAlbumsDatesResponse[]
>([]);
const [filters, setFilters] = useState<{ sortBy: string, sortOrder: string }>({ sortBy: "date", sortOrder: "desc" });
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fetchData = async () => {
return listPotentialAlbumsDates({})
return listPotentialAlbumsDates({
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
})
.then(setDateRecords)
.catch(setErrorMessage)
.finally(() => setLoading(false));
@@ -31,15 +38,38 @@ export default function PotentialAlbumsDates() {
pathname: router.pathname,
query: { startDate: date },
})
};
useEffect(() => {
fetchData();
}, []);
}, [filters]);
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">
<div className="overflow-y-auto min-w-[200px] 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">
<div className="flex justify-between items-center gap-2">
<Select
defaultValue={filters.sortBy}
onValueChange={(value) => setFilters({ ...filters, sortBy: value })}
>
<SelectTrigger className="!p-2">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent defaultValue={filters.sortBy}>
<SelectGroup title="Sort by">
<SelectLabel>Sort by</SelectLabel>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="asset_count">Asset Count</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div>
<Button variant="default" size="sm" onClick={() => setFilters({ ...filters, sortOrder: filters.sortOrder === "asc" ? "desc" : "asc" })}>
{filters.sortOrder === "asc" ? <SortAsc size={16} /> : <SortDesc size={16} />}
</Button>
</div>
</div>
{dateRecords.map((record) => (
<PotentialDateItem
key={record.date}
@@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react'
import { IAsset } from '@/types/asset';
import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { formatDate, offsetDate } from '@/helpers/date.helper';
import LazyImage from '@/components/ui/lazy-image';
import Image from 'next/image';
import { updateAssets } from '@/handlers/api/asset.handler';
import { useToast } from '@/components/ui/use-toast';
interface IProps {
assets: IAsset[];
open: boolean;
toggleOpen: (open: boolean) => void;
}
export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: IProps) {
const { toast } = useToast();
const [assets, setAssets] = useState<IAsset[]>(_assets);
const [offsetData, setOffsetData] = useState<{ days: number, hours: number, minutes: number, seconds: number, years: number }>({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
years: 0
});
const [loading, setLoading] = useState(false);
const [assetStatus, setAssetStatus] = useState<Record<string, string>>({});
const handleChange = (key: keyof typeof offsetData, value: number) => {
setOffsetData({ ...offsetData, [key]: value });
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log(offsetData);
setLoading(true);
const promises = assets.map(async (asset) => {
setAssetStatus({ [asset.id]: 'pending' });
await updateAssets({
ids: [asset.id],
dateTimeOriginal: offsetDate(asset.dateTimeOriginal, offsetData)
})
.then(() => {
setLoading(false);
toggleOpen(false);
})
.catch((error) => {
setLoading(false);
setAssetStatus({ [asset.id]: 'error' });
})
});
await Promise.all(promises);
setLoading(false);
toggleOpen(false);
toast({
title: 'Asset dates offset',
description: 'Asset dates have been offset',
})
}
useEffect(() => {
setAssets(_assets);
}, [_assets]);
return (
<Dialog open={open} onOpenChange={toggleOpen}>
<DialogContent className='max-w-[800px]'>
<DialogHeader>
<DialogTitle>Offset Asset Dates</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className='flex items-center gap-2'>
<div>
<Label>Years</Label>
<Input type="number" value={offsetData.years} onChange={(e) => handleChange("years", parseInt(e.target.value))} />
</div>
<div>
<Label>Days</Label>
<Input type="number" value={offsetData.days} onChange={(e) => handleChange("days", parseInt(e.target.value))} />
</div>
<div>
<Label>Hours</Label>
<Input type="number" value={offsetData.hours} onChange={(e) => handleChange("hours", parseInt(e.target.value))} />
</div>
<div>
<Label>Minutes</Label>
<Input type="number" value={offsetData.minutes} onChange={(e) => handleChange("minutes", parseInt(e.target.value))} />
</div>
<div>
<Label>Seconds</Label>
<Input type="number" value={offsetData.seconds} onChange={(e) => handleChange("seconds", parseInt(e.target.value))} />
</div>
<Button disabled={loading} type="submit">Offset Dates</Button>
</form>
<div className='grid grid-cols-3 gap-4 py-2 overflow-y-auto max-h-[500px]'>
{assets.map((asset) => (
<div key={asset.id} >
<div className='relative w-full h-full rounded-md border overflow-hidden' >
{assetStatus[asset.id] === 'pending' ? <div className='absolute text-xs text-center bottom-0 left-0 right-0 bg-blue-500/50 text-white p-2'>
<p className='text-white'>Offsetting...</p>
</div> : (
<div className='absolute text-xs text-center bottom-0 left-0 right-0 bg-green-500/50 text-white p-2'>{formatDate(offsetDate(asset.dateTimeOriginal, offsetData), 'PPpp')} </div>
)}
<Image
src={asset.previewUrl}
alt={asset.originalFileName}
width={300}
height={300}
objectPosition='cover'
style={{
objectFit: 'cover',
overflow: 'hidden',
height: '100%',
width: '100%'
}}
/>
<div className='absolute text-xs text-center top-0 left-0 right-0 bg-red-500/50 text-white p-2'>{formatDate(asset.dateTimeOriginal, 'PPpp')}</div>
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,31 @@
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 -1
View File
@@ -54,7 +54,7 @@ export default function PeopleList() {
if (errorMessage) return <div>{errorMessage}</div>;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9 gap-1 p-2">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-1 p-2">
{people.map((person) => (
<PersonItem person={person} key={person.id} onRemove={handleRemove}/>
))}
+2
View File
@@ -7,6 +7,8 @@ import { IAsset } from "@/types/asset";
interface IPotentialAlbumsDatesFilters {
startDate?: string;
endDate?: string;
sortBy?: string;
sortOrder?: string;
}
export interface IPotentialAlbumsDatesResponse {
date: string;
+1
View File
@@ -37,6 +37,7 @@ export interface IUpdateAssetsParams {
ids: string[];
latitude?: number;
longitude?: number;
dateTimeOriginal?: string;
}
export const updateAssets = async (params: IUpdateAssetsParams) => {
+19 -1
View File
@@ -1,4 +1,4 @@
import { format, parse } from "date-fns"
import { addSeconds, addHours, addMinutes, format, parse, addYears } from "date-fns"
export const formatDate = (date: string, outputFormat?: string): string => {
return format(date, outputFormat || "PPP")
@@ -13,3 +13,21 @@ export const addDays = (date: Date, days: number): Date => {
result.setDate(result.getDate() + days);
return result;
}
export const offsetDate = (date: string, offset: {
years: number,
days: number,
hours: number,
minutes: number,
seconds: number
}): string => {
console.log(offset)
const parsedDate = new Date(date);
const result = addYears(parsedDate, offset.years || 0)
const result2 = addDays(result, offset.days || 0)
const result3 = addHours(result2, offset.hours || 0)
const result4 = addMinutes(result3, offset.minutes || 0)
const result5 = addSeconds(result4, offset.seconds || 0)
return result5.toISOString()
}
+6 -2
View File
@@ -2,6 +2,7 @@ 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 PageLayout from "@/components/layouts/PageLayout";
import Header from "@/components/shared/Header";
import { Badge } from "@/components/ui/badge";
@@ -14,7 +15,7 @@ import PotentialAlbumContext, {
import { addAssetToAlbum, createAlbum } from "@/handlers/api/album.handler";
import { IAlbum, IAlbumCreate } from "@/types/album";
import { useRouter } from "next/router";
import React from "react";
import React, { useMemo } from "react";
export default function PotentialAlbums() {
const { toast } = useToast();
@@ -28,6 +29,8 @@ export default function PotentialAlbums() {
assets: [],
});
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)
.then(() => {
@@ -65,7 +68,7 @@ export default function PotentialAlbums() {
}]
})
}
return (
<PageLayout className="!p-0 !mb-0">
<Header
@@ -102,6 +105,7 @@ export default function PotentialAlbums() {
)}
<AlbumSelectorDialog onSelected={handleSelect} />
<AlbumCreateDialog onSubmit={handleCreate} assetIds={config.assets.map((a) => a.id)} />
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
</>
}
/>
@@ -21,9 +21,9 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
a."sidecarPath",
a."thumbhash",
a."deletedAt",
a."localDateTime",
e."exifImageWidth",
e."exifImageHeight"
e."exifImageHeight",
e."dateTimeOriginal"
FROM
assets a
LEFT JOIN
@@ -35,7 +35,7 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
WHERE
aaa."albumsId" IS NULL
AND a."ownerId" = '${ownerId}'
AND a."localDateTime"::date = '${date}'
AND e."dateTimeOriginal"::date = '${date}'
AND a."isVisible" = true
`);
+22 -3
View File
@@ -1,25 +1,30 @@
// 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 { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { parseDate } from "@/helpers/date.helper";
import { exif } from "@/schema";
import { count, desc, isNotNull, ne, sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
const SELECT_ORPHAN_PHOTOS = (ownerId: string) => sql`
SELECT
DATE(a."localDateTime") AS "date",
DATE(e."dateTimeOriginal") AS "date",
COUNT(a."id") AS "asset_count"
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."ownerId" = ${ownerId}
AND a."isVisible" = true
AND e."dateTimeOriginal" IS NOT NULL
GROUP BY
DATE(a."localDateTime")
DATE(e."dateTimeOriginal")
ORDER BY
"asset_count" DESC;
`;
@@ -29,8 +34,22 @@ export default async function handler(
res: NextApiResponse
) {
try {
const { sortBy = "date", sortOrder = "desc" } = req.query;
const currentUser = await getCurrentUser(req)
const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(currentUser.id));
const data = await db.execute(SELECT_ORPHAN_PHOTOS(currentUser.id)) as any;
const rows = data.rows as IPotentialAlbumsDatesResponse[];
if (sortBy === "date") {
rows.sort((a, b) => {
const aDate = parseDate(a.date, "yyyy-MM-dd");
const bDate = parseDate(b.date, "yyyy-MM-dd");
return sortOrder === "asc" ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime();
});
} else if (sortBy === "asset_count") {
rows.sort((a, b) => {
return sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count;
});
}
return res.status(200).json(rows);
} catch (error: any) {
res.status(500).json({
@@ -43,6 +43,7 @@ export default async function handler(
exifImageWidth: exif.exifImageWidth,
exifImageHeight: exif.exifImageHeight,
ownerId: assets.ownerId,
dateTimeOriginal: exif.dateTimeOriginal,
})
.from(assets)
.leftJoin(exif, eq(exif.assetId, assets.id))
+9 -4
View File
@@ -14,10 +14,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try {
const response = await fetch(targetUrl, {
method: req.method,
headers: getUserHeaders(currentUser),
headers: {
...getUserHeaders(currentUser),
'Accept-Encoding': 'gzip, deflate, br',
},
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : null,
})
// Log response headers for debugging
console.log('Response Headers:', response.headers);
// Forward the status code
res.status(response.status)
@@ -28,7 +33,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.setHeader(key, value)
})
// Stream the response body
// Stream the response body
const reader = response.body?.getReader()
if (reader) {
while (true) {
@@ -38,8 +43,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
}
res.end()
} catch (error) {
} catch (error: any) {
console.error('Proxy error:', error)
res.status(500).json({ error })
res.status(500).json({ error: error?.message })
}
}
+5 -1
View File
@@ -1,6 +1,7 @@
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 MissingLocationAssets from "@/components/assets/missing-location/MissingLocationAssets";
import MissingLocationDates from "@/components/assets/missing-location/MissingLocationDates";
import TagMissingLocationDialog from "@/components/assets/missing-location/TagMissingLocationDialog/TagMissingLocationDialog";
@@ -17,7 +18,7 @@ import { updateAssets } from "@/handlers/api/asset.handler";
import { IPlace } from "@/types/common";
import { useRouter } from "next/router";
import React from "react";
import React, { useMemo } from "react";
export default function MissingLocations() {
const { toast } = useToast();
@@ -30,6 +31,8 @@ export default function MissingLocations() {
assets: [],
});
const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]) ;
const handleSubmit = (place: IPlace) => {
return updateAssets({
ids: config.selectedIds,
@@ -78,6 +81,7 @@ export default function MissingLocations() {
</Button>
)}
<TagMissingLocationDialog onSubmit={handleSubmit} />
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
</>
}
/>
+1
View File
@@ -17,6 +17,7 @@ export interface IAsset {
url: string;
previewUrl: string;
videoURL?: string;
dateTimeOriginal: string;
}
export interface IAssetThumbhash {