Store client timezone in cookie and use on server when possible

This commit is contained in:
Sam Becker 2025-01-12 17:13:45 -06:00
parent 3d69e2d20c
commit 5e3521c687
14 changed files with 110 additions and 16 deletions

View File

@ -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",

12
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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({
<AdminPhotosTable
photos={photos}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
timezone={timezone}
/>
{photosCount > photos.length &&
<AdminPhotosTableInfinite
initialOffset={infiniteScrollInitial}
itemsPerPage={infiniteScrollMultiple}
hasAiTextGeneration={AI_TEXT_GENERATION_ENABLED}
timezone={timezone}
/>}
</div>
</div>}

View File

@ -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({
<PhotoDate {...{
photo,
dateType: showUpdatedAt ? 'updatedAt' : 'createdAt',
timezone,
}} />
</div>
</div>

View File

@ -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,
}} />
);
}

View File

@ -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)}
</span>
{/* Medium */}
<span
className={clsx('hidden xs:inline-block sm:hidden', contentClass)}
aria-hidden
>
{formatDate(date, 'medium', showPlaceholderContent)}
{formatDate(date, 'medium', timezone,showPlaceholderContent)}
</span>
{/* Large */}
<span
className={clsx('hidden sm:inline-block', contentClass)}
>
{formatDate(date, undefined, showPlaceholderContent)}
{formatDate(date, undefined, timezone, showPlaceholderContent)}
</span>
</span>
);

View File

@ -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: <PhotoDate {...{ photo }} />,
annotation: <PhotoDate {...{ photo, timezone }} />,
accessory: <PhotoSmall photo={photo} />,
path: pathForPhoto({ photo }),
})),
@ -184,7 +187,7 @@ export default function CommandKClient({
setIsLoading(false);
});
}
}, [queryDebounced, isPending]);
}, [queryDebounced, isPending, timezone]);
useEffect(() => {
if (queryLive === '') {

View File

@ -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,
}} />
);
}

View File

@ -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}
/>
<div className={clsx(
'flex gap-1 translate-y-[0.5px]',

View File

@ -22,8 +22,12 @@ import { labelForFilmSimulation } from '@/vendors/fujifilm';
import { getUniqueFocalLengths } from '@/photo/db/query';
import { formatFocalLength } from '@/focal';
import { TbCone } from 'react-icons/tb';
import { cookies } from 'next/headers';
import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone';
export default async function CommandK() {
const timezone = (await cookies()).get(TIMEZONE_COOKIE_NAME)?.value;
const [
count,
tags,
@ -88,5 +92,6 @@ export default async function CommandK() {
]}
showDebugTools={ADMIN_DEBUG_TOOLS_ENABLED}
footer={photoQuantityText(count, false)}
timezone={timezone}
/>;
}

View File

@ -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 (

21
src/utility/cookie.ts Normal file
View File

@ -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<string, string> = {};
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`;
};

View File

@ -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,20 +23,29 @@ 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
: 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);
}
};

18
src/utility/timezone.ts Normal file
View File

@ -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);