diff --git a/package.json b/package.json index db492a40..9816e848 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "camelcase-keys": "^9.1.3", "cmdk": "^1.0.4", "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "exifr": "^7.1.3", "framer-motion": "^11.17.0", "nanoid": "^5.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81025d86..3042825e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + date-fns-tz: + specifier: ^3.2.0 + version: 3.2.0(date-fns@4.1.0) exifr: specifier: ^7.1.3 version: 7.1.3 @@ -2177,6 +2180,11 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -7037,6 +7045,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-tz@3.2.0(date-fns@4.1.0): + dependencies: + date-fns: 4.1.0 + date-fns@4.1.0: {} debounce@1.2.1: {} diff --git a/src/admin/AdminPhotosClient.tsx b/src/admin/AdminPhotosClient.tsx index 8c82d61c..1da58678 100644 --- a/src/admin/AdminPhotosClient.tsx +++ b/src/admin/AdminPhotosClient.tsx @@ -13,6 +13,7 @@ import { StorageListResponse } from '@/services/storage'; import { useState } from 'react'; import { LiaBroomSolid } from 'react-icons/lia'; import AdminUploadsTable from './AdminUploadsTable'; +import { Timezone } from '@/utility/timezone'; export default function AdminPhotosClient({ photos, @@ -22,6 +23,7 @@ export default function AdminPhotosClient({ blobPhotoUrls, infiniteScrollInitial, infiniteScrollMultiple, + timezone, }: { photos: Photo[] photosCount: number @@ -30,6 +32,7 @@ export default function AdminPhotosClient({ blobPhotoUrls: StorageListResponse infiniteScrollInitial: number infiniteScrollMultiple: number + timezone: Timezone }) { const [isUploading, setIsUploading] = useState(false); @@ -74,12 +77,14 @@ export default function AdminPhotosClient({ {photosCount > photos.length && } } diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index ee1ec308..03002e7e 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -14,6 +14,7 @@ import { useAppState } from '@/state/AppState'; import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll'; import PhotoSyncButton from './PhotoSyncButton'; import DeletePhotoButton from './DeletePhotoButton'; +import { Timezone } from '@/utility/timezone'; export default function AdminPhotosTable({ photos, @@ -24,6 +25,7 @@ export default function AdminPhotosTable({ showUpdatedAt, canEdit = true, canDelete = true, + timezone, }: { photos: Photo[], onLastPhotoVisible?: () => void @@ -33,6 +35,7 @@ export default function AdminPhotosTable({ showUpdatedAt?: boolean canEdit?: boolean canDelete?: boolean + timezone?: Timezone }) { const { invalidateSwr } = useAppState(); @@ -90,6 +93,7 @@ export default function AdminPhotosTable({ diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index 4ae24299..d8ef3d11 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -4,6 +4,8 @@ import { getPhotosMetaCached } from '@/photo/cache'; import { OUTDATED_THRESHOLD } from '@/photo'; import AdminPhotosClient from '@/admin/AdminPhotosClient'; import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; +import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone'; export const maxDuration = 60; @@ -13,6 +15,8 @@ const INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS = 25; const INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS = 50; export default async function AdminPhotosPage() { + const timezone = (await cookies()).get(TIMEZONE_COOKIE_NAME)?.value; + const [ photos, photosCount, @@ -51,6 +55,7 @@ export default async function AdminPhotosPage() { blobPhotoUrls, infiniteScrollInitial: INFINITE_SCROLL_INITIAL_ADMIN_PHOTOS, infiniteScrollMultiple: INFINITE_SCROLL_MULTIPLE_ADMIN_PHOTOS, + timezone, }} /> ); } diff --git a/src/components/ResponsiveDate.tsx b/src/components/ResponsiveDate.tsx index 433ae5bb..e593a390 100644 --- a/src/components/ResponsiveDate.tsx +++ b/src/components/ResponsiveDate.tsx @@ -1,40 +1,68 @@ +'use client'; + import { formatDate } from '@/utility/date'; +import { Timezone } from '@/utility/timezone'; import { clsx } from 'clsx/lite'; +import { useEffect, useState } from 'react'; export default function ResponsiveDate({ date, className, titleLabel, + timezone: timezoneFromProps, }: { date: Date className?: string titleLabel?: string + timezone?: Timezone }) { + const [timezone, setTimezone] = useState(timezoneFromProps); + + useEffect(() => { + if (!timezoneFromProps) { + setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); + } + }, [timezoneFromProps]); + + const showPlaceholderContent = timezone === undefined; + + const titleDateFormatted = formatDate(date, undefined, timezone) + .toLocaleUpperCase(); + const title = titleLabel - ? `${titleLabel}: ${formatDate(date).toLocaleUpperCase()}` - : formatDate(date).toLocaleUpperCase(); + ? `${titleLabel}: ${titleDateFormatted}` + : titleDateFormatted; + + const contentClass = showPlaceholderContent && 'opacity-0 select-none'; + return ( {/* Small */} - {formatDate(date, 'short')} + {formatDate(date, 'short', timezone, showPlaceholderContent)} {/* Medium */} - {formatDate(date, 'medium')} + {formatDate(date, 'medium', timezone,showPlaceholderContent)} {/* Large */} - - {formatDate(date)} + + {formatDate(date, undefined, timezone, showPlaceholderContent)} ); diff --git a/src/components/cmdk/CommandKClient.tsx b/src/components/cmdk/CommandKClient.tsx index e73ac731..bd598885 100644 --- a/src/components/cmdk/CommandKClient.tsx +++ b/src/components/cmdk/CommandKClient.tsx @@ -166,7 +166,7 @@ export default function CommandKClient({ items: photos.map(photo => ({ label: titleForPhoto(photo), keywords: getKeywordsForPhoto(photo), - annotation: , + annotation: , accessory: , path: pathForPhoto({ photo }), })), diff --git a/src/photo/PhotoDate.tsx b/src/photo/PhotoDate.tsx index a8c3efb4..96449f0f 100644 --- a/src/photo/PhotoDate.tsx +++ b/src/photo/PhotoDate.tsx @@ -1,15 +1,18 @@ import ResponsiveDate from '@/components/ResponsiveDate'; import { Photo } from '.'; import { useMemo } from 'react'; +import { Timezone } from '@/utility/timezone'; export default function PhotoDate({ photo, className, dateType = 'takenAt', + timezone, }: { photo: Photo className?: string dateType?: 'takenAt' | 'createdAt' | 'updatedAt' + timezone: Timezone }) { const date = useMemo(() => { const date = new Date(dateType === 'takenAt' @@ -41,6 +44,7 @@ export default function PhotoDate({ date, className, titleLabel: getTitleLabel(), + timezone, }} /> ); } diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 668298f4..32befc1b 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -247,6 +247,9 @@ export default function PhotoLarge({ // Prevent collision with admin button !hasNonDateContent && isUserSignedIn && 'md:pr-7', )} + // Created at is a naive datetime which + // does not require a timezone + timezone={null} />
{ setHasLoaded?.(true); + storeTimezoneCookie(); }, []); return ( diff --git a/src/utility/cookie.ts b/src/utility/cookie.ts new file mode 100644 index 00000000..c69c64de --- /dev/null +++ b/src/utility/cookie.ts @@ -0,0 +1,21 @@ +export const storeCookie = ( + name: string, + value: string, + path= '/', + maxAge = 63158400, +) => { + document.cookie = `${name}=${value};Path=${path};Max-Age=${maxAge}`; +}; + +export const getCookie = (name: string) => { + const cookie: Record = {}; + document.cookie.split(';').forEach(function(el) { + const split = el.split('='); + cookie[split[0].trim()] = split.slice(1).join('='); + }); + return cookie[name]; +}; + +export const deleteCookie = (name: string) => { + document.cookie = `${name}=;Max-Age=0`; +}; diff --git a/src/utility/date.ts b/src/utility/date.ts index fdbe6454..30662c76 100644 --- a/src/utility/date.ts +++ b/src/utility/date.ts @@ -1,25 +1,52 @@ -import { format, parseISO, parse } from 'date-fns'; +import { parseISO, parse, format } from 'date-fns'; +import { formatInTimeZone } from 'date-fns-tz'; +import { Timezone } from './timezone'; -const DATE_STRING_FORMAT_TINY = 'dd MMM yy'; -const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy'; -const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma'; -const DATE_STRING_FORMAT = 'dd MMM yyyy h:mma'; -const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss'; +const DATE_STRING_FORMAT_TINY = 'dd MMM yy'; +const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00'; + +const DATE_STRING_FORMAT_SHORT = 'dd MMM yyyy'; +const DATE_STRING_FORMAT_SHORT_PLACEHOLDER = '00 000 0000'; + +const DATE_STRING_FORMAT_MEDIUM = 'dd MMM yy h:mma'; +const DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER = '00 000 00 00:0000'; + +const DATE_STRING_FORMAT_LONG = 'dd MMM yyyy h:mma'; +const DATE_STRING_FORMAT_LONG_PLACEHOLDER = '00 000 0000 00:0000'; + +const DATE_STRING_FORMAT_POSTGRES = 'yyyy-MM-dd HH:mm:ss'; type AmbiguousTimestamp = number | string; type Length = 'tiny' | 'short' | 'medium' | 'long'; -export const formatDate = (date: Date, length: Length = 'long') => { +export const formatDate = ( + date: Date, + length: Length = 'long', + timezone?: Timezone, + showPlaceholder?: boolean, +) => { switch (length) { - case 'tiny': - return format(date, DATE_STRING_FORMAT_TINY); - case 'short': - return format(date, DATE_STRING_FORMAT_SHORT); - case 'medium': - return format(date, DATE_STRING_FORMAT_MEDIUM); - default: - return format(date, DATE_STRING_FORMAT); + case 'tiny': return showPlaceholder + ? DATE_STRING_FORMAT_TINY_PLACEHOLDER + : timezone + ? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_TINY) + : format(date, DATE_STRING_FORMAT_TINY); + case 'short': return showPlaceholder + ? DATE_STRING_FORMAT_SHORT_PLACEHOLDER + : timezone + ? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_SHORT) + : format(date, DATE_STRING_FORMAT_SHORT); + case 'medium': return showPlaceholder + ? DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER + : timezone + ? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_MEDIUM) + : format(date, DATE_STRING_FORMAT_MEDIUM); + default: return showPlaceholder + ? DATE_STRING_FORMAT_LONG_PLACEHOLDER + : timezone + ? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_LONG) + : format(date, DATE_STRING_FORMAT_LONG); } }; diff --git a/src/utility/timezone.ts b/src/utility/timezone.ts new file mode 100644 index 00000000..ba2b689b --- /dev/null +++ b/src/utility/timezone.ts @@ -0,0 +1,18 @@ +import { getCookie, storeCookie } from './cookie'; + +// Timezone +// string: timezone +// undefined: timezone must be resolved on the client +// null: timezone not required +export type Timezone = string | undefined | null; + +export const TIMEZONE_COOKIE_NAME = 'timezone-client'; + +export const storeTimezoneCookie = () => + storeCookie( + TIMEZONE_COOKIE_NAME, + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + +export const getTimezoneCookie = () => + getCookie(TIMEZONE_COOKIE_NAME);