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 1134f8cc..e593a390 100644
--- a/src/components/ResponsiveDate.tsx
+++ b/src/components/ResponsiveDate.tsx
@@ -1,6 +1,7 @@
'use client';
import { formatDate } from '@/utility/date';
+import { Timezone } from '@/utility/timezone';
import { clsx } from 'clsx/lite';
import { useEffect, useState } from 'react';
@@ -13,19 +14,20 @@ export default function ResponsiveDate({
date: Date
className?: string
titleLabel?: string
- timezone?: string | null
+ timezone?: Timezone
}) {
const [timezone, setTimezone] = useState(timezoneFromProps);
useEffect(() => {
- if (timezoneFromProps === null) {
+ if (!timezoneFromProps) {
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [timezoneFromProps]);
- const showPlaceholderContent = timezone === null;
+ const showPlaceholderContent = timezone === undefined;
- const titleDateFormatted = formatDate(date).toLocaleUpperCase();
+ const titleDateFormatted = formatDate(date, undefined, timezone)
+ .toLocaleUpperCase();
const title = titleLabel
? `${titleLabel}: ${titleDateFormatted}`
@@ -47,20 +49,20 @@ export default function ResponsiveDate({
className={clsx('xs:hidden', contentClass)}
aria-hidden
>
- {formatDate(date, 'short', showPlaceholderContent)}
+ {formatDate(date, 'short', timezone, showPlaceholderContent)}
{/* Medium */}
- {formatDate(date, 'medium', showPlaceholderContent)}
+ {formatDate(date, 'medium', timezone,showPlaceholderContent)}
{/* Large */}
- {formatDate(date, undefined, showPlaceholderContent)}
+ {formatDate(date, undefined, timezone, showPlaceholderContent)}
);
diff --git a/src/components/cmdk/CommandKClient.tsx b/src/components/cmdk/CommandKClient.tsx
index e73ac731..6c36cd0a 100644
--- a/src/components/cmdk/CommandKClient.tsx
+++ b/src/components/cmdk/CommandKClient.tsx
@@ -48,6 +48,7 @@ import CommandKItem from './CommandKItem';
import { GRID_HOMEPAGE_ENABLED } from '@/site/config';
import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog';
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
+import { Timezone } from '@/utility/timezone';
const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@@ -76,11 +77,13 @@ export default function CommandKClient({
serverSections = [],
showDebugTools,
footer,
+ timezone,
}: {
tags: Tags
serverSections?: CommandKSection[]
showDebugTools?: boolean
footer?: string
+ timezone: Timezone
}) {
const pathname = usePathname();
@@ -166,7 +169,7 @@ export default function CommandKClient({
items: photos.map(photo => ({
label: titleForPhoto(photo),
keywords: getKeywordsForPhoto(photo),
- annotation: ,
+ annotation: ,
accessory: ,
path: pathForPhoto({ photo }),
})),
@@ -184,7 +187,7 @@ export default function CommandKClient({
setIsLoading(false);
});
}
- }, [queryDebounced, isPending]);
+ }, [queryDebounced, isPending, timezone]);
useEffect(() => {
if (queryLive === '') {
diff --git a/src/photo/PhotoDate.tsx b/src/photo/PhotoDate.tsx
index 8966a6ba..96449f0f 100644
--- a/src/photo/PhotoDate.tsx
+++ b/src/photo/PhotoDate.tsx
@@ -1,16 +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?: string | null
+ timezone: Timezone
}) {
const date = useMemo(() => {
const date = new Date(dateType === 'takenAt'
@@ -42,7 +44,7 @@ export default function PhotoDate({
date,
className,
titleLabel: getTitleLabel(),
- timezone: null,
+ 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}
/>
;
}
diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx
index 262b4776..8a8f1da8 100644
--- a/src/state/AppStateProvider.tsx
+++ b/src/state/AppStateProvider.tsx
@@ -9,6 +9,7 @@ import useSWR from 'swr';
import { HIGH_DENSITY_GRID, MATTE_PHOTOS } from '@/site/config';
import { getPhotosHiddenMetaCachedAction } from '@/photo/actions';
import { ShareModalProps } from '@/share';
+import { storeTimezoneCookie } from '@/utility/timezone';
export default function AppStateProvider({
children,
@@ -77,6 +78,7 @@ export default function AppStateProvider({
useEffect(() => {
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 96f99c99..30662c76 100644
--- a/src/utility/date.ts
+++ b/src/utility/date.ts
@@ -1,4 +1,6 @@
-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_TINY_PLACEHOLDER = '00 000 00';
@@ -21,21 +23,30 @@ type Length = 'tiny' | 'short' | 'medium' | 'long';
export const formatDate = (
date: Date,
length: Length = 'long',
+ timezone?: Timezone,
showPlaceholder?: boolean,
) => {
switch (length) {
case 'tiny': return showPlaceholder
? DATE_STRING_FORMAT_TINY_PLACEHOLDER
- : format(date, DATE_STRING_FORMAT_TINY);
+ : timezone
+ ? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_TINY)
+ : format(date, DATE_STRING_FORMAT_TINY);
case 'short': return showPlaceholder
? DATE_STRING_FORMAT_SHORT_PLACEHOLDER
- : format(date, DATE_STRING_FORMAT_SHORT);
+ : timezone
+ ? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_SHORT)
+ : format(date, DATE_STRING_FORMAT_SHORT);
case 'medium': return showPlaceholder
? DATE_STRING_FORMAT_MEDIUM_PLACEHOLDER
- : format(date, DATE_STRING_FORMAT_MEDIUM);
+ : timezone
+ ? formatInTimeZone(date, timezone, DATE_STRING_FORMAT_MEDIUM)
+ : format(date, DATE_STRING_FORMAT_MEDIUM);
default: return showPlaceholder
? DATE_STRING_FORMAT_LONG_PLACEHOLDER
- : format(date, DATE_STRING_FORMAT_LONG);
+ : 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..c9836fef
--- /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 | null | undefined;
+
+export const TIMEZONE_COOKIE_NAME = 'client-timezone';
+
+export const storeTimezoneCookie = () =>
+ storeCookie(
+ TIMEZONE_COOKIE_NAME,
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
+ );
+
+export const getTimezoneCookie = () =>
+ getCookie(TIMEZONE_COOKIE_NAME);