Store client timezone in cookie and use on server when possible
This commit is contained in:
parent
3d69e2d20c
commit
5e3521c687
@ -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,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>
|
||||
);
|
||||
|
||||
@ -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 === '') {
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
@ -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]',
|
||||
|
||||
@ -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}
|
||||
/>;
|
||||
}
|
||||
|
||||
@ -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,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
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 | 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);
|
||||
Loading…
Reference in New Issue
Block a user