Merge branch 'main' into static
This commit is contained in:
commit
77e53b7755
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -2,6 +2,7 @@
|
||||
"cSpell.words": [
|
||||
"ABCDEFGHIJKLMNOP",
|
||||
"Acros",
|
||||
"affordance",
|
||||
"ARROWLEFT",
|
||||
"ARROWRIGHT",
|
||||
"Astia",
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"@vercel/blob": "^0.22.0",
|
||||
"@vercel/blob": "^0.22.1",
|
||||
"@vercel/postgres": "0.7.2",
|
||||
"@vercel/speed-insights": "^1.0.10",
|
||||
"autoprefixer": "10.4.17",
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@ -48,8 +48,8 @@ dependencies:
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(next@14.1.1-canary.65)(react@18.2.0)
|
||||
'@vercel/blob':
|
||||
specifier: ^0.22.0
|
||||
version: 0.22.0
|
||||
specifier: ^0.22.1
|
||||
version: 0.22.1
|
||||
'@vercel/postgres':
|
||||
specifier: 0.7.2
|
||||
version: 0.7.2
|
||||
@ -3259,13 +3259,14 @@ packages:
|
||||
server-only: 0.0.1
|
||||
dev: false
|
||||
|
||||
/@vercel/blob@0.22.0:
|
||||
resolution: {integrity: sha512-l0o5bN5ih1H1DG29goULMpCzNIoFI3knFYNFwvGN7iZhK9vltCdlDy77AmrFldRP5af02YczUkjSXWLHMrHStg==}
|
||||
/@vercel/blob@0.22.1:
|
||||
resolution: {integrity: sha512-LtHmiYAdJhiSAfBP+5hHXtVyqZUND2G+ild/XVY0SOiB46ab7VUrQctwUMGcVx+yZyXZ2lXPT1HvRJtXFnKvHA==}
|
||||
engines: {node: '>=16.14'}
|
||||
dependencies:
|
||||
async-retry: 1.3.3
|
||||
bytes: 3.1.2
|
||||
undici: 5.28.2
|
||||
is-buffer: 2.0.5
|
||||
undici: 5.28.3
|
||||
dev: false
|
||||
|
||||
/@vercel/postgres@0.7.2:
|
||||
@ -5184,6 +5185,11 @@ packages:
|
||||
has-tostringtag: 1.0.2
|
||||
dev: false
|
||||
|
||||
/is-buffer@2.0.5:
|
||||
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
|
||||
engines: {node: '>=4'}
|
||||
dev: false
|
||||
|
||||
/is-callable@1.2.7:
|
||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -7526,8 +7532,8 @@ packages:
|
||||
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||
dev: false
|
||||
|
||||
/undici@5.28.2:
|
||||
resolution: {integrity: sha512-wh1pHJHnUeQV5Xa8/kyQhO7WFa8M34l026L5P/+2TYiakvGy5Rdc8jWZVyG7ieht/0WgJLEd3kcU5gKx+6GC8w==}
|
||||
/undici@5.28.3:
|
||||
resolution: {integrity: sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==}
|
||||
engines: {node: '>=14.0'}
|
||||
dependencies:
|
||||
'@fastify/busboy': 2.1.0
|
||||
|
||||
@ -97,11 +97,11 @@ export default function RootLayout({
|
||||
<CommandK />
|
||||
</ThemeProviderClient>
|
||||
</MoreComponentsProvider>
|
||||
</AppStateProvider>
|
||||
<Analytics debug={false} />
|
||||
<SpeedInsights debug={false} />
|
||||
<PhotoEscapeHandler />
|
||||
<ToasterWithThemes />
|
||||
</AppStateProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Photo } from '@/photo';
|
||||
import type { Photo } from '@/photo';
|
||||
import { parameterize } from '@/utility/string';
|
||||
|
||||
const CAMERA_PLACEHOLDER: Camera = { make: 'Camera', model: 'Model' };
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Modal from './Modal';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
@ -10,6 +10,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
|
||||
import { IoInvertModeSharp } from 'react-icons/io5';
|
||||
import { useAppState } from '@/state';
|
||||
|
||||
const LISTENER_KEYDOWN = 'keydown';
|
||||
const MINIMUM_QUERY_LENGTH = 2;
|
||||
@ -30,13 +31,31 @@ export type CommandKSection = {
|
||||
export default function CommandKClient({
|
||||
onQueryChange,
|
||||
sections = [],
|
||||
footer,
|
||||
}: {
|
||||
onQueryChange?: (query: string) => Promise<CommandKSection[]>
|
||||
sections?: CommandKSection[]
|
||||
footer?: string
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [queryRaw, setQueryRaw] = useState('');
|
||||
const [queryDebounced] = useDebounce(queryRaw, 500, { trailing: true });
|
||||
const {
|
||||
isCommandKOpen: isOpen,
|
||||
setIsCommandKOpen: setIsOpen,
|
||||
setShouldRespondToKeyboardCommands,
|
||||
} = useAppState();
|
||||
|
||||
const isOpenRef = useRef(isOpen);
|
||||
|
||||
// Raw query values
|
||||
const [queryLiveRaw, setQueryLive] = useState('');
|
||||
const [queryDebouncedRaw] =
|
||||
useDebounce(queryLiveRaw, 500, { trailing: true });
|
||||
const isPlaceholderVisible = queryLiveRaw === '';
|
||||
|
||||
// Parameterized query values
|
||||
const queryLive = useMemo(() =>
|
||||
queryLiveRaw.trim().toLocaleLowerCase(), [queryLiveRaw]);
|
||||
const queryDebounced = useMemo(() =>
|
||||
queryDebouncedRaw.trim().toLocaleLowerCase(), [queryDebouncedRaw]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
|
||||
@ -45,42 +64,55 @@ export default function CommandKClient({
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
isOpenRef.current = isOpen;
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setIsOpen((open) => !open);
|
||||
setIsOpen?.((open) => !open);
|
||||
}
|
||||
};
|
||||
document.addEventListener(LISTENER_KEYDOWN, down);
|
||||
return () => document.removeEventListener(LISTENER_KEYDOWN, down);
|
||||
}, []);
|
||||
}, [setIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryDebounced.length >= MINIMUM_QUERY_LENGTH) {
|
||||
setIsLoading(true);
|
||||
onQueryChange?.(queryDebounced).then(querySections => {
|
||||
if (isOpenRef.current) {
|
||||
setQueriedSections(querySections);
|
||||
} else {
|
||||
// Ignore stale requests that come in after dialog is closed
|
||||
setQueriedSections([]);
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [queryDebounced, onQueryChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryRaw === '') {
|
||||
setQueriedSections([]);
|
||||
} else if (queryRaw.length >= MINIMUM_QUERY_LENGTH) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [queryRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQueryRaw('');
|
||||
if (queryLive === '') {
|
||||
setQueriedSections([]);
|
||||
setIsLoading(false);
|
||||
} else if (queryLive.length >= MINIMUM_QUERY_LENGTH) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [queryLive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShouldRespondToKeyboardCommands?.(false);
|
||||
} else if (!isOpen) {
|
||||
setQueryLive('');
|
||||
setQueriedSections([]);
|
||||
setIsLoading(false);
|
||||
setTimeout(() => setShouldRespondToKeyboardCommands?.(true), 500);
|
||||
}
|
||||
}, [isOpen, setShouldRespondToKeyboardCommands]);
|
||||
|
||||
const sectionTheme: CommandKSection = {
|
||||
heading: 'Theme',
|
||||
@ -114,32 +146,38 @@ export default function CommandKClient({
|
||||
>
|
||||
<Modal
|
||||
anchor='top'
|
||||
onClose={() => setIsOpen(false)}
|
||||
onClose={() => setIsOpen?.(false)}
|
||||
fast
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative">
|
||||
<Command.Input
|
||||
onChangeCapture={(e) => setQueryRaw(e.currentTarget.value)}
|
||||
onChangeCapture={(e) => setQueryLive(e.currentTarget.value)}
|
||||
className={clsx(
|
||||
'w-full',
|
||||
'w-full !min-w-0',
|
||||
'focus:ring-0',
|
||||
isPlaceholderVisible || isLoading && '!pr-8',
|
||||
'!border-gray-200 dark:!border-gray-800',
|
||||
'focus:border-gray-200 focus:dark:border-gray-800',
|
||||
'placeholder:text-gray-400/80',
|
||||
'placeholder:dark:text-gray-700',
|
||||
)}
|
||||
style={{ paddingRight: '2rem' }}
|
||||
placeholder="Search photos, views, settings ..."
|
||||
/>
|
||||
{isLoading &&
|
||||
<span className="absolute top-2.5 right-3">
|
||||
<span className={clsx(
|
||||
'absolute top-2.5 right-0 w-8',
|
||||
'flex items-center justify-center translate-y-[2px]',
|
||||
)}>
|
||||
<Spinner size={16} />
|
||||
</span>}
|
||||
</div>
|
||||
<Command.List className="relative max-h-72 overflow-y-scroll">
|
||||
<Command.List className={clsx(
|
||||
'relative overflow-y-scroll',
|
||||
'max-h-48 sm:max-h-72',
|
||||
)}>
|
||||
<Command.Empty className="mt-1 pl-3 text-dim">
|
||||
{isLoading ? 'Loading ...' : 'No results found'}
|
||||
{isLoading ? 'Searching ...' : 'No results found'}
|
||||
</Command.Empty>
|
||||
{queriedSections
|
||||
.concat(sections)
|
||||
@ -175,8 +213,8 @@ export default function CommandKClient({
|
||||
action,
|
||||
}) =>
|
||||
<Command.Item
|
||||
key={`${heading}-${label}`}
|
||||
value={`${heading}-${label}`}
|
||||
key={`${heading} ${label}`}
|
||||
value={`${heading} ${label}`}
|
||||
className={clsx(
|
||||
'px-2',
|
||||
accessory ? 'py-2' : 'py-1',
|
||||
@ -185,7 +223,7 @@ export default function CommandKClient({
|
||||
'data-[selected=true]:dark:bg-gray-900/75',
|
||||
)}
|
||||
onSelect={() => {
|
||||
setIsOpen(false);
|
||||
setIsOpen?.(false);
|
||||
action?.();
|
||||
if (path) {
|
||||
router.push(path);
|
||||
@ -209,6 +247,10 @@ export default function CommandKClient({
|
||||
</div>
|
||||
</Command.Item>)}
|
||||
</Command.Group>)}
|
||||
{footer && !queryLive &&
|
||||
<div className="text-center text-dim pt-3 sm:pt-4">
|
||||
{footer}
|
||||
</div>}
|
||||
</Command.List>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation';
|
||||
import AnimateItems from './AnimateItems';
|
||||
import { PATH_ROOT } from '@/site/paths';
|
||||
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
||||
import useMetaThemeColor from '@/site/useMetaThemeColor';
|
||||
|
||||
export default function Modal({
|
||||
onClosePath,
|
||||
@ -38,6 +39,8 @@ export default function Modal({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useMetaThemeColor({ colorLight: '#333' });
|
||||
|
||||
useClickInsideOutside({
|
||||
htmlElements,
|
||||
onClickOutside: () => {
|
||||
@ -70,16 +73,16 @@ export default function Modal({
|
||||
<AnimateItems
|
||||
duration={fast ? 0.1 : 0.3}
|
||||
items={[<div
|
||||
ref={contentRef}
|
||||
key="modalContent"
|
||||
className={clsx(
|
||||
'w-[calc(100vw-1.5rem)] sm:w-[min(540px,90vw)]',
|
||||
'p-3 rounded-lg',
|
||||
'md:p-4 md:rounded-xl',
|
||||
'bg-white dark:bg-black',
|
||||
'dark:border dark:border-gray-800',
|
||||
'md:p-4 md:rounded-xl',
|
||||
className,
|
||||
)}
|
||||
style={{ width: 'min(500px, 90vw)' }}
|
||||
ref={contentRef}
|
||||
>
|
||||
{children}
|
||||
</div>]}
|
||||
|
||||
@ -3,17 +3,20 @@ import { clsx } from 'clsx/lite';
|
||||
|
||||
export default function Switcher({
|
||||
children,
|
||||
type = 'regular',
|
||||
}: {
|
||||
children: ReactNode
|
||||
type?: 'regular' | 'borderless'
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'flex divide-x',
|
||||
'flex divide-x overflow-hidden',
|
||||
'divide-gray-300 dark:divide-gray-800',
|
||||
'border rounded-[0.25rem]',
|
||||
'border-gray-300 dark:border-gray-800',
|
||||
'overflow-hidden',
|
||||
'shadow-sm',
|
||||
type === 'regular'
|
||||
? 'border-gray-300 dark:border-gray-800'
|
||||
: 'border-transparent',
|
||||
type === 'regular' && 'shadow-sm',
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -20,12 +20,14 @@ export default function SwitcherItem({
|
||||
classNameProp,
|
||||
'py-0.5 px-1.5',
|
||||
'cursor-pointer',
|
||||
'hover:bg-gray-50 active:bg-gray-100 active:text-gray-400',
|
||||
// eslint-disable-next-line max-len
|
||||
'dark:hover:bg-gray-950 dark:active:bg-gray-900/75 dark:active:text-gray-600',
|
||||
'hover:bg-gray-100/60 active:bg-gray-100',
|
||||
'dark:hover:bg-gray-900/75 dark:active:bg-gray-900',
|
||||
active
|
||||
? 'text-black dark:text-white'
|
||||
: 'text-gray-300 dark:text-gray-700',
|
||||
: 'text-gray-400 dark:text-gray-600',
|
||||
active
|
||||
? 'hover:text-black hover:dark:text-white'
|
||||
: 'hover:text-gray-700 dark:hover:text-gray-400',
|
||||
);
|
||||
|
||||
const renderIcon = () => noPadding
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Photo } from '../photo';
|
||||
import type { Photo } from '../photo';
|
||||
import { FaStar, FaTag } from 'react-icons/fa';
|
||||
import ImageCaption from './components/ImageCaption';
|
||||
import ImagePhotoGrid from './components/ImagePhotoGrid';
|
||||
import ImageContainer from './components/ImageContainer';
|
||||
import { NextImageSize } from '@/services/next-image';
|
||||
import type { NextImageSize } from '@/services/next-image';
|
||||
import { isTagFavs } from '@/tag';
|
||||
|
||||
export default function TagImageResponse({
|
||||
|
||||
@ -95,12 +95,19 @@ export default function PhotoDetailPage({
|
||||
tag={tag}
|
||||
animateOnFirstLoadOnly
|
||||
/>}
|
||||
contentSide={<div className={clsx(
|
||||
contentSide={<AnimateItems
|
||||
animateOnFirstLoadOnly
|
||||
type="bottom"
|
||||
items={[
|
||||
<div
|
||||
key="PhotoLinks"
|
||||
className={clsx(
|
||||
'grid grid-cols-2',
|
||||
'gap-0.5 sm:gap-1',
|
||||
'md:flex md:gap-4',
|
||||
'user-select-none',
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
<PhotoLinks {...{
|
||||
photo,
|
||||
photos,
|
||||
@ -108,7 +115,9 @@ export default function PhotoDetailPage({
|
||||
camera,
|
||||
simulation,
|
||||
}} />
|
||||
</div>}
|
||||
</div>,
|
||||
]}
|
||||
/>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getEscapePath } from '@/site/paths';
|
||||
import { useAppState } from '@/state';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
@ -11,9 +12,12 @@ export default function PhotoEscapeHandler() {
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
const { shouldRespondToKeyboardCommands } = useAppState();
|
||||
|
||||
const escapePath = getEscapePath(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRespondToKeyboardCommands) {
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key.toUpperCase() === 'ESCAPE' && escapePath) {
|
||||
router.push(escapePath, { scroll: false });
|
||||
@ -21,7 +25,8 @@ export default function PhotoEscapeHandler() {
|
||||
};
|
||||
window.addEventListener(LISTENER_KEYUP, onKeyUp);
|
||||
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
|
||||
}, [router, escapePath]);
|
||||
}
|
||||
}, [shouldRespondToKeyboardCommands, router, escapePath]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ export default function PhotoGrid({
|
||||
'aspect-square',
|
||||
'overflow-hidden',
|
||||
'[&>*]:flex [&>*]:w-full [&>*]:h-full',
|
||||
'[&>*>*]:object-cover',
|
||||
'[&>*>*]:object-cover [&>*>*]:min-h-full',
|
||||
)
|
||||
: undefined}
|
||||
style={{
|
||||
|
||||
@ -30,12 +30,16 @@ export default function PhotoLinks({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const { setNextPhotoAnimation } = useAppState();
|
||||
const {
|
||||
setNextPhotoAnimation,
|
||||
shouldRespondToKeyboardCommands,
|
||||
} = useAppState();
|
||||
|
||||
const previousPhoto = getPreviousPhoto(photo, photos);
|
||||
const nextPhoto = getNextPhoto(photo, photos);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRespondToKeyboardCommands) {
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
switch (e.key.toUpperCase()) {
|
||||
case 'ARROWLEFT':
|
||||
@ -62,8 +66,10 @@ export default function PhotoLinks({
|
||||
};
|
||||
window.addEventListener(LISTENER_KEYUP, onKeyUp);
|
||||
return () => window.removeEventListener(LISTENER_KEYUP, onKeyUp);
|
||||
}
|
||||
}, [
|
||||
router,
|
||||
shouldRespondToKeyboardCommands,
|
||||
setNextPhotoAnimation,
|
||||
previousPhoto,
|
||||
nextPhoto,
|
||||
|
||||
@ -155,7 +155,10 @@ export const convertPhotoToFormData = (
|
||||
export const convertExifToFormData = (
|
||||
data: ExifData,
|
||||
filmSimulation?: FilmSimulation,
|
||||
): Record<keyof PhotoExif, string | undefined> => ({
|
||||
): Omit<
|
||||
Record<keyof PhotoExif, string | undefined>,
|
||||
'takenAt' | 'takenAtNaive'
|
||||
> => ({
|
||||
aspectRatio: getAspectRatioFromExif(data).toString(),
|
||||
make: data.tags?.Make,
|
||||
model: data.tags?.Model,
|
||||
@ -170,15 +173,14 @@ export const convertExifToFormData = (
|
||||
longitude:
|
||||
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
|
||||
filmSimulation,
|
||||
takenAt: data.tags?.DateTimeOriginal
|
||||
? convertTimestampWithOffsetToPostgresString(
|
||||
data.tags?.DateTimeOriginal,
|
||||
...data.tags?.DateTimeOriginal && {
|
||||
takenAt: convertTimestampWithOffsetToPostgresString(
|
||||
data.tags.DateTimeOriginal,
|
||||
getOffsetFromExif(data),
|
||||
)
|
||||
: undefined,
|
||||
takenAtNaive: data.tags?.DateTimeOriginal
|
||||
? convertTimestampToNaivePostgresString(data.tags?.DateTimeOriginal)
|
||||
: undefined,
|
||||
),
|
||||
takenAtNaive:
|
||||
convertTimestampToNaivePostgresString(data.tags.DateTimeOriginal),
|
||||
},
|
||||
});
|
||||
|
||||
// PREPARE FORM FOR DB INSERT
|
||||
|
||||
@ -44,8 +44,8 @@ export interface PhotoExif {
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
filmSimulation?: FilmSimulation
|
||||
takenAt: string
|
||||
takenAtNaive: string
|
||||
takenAt?: string
|
||||
takenAtNaive?: string
|
||||
}
|
||||
|
||||
// Raw db insert
|
||||
@ -59,6 +59,8 @@ export interface PhotoDbInsert extends PhotoExif {
|
||||
locationName?: string
|
||||
priorityOrder?: number
|
||||
hidden?: boolean
|
||||
takenAt: string
|
||||
takenAtNaive: string
|
||||
}
|
||||
|
||||
// Raw db response
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import CommandKClient, { CommandKSection } from '@/components/CommandKClient';
|
||||
import {
|
||||
getPhotosCountCached,
|
||||
getUniqueCamerasCached,
|
||||
getUniqueFilmSimulationsCached,
|
||||
getUniqueTagsCached,
|
||||
@ -9,6 +10,7 @@ import {
|
||||
PATH_ADMIN_PHOTOS,
|
||||
PATH_ADMIN_TAGS,
|
||||
PATH_ADMIN_UPLOADS,
|
||||
PATH_SIGN_IN,
|
||||
pathForCamera,
|
||||
pathForFilmSimulation,
|
||||
pathForPhoto,
|
||||
@ -17,24 +19,27 @@ import {
|
||||
import { formatCameraText } from '@/camera';
|
||||
import { authCached } from '@/auth/cache';
|
||||
import { getPhotos } from '@/services/vercel-postgres';
|
||||
import { titleForPhoto } from '@/photo';
|
||||
import { photoQuantityText, titleForPhoto } from '@/photo';
|
||||
import PhotoTiny from '@/photo/PhotoTiny';
|
||||
import { formatDate } from '@/utility/date';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
import { BiLockAlt } from 'react-icons/bi';
|
||||
import { BiLockAlt, BiSolidUser } from 'react-icons/bi';
|
||||
import { sortTagsObject } from '@/tag';
|
||||
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
|
||||
import { FaTag } from 'react-icons/fa';
|
||||
import { TbPhoto } from 'react-icons/tb';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { HiDocumentText } from 'react-icons/hi';
|
||||
import { signOutAction } from '@/auth/actions';
|
||||
|
||||
export default async function CommandK() {
|
||||
const [
|
||||
count,
|
||||
tags,
|
||||
cameras,
|
||||
filmSimulations,
|
||||
] = await Promise.all([
|
||||
getPhotosCountCached().catch(() => 0),
|
||||
getUniqueTagsCached().catch(() => []),
|
||||
getUniqueCamerasCached().catch(() => []),
|
||||
getUniqueFilmSimulationsCached().catch(() => []),
|
||||
@ -42,7 +47,7 @@ export default async function CommandK() {
|
||||
|
||||
const session = await authCached().catch(() => null);
|
||||
|
||||
const showAdminPages = Boolean(session?.user?.email);
|
||||
const isAdminLoggedIn = Boolean(session?.user?.email);
|
||||
|
||||
const SECTION_TAGS: CommandKSection = {
|
||||
heading: 'Tags',
|
||||
@ -72,7 +77,7 @@ export default async function CommandK() {
|
||||
const SECTION_FILM: CommandKSection = {
|
||||
heading: 'Film Simulations',
|
||||
accessory: <span className="w-3">
|
||||
<PhotoFilmSimulationIcon />
|
||||
<PhotoFilmSimulationIcon className="translate-y-[0.5px]" />
|
||||
</span>,
|
||||
items: filmSimulations.map(({ simulation, count }) => ({
|
||||
label: simulation,
|
||||
@ -91,23 +96,37 @@ export default async function CommandK() {
|
||||
}, {
|
||||
label: 'Grid',
|
||||
path:'/grid',
|
||||
}] as CommandKSection['items']).concat(showAdminPages ? [{
|
||||
label: 'Admin / Photos',
|
||||
}]),
|
||||
};
|
||||
|
||||
const SECTION_ADMIN: CommandKSection = {
|
||||
heading: 'Admin',
|
||||
accessory: <BiSolidUser size={15} className="translate-x-[-1px]" />,
|
||||
items: isAdminLoggedIn
|
||||
? [{
|
||||
label: 'Manage Photos',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_PHOTOS,
|
||||
}, {
|
||||
label: 'Admin / Uploads',
|
||||
label: 'Manage Uploads',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_UPLOADS,
|
||||
}, {
|
||||
label: 'Admin / Tags',
|
||||
label: 'Manage Tags',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_TAGS,
|
||||
}, {
|
||||
label: 'Admin / Config',
|
||||
label: 'App Config',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_CONFIGURATION,
|
||||
}] : []),
|
||||
}, {
|
||||
label: 'Sign Out',
|
||||
action: signOutAction,
|
||||
}]
|
||||
: [{
|
||||
label: 'Sign In',
|
||||
path: PATH_SIGN_IN,
|
||||
}],
|
||||
};
|
||||
|
||||
return <CommandKClient
|
||||
@ -116,10 +135,11 @@ export default async function CommandK() {
|
||||
SECTION_CAMERAS,
|
||||
SECTION_FILM,
|
||||
SECTION_PAGES,
|
||||
SECTION_ADMIN,
|
||||
]}
|
||||
onQueryChange={async (query) => {
|
||||
'use server';
|
||||
const photos = (await getPhotos({ title: query }))
|
||||
const photos = (await getPhotos({ title: query, limit: 10 }))
|
||||
.filter(({ title }) => Boolean(title));
|
||||
return photos.length > 0
|
||||
? [{
|
||||
@ -134,5 +154,6 @@ export default async function CommandK() {
|
||||
}]
|
||||
: [];
|
||||
}}
|
||||
footer={photoQuantityText(count, false)}
|
||||
/>;
|
||||
}
|
||||
|
||||
27
src/site/IconSearch.tsx
Normal file
27
src/site/IconSearch.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
/* eslint-disable max-len */
|
||||
|
||||
const INTRINSIC_WIDTH = 28;
|
||||
const INTRINSIC_HEIGHT = 24;
|
||||
|
||||
export default function IconSearch({
|
||||
width = INTRINSIC_WIDTH,
|
||||
includeTitle = true,
|
||||
}: {
|
||||
width?: number;
|
||||
includeTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={(INTRINSIC_HEIGHT * width) / INTRINSIC_WIDTH}
|
||||
viewBox="0 0 28 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{includeTitle && <title>Search ⌘K</title>}
|
||||
<circle cx="13.5" cy="11.5" r="4.875" strokeWidth="1.5" />
|
||||
<path d="M17 15L21 19" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
/* eslint-disable max-len */
|
||||
|
||||
const INTRINSIC_WIDTH = 28;
|
||||
const INTRINSIC_HEIGHT = 24;
|
||||
|
||||
export default function IconSets({
|
||||
width = INTRINSIC_WIDTH,
|
||||
includeTitle = true,
|
||||
}: {
|
||||
width?: number
|
||||
includeTitle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={INTRINSIC_HEIGHT * width / INTRINSIC_WIDTH}
|
||||
viewBox="0 0 28 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{includeTitle && <title>Photo Sets</title>}
|
||||
<path d="M18.5 16.375L9.75 16.375" strokeWidth="1.25"/>
|
||||
<path d="M22.25 12.125L9.75 12.125" strokeWidth="1.25"/>
|
||||
<path d="M20.5 7.875L9.75 7.875" strokeWidth="1.25"/>
|
||||
<path d="M7.25 16.375L6.25 16.375" strokeWidth="1.25" strokeLinecap="round"/>
|
||||
<path d="M7.25 12.125L6.25 12.125" strokeWidth="1.25" strokeLinecap="round"/>
|
||||
<path d="M7.25 7.875L6.25 7.875" strokeWidth="1.25" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@ -11,7 +11,6 @@ import {
|
||||
isPathAdmin,
|
||||
isPathGrid,
|
||||
isPathProtected,
|
||||
isPathSets,
|
||||
isPathSignIn,
|
||||
} from '@/site/paths';
|
||||
import AnimateItems from '../components/AnimateItems';
|
||||
@ -40,8 +39,6 @@ export default function NavClient({
|
||||
return 'full-frame';
|
||||
} else if (isPathGrid(pathname)) {
|
||||
return 'grid';
|
||||
} else if (isPathSets(pathname)) {
|
||||
return 'sets';
|
||||
} else if (isPathProtected(pathname)) {
|
||||
return 'admin';
|
||||
}
|
||||
@ -62,7 +59,7 @@ export default function NavClient({
|
||||
'w-full min-h-[4rem]',
|
||||
'leading-none',
|
||||
)}>
|
||||
<div className="flex flex-grow items-center gap-4">
|
||||
<div className="flex-grow">
|
||||
<ViewSwitcher
|
||||
currentSelection={switcherSelectionForPath()}
|
||||
showAdmin={showAdmin}
|
||||
|
||||
@ -2,9 +2,10 @@ import Switcher from '@/components/Switcher';
|
||||
import SwitcherItem from '@/components/SwitcherItem';
|
||||
import IconFullFrame from '@/site/IconFullFrame';
|
||||
import IconGrid from '@/site/IconGrid';
|
||||
import { PATH_ADMIN_PHOTOS, PATH_GRID, PATH_SETS } from '@/site/paths';
|
||||
import { PATH_ADMIN_PHOTOS, PATH_GRID } from '@/site/paths';
|
||||
import { BiLockAlt } from 'react-icons/bi';
|
||||
import IconSets from './IconSets';
|
||||
import { useAppState } from '@/state';
|
||||
import IconSearch from './IconSearch';
|
||||
|
||||
export type SwitcherSelection = 'full-frame' | 'grid' | 'sets' | 'admin';
|
||||
|
||||
@ -15,7 +16,10 @@ export default function ViewSwitcher({
|
||||
currentSelection?: SwitcherSelection
|
||||
showAdmin?: boolean
|
||||
}) {
|
||||
const { setIsCommandKOpen } = useAppState();
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<Switcher>
|
||||
<SwitcherItem
|
||||
icon={<IconFullFrame />}
|
||||
@ -29,13 +33,6 @@ export default function ViewSwitcher({
|
||||
active={currentSelection === 'grid'}
|
||||
noPadding
|
||||
/>
|
||||
<SwitcherItem
|
||||
className="md:hidden"
|
||||
icon={<IconSets />}
|
||||
href={PATH_SETS}
|
||||
active={currentSelection === 'sets'}
|
||||
noPadding
|
||||
/>
|
||||
{showAdmin &&
|
||||
<SwitcherItem
|
||||
icon={<BiLockAlt size={16} className="translate-y-[-0.5px]" />}
|
||||
@ -43,5 +40,12 @@ export default function ViewSwitcher({
|
||||
active={currentSelection === 'admin'}
|
||||
/>}
|
||||
</Switcher>
|
||||
<Switcher type="borderless">
|
||||
<SwitcherItem
|
||||
icon={<IconSearch />}
|
||||
onClick={() => setIsCommandKOpen?.(true)}
|
||||
/>
|
||||
</Switcher>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import { FilmSimulation } from '@/simulation';
|
||||
// Core paths
|
||||
export const PATH_ROOT = '/';
|
||||
export const PATH_GRID = '/grid';
|
||||
export const PATH_SETS = '/sets';
|
||||
export const PATH_ADMIN = '/admin';
|
||||
export const PATH_API = '/api';
|
||||
export const PATH_SIGN_IN = '/sign-in';
|
||||
@ -55,7 +54,6 @@ export const PATHS_ADMIN = [
|
||||
export const PATHS_TO_CACHE = [
|
||||
PATH_ROOT,
|
||||
PATH_GRID,
|
||||
PATH_SETS,
|
||||
PATH_OG,
|
||||
PATH_PHOTO_DYNAMIC,
|
||||
PATH_TAG_DYNAMIC,
|
||||
@ -236,9 +234,6 @@ export const checkPathPrefix = (pathname = '', prefix: string) =>
|
||||
export const isPathGrid = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_GRID);
|
||||
|
||||
export const isPathSets = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_SETS);
|
||||
|
||||
export const isPathSignIn = (pathname?: string) =>
|
||||
checkPathPrefix(pathname, PATH_SIGN_IN);
|
||||
|
||||
|
||||
28
src/site/useMetaThemeColor.ts
Normal file
28
src/site/useMetaThemeColor.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function useMetaThemeColor({
|
||||
colorLight,
|
||||
colorDark,
|
||||
}: {
|
||||
colorLight?: string
|
||||
colorDark?: string
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const preferredThemeColor = resolvedTheme === 'light'
|
||||
? colorLight
|
||||
: colorDark;
|
||||
|
||||
useEffect(() => {
|
||||
if (preferredThemeColor) {
|
||||
// Temporarily create meta tag for overlays,
|
||||
// which prevents stale headers on theme changes
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'theme-color';
|
||||
meta.content = preferredThemeColor;
|
||||
document.getElementsByTagName('head')[0]?.appendChild(meta);
|
||||
return () => meta.remove();
|
||||
}
|
||||
}, [preferredThemeColor]);
|
||||
}
|
||||
@ -1,12 +1,16 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { Dispatch, SetStateAction, createContext, useContext } from 'react';
|
||||
import { AnimationConfig } from '@/components/AnimateItems';
|
||||
|
||||
export interface AppStateContext {
|
||||
previousPathname?: string
|
||||
hasLoaded?: boolean
|
||||
setHasLoaded?: (hasLoaded: boolean) => void
|
||||
setHasLoaded?: Dispatch<SetStateAction<boolean>>
|
||||
nextPhotoAnimation?: AnimationConfig
|
||||
setNextPhotoAnimation?: (animation?: AnimationConfig) => void
|
||||
setNextPhotoAnimation?: Dispatch<SetStateAction<AnimationConfig | undefined>>
|
||||
shouldRespondToKeyboardCommands?: boolean
|
||||
setShouldRespondToKeyboardCommands?: Dispatch<SetStateAction<boolean>>
|
||||
isCommandKOpen?: boolean
|
||||
setIsCommandKOpen?: Dispatch<SetStateAction<boolean>>
|
||||
clearNextPhotoAnimation?: () => void
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,11 @@ export default function AppStateProvider({
|
||||
const [nextPhotoAnimation, setNextPhotoAnimation] =
|
||||
useState<AnimationConfig>();
|
||||
|
||||
const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] =
|
||||
useState(true);
|
||||
|
||||
const [isCommandKOpen, setIsCommandKOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasLoaded?.(true);
|
||||
}, [setHasLoaded]);
|
||||
@ -29,6 +34,10 @@ export default function AppStateProvider({
|
||||
setHasLoaded,
|
||||
nextPhotoAnimation,
|
||||
setNextPhotoAnimation,
|
||||
shouldRespondToKeyboardCommands,
|
||||
setShouldRespondToKeyboardCommands,
|
||||
isCommandKOpen,
|
||||
setIsCommandKOpen,
|
||||
clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined),
|
||||
}}
|
||||
>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user