Merge pull request #161 from sambecker/server-dates
Prevent date hydration errors
This commit is contained in:
commit
2cc632143e
@ -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
12
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 }),
|
||||
})),
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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]',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
21
src/utility/cookie.ts
Normal 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`;
|
||||
};
|
||||
@ -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_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 = 'dd MMM yyyy 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
18
src/utility/timezone.ts
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user