diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6ef46e..519d827 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: ci on: push: branches: - - main + - release jobs: release: diff --git a/src/components/albums/potential-albums/PotentialAlbumsDates.tsx b/src/components/albums/potential-albums/PotentialAlbumsDates.tsx index 31c537e..5710ec2 100644 --- a/src/components/albums/potential-albums/PotentialAlbumsDates.tsx +++ b/src/components/albums/potential-albums/PotentialAlbumsDates.tsx @@ -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(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 ( -
+
+
+ +
+ +
+
+ {dateRecords.map((record) => ( void; +} + +export default function AssetOffsetDialog({ assets: _assets, open, toggleOpen }: IProps) { + const { toast } = useToast(); + const [assets, setAssets] = useState(_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>({}); + + const handleChange = (key: keyof typeof offsetData, value: number) => { + setOffsetData({ ...offsetData, [key]: value }); + } + + const handleSubmit = async (e: React.FormEvent) => { + 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 ( + + + + Offset Asset Dates + +
+
+ + handleChange("years", parseInt(e.target.value))} /> +
+
+ + handleChange("days", parseInt(e.target.value))} /> +
+
+ + handleChange("hours", parseInt(e.target.value))} /> +
+
+ + handleChange("minutes", parseInt(e.target.value))} /> +
+
+ + handleChange("seconds", parseInt(e.target.value))} /> +
+ +
+
+ {assets.map((asset) => ( +
+
+ {assetStatus[asset.id] === 'pending' ?
+

Offsetting...

+
: ( +
{formatDate(offsetDate(asset.dateTimeOriginal, offsetData), 'PPpp')}
+ )} + {asset.originalFileName} +
{formatDate(asset.dateTimeOriginal, 'PPpp')}
+
+
+ ))} +
+
+
+ ) +} diff --git a/src/components/assets/assets-options/AssetsOptions.tsx b/src/components/assets/assets-options/AssetsOptions.tsx new file mode 100644 index 0000000..cb65e53 --- /dev/null +++ b/src/components/assets/assets-options/AssetsOptions.tsx @@ -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 ( + <> + + + + + + setOpen(true)}> + Offset Asset Dates + + + + {open && } + + ) +} diff --git a/src/components/people/PeopleFilters.tsx b/src/components/people/PeopleFilters.tsx index 232ba81..d173565 100644 --- a/src/components/people/PeopleFilters.tsx +++ b/src/components/people/PeopleFilters.tsx @@ -27,7 +27,7 @@ export function PeopleFilters() { query: { ...router.query, ...data, - page: undefined, + page: data.page || undefined, }, }); } diff --git a/src/components/people/PeopleList.tsx b/src/components/people/PeopleList.tsx index 392fb0d..1e45db4 100644 --- a/src/components/people/PeopleList.tsx +++ b/src/components/people/PeopleList.tsx @@ -54,7 +54,7 @@ export default function PeopleList() { if (errorMessage) return
{errorMessage}
; return ( -
+
{people.map((person) => ( ))} diff --git a/src/components/people/PersonMergeDropdown.tsx b/src/components/people/PersonMergeDropdown.tsx index 92db3e9..28ab97c 100644 --- a/src/components/people/PersonMergeDropdown.tsx +++ b/src/components/people/PersonMergeDropdown.tsx @@ -103,47 +103,47 @@ export function PersonMergeDropdown({ setSelectedPeople(selectedPeople.filter((p) => p.id !== value.id)); return; } - if (selectedPeople.length >= 5) { - toast({ - title: "Error", - description: "You can only merge 5 people at a time", - }); - return; - } else { - setSelectedPeople([...selectedPeople, value]); - } + setSelectedPeople([...selectedPeople, value]); if (primaryPerson.name.length === 0 && value.name.length > 0) { setPrimaryPerson(value); } - }; + + const handleMerge = () => { if (selectedPeople.length === 0) { return; } + const personIds = selectedPeople.map((p) => p.id); setMerging(true); - return mergePerson(person.id, personIds) - .then(() => { - onRemove?.(person); - }) - .then(() => { + + const mergeInBatches = async (ids: string[], index: number = 0) => { + if (index >= ids.length) { setOpen(false); toast({ title: "Success", description: "People merged successfully", }); - }) - .catch(() => { + setMerging(false); + return; + } + + const batch = ids.slice(index, index + 5); + try { + await mergePerson(person.id, batch); + mergeInBatches(ids, index + 5); + } catch { toast({ title: "Error", description: "Failed to merge people", }); - }) - .finally(() => { setMerging(false); - }); + } + }; + + mergeInBatches(personIds); }; const handleRemove = (value: IPerson) => { diff --git a/src/handlers/api/album.handler.ts b/src/handlers/api/album.handler.ts index 7b35bb7..9097c0b 100644 --- a/src/handlers/api/album.handler.ts +++ b/src/handlers/api/album.handler.ts @@ -7,6 +7,8 @@ import { IAsset } from "@/types/asset"; interface IPotentialAlbumsDatesFilters { startDate?: string; endDate?: string; + sortBy?: string; + sortOrder?: string; } export interface IPotentialAlbumsDatesResponse { date: string; diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index 040d6e7..daebb2b 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -37,6 +37,7 @@ export interface IUpdateAssetsParams { ids: string[]; latitude?: number; longitude?: number; + dateTimeOriginal?: string; } export const updateAssets = async (params: IUpdateAssetsParams) => { diff --git a/src/helpers/date.helper.ts b/src/helpers/date.helper.ts index 2f72fc2..9cabed2 100644 --- a/src/helpers/date.helper.ts +++ b/src/helpers/date.helper.ts @@ -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() +} \ No newline at end of file diff --git a/src/pages/albums/potential-albums.tsx b/src/pages/albums/potential-albums.tsx index b7aa580..796cc5a 100644 --- a/src/pages/albums/potential-albums.tsx +++ b/src/pages/albums/potential-albums.tsx @@ -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 (
a.id)} /> + { }} /> } /> diff --git a/src/pages/api/albums/potential-albums-assets.ts b/src/pages/api/albums/potential-albums-assets.ts index 0cd9bfe..080fb8f 100644 --- a/src/pages/api/albums/potential-albums-assets.ts +++ b/src/pages/api/albums/potential-albums-assets.ts @@ -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 `); diff --git a/src/pages/api/albums/potential-albums-dates.ts b/src/pages/api/albums/potential-albums-dates.ts index cb2c751..1998ac7 100644 --- a/src/pages/api/albums/potential-albums-dates.ts +++ b/src/pages/api/albums/potential-albums-dates.ts @@ -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({ diff --git a/src/pages/api/assets/missing-location-assets.ts b/src/pages/api/assets/missing-location-assets.ts index 4847b53..d1e7ced 100644 --- a/src/pages/api/assets/missing-location-assets.ts +++ b/src/pages/api/assets/missing-location-assets.ts @@ -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)) diff --git a/src/pages/api/immich-proxy/[...path].ts b/src/pages/api/immich-proxy/[...path].ts index c7641f7..31f597b 100644 --- a/src/pages/api/immich-proxy/[...path].ts +++ b/src/pages/api/immich-proxy/[...path].ts @@ -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 }) } } \ No newline at end of file diff --git a/src/pages/assets/missing-locations.tsx b/src/pages/assets/missing-locations.tsx index c299609..6921532 100644 --- a/src/pages/assets/missing-locations.tsx +++ b/src/pages/assets/missing-locations.tsx @@ -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() { )} + { }} /> } /> diff --git a/src/types/asset.d.ts b/src/types/asset.d.ts index 25e1491..545dfc5 100644 --- a/src/types/asset.d.ts +++ b/src/types/asset.d.ts @@ -17,6 +17,7 @@ export interface IAsset { url: string; previewUrl: string; videoURL?: string; + dateTimeOriginal: string; } export interface IAssetThumbhash {