Merge pull request #161 from sambecker/server-dates

Prevent date hydration errors
This commit is contained in:
Sam Becker 2025-01-12 17:45:14 -06:00 committed by GitHub
commit 2cc632143e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 160 additions and 26 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,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 (
<span
title={title}
className={clsx(className, 'uppercase')}
title={showPlaceholderContent ? 'LOADING LOCAL TIME' : title}
className={clsx(
'uppercase rounded-md transition-colors',
showPlaceholderContent && 'bg-dim',
className,
)}
>
{/* Small */}
<span
className="xs:hidden"
className={clsx('xs:hidden', contentClass)}
aria-hidden
>
{formatDate(date, 'short')}
{formatDate(date, 'short', timezone, showPlaceholderContent)}
</span>
{/* Medium */}
<span
className="hidden xs:inline-block sm:hidden"
className={clsx('hidden xs:inline-block sm:hidden', contentClass)}
aria-hidden
>
{formatDate(date, 'medium')}
{formatDate(date, 'medium', timezone,showPlaceholderContent)}
</span>
{/* Large */}
<span className="hidden sm:inline-block">
{formatDate(date)}
<span
className={clsx('hidden sm:inline-block', contentClass)}
>
{formatDate(date, undefined, timezone, showPlaceholderContent)}
</span>
</span>
);

View File

@ -166,7 +166,7 @@ export default function CommandKClient({
items: photos.map(photo => ({
label: titleForPhoto(photo),
keywords: getKeywordsForPhoto(photo),
annotation: <PhotoDate {...{ photo }} />,
annotation: <PhotoDate {...{ photo, timezone: undefined }} />,
accessory: <PhotoSmall photo={photo} />,
path: pathForPhoto({ photo }),
})),

View File

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

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

@ -164,6 +164,10 @@
@apply
bg-white dark:bg-black
}
.bg-dim {
@apply
bg-gray-100 dark:bg-gray-900/75
}
.bg-content {
@apply
bg-white border-gray-200

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

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