Merge branch 'main' into static

This commit is contained in:
Sam Becker 2024-02-20 22:35:37 -06:00
commit dc07da13fe
13 changed files with 1070 additions and 421 deletions

View File

@ -7,6 +7,7 @@
"Astia",
"camelcase",
"cloudflarestorage",
"cmdk",
"CredentialsSignin",
"Eterna",
"exif",

View File

@ -18,17 +18,18 @@
"@testing-library/react": "^14.2.1",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.19",
"@types/react": "18.2.55",
"@types/react": "18.2.57",
"@types/react-dom": "18.2.19",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"@vercel/analytics": "^1.2.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vercel/analytics": "^1.2.2",
"@vercel/blob": "^0.22.0",
"@vercel/postgres": "0.7.2",
"@vercel/speed-insights": "^1.0.10",
"autoprefixer": "10.4.17",
"camelcase-keys": "^9.1.3",
"clsx": "^2.1.0",
"cmdk": "^0.2.1",
"date-fns": "^3.3.1",
"eslint": "8.56.0",
"eslint-config-next": "14.1.0",
@ -36,8 +37,8 @@
"framer-motion": "^11.0.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"nanoid": "^5.0.5",
"next": "14.1.1-canary.59",
"nanoid": "^5.0.6",
"next": "14.1.1-canary.65",
"next-auth": "5.0.0-beta.9",
"next-themes": "^0.2.1",
"postcss": "8.4.35",
@ -47,6 +48,7 @@
"sonner": "^1.4.0",
"tailwindcss": "3.4.1",
"ts-exif-parser": "^0.2.2",
"typescript": "5.3.3"
"typescript": "5.3.3",
"use-debounce": "^10.0.0"
}
}

1051
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -73,13 +73,7 @@ export default async function AdminPhotosPage({
<AdminGrid>
{photos.map(photo =>
<Fragment key={photo.id}>
<PhotoTiny
className={clsx(
'rounded-sm overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
photo={photo}
/>
<PhotoTiny photo={photo} />
<div className="flex flex-col lg:flex-row">
<Link
key={photo.id}

View File

@ -14,6 +14,7 @@ import FooterClient from '@/site/FooterClient';
import NavClient from '@/site/NavClient';
import { Metadata } from 'next/types';
import MoreComponentsProvider from '@/state/MoreComponentsProvider';
import CommandK from '@/site/CommandK';
import '../site/globals.css';
@ -93,6 +94,7 @@ export default function RootLayout({
<Footer />
</Suspense>
</main>
<CommandK />
</ThemeProviderClient>
</MoreComponentsProvider>
</AppStateProvider>

View File

@ -0,0 +1,218 @@
'use client';
import { Command } from 'cmdk';
import { ReactNode, useEffect, useState } from 'react';
import Modal from './Modal';
import { clsx } from 'clsx/lite';
import { useDebounce } from 'use-debounce';
import Spinner from './Spinner';
import { useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5';
const LISTENER_KEYDOWN = 'keydown';
const MINIMUM_QUERY_LENGTH = 2;
export type CommandKSection = {
heading: string
accessory?: ReactNode
items: {
label: string
annotation?: ReactNode
annotationAria?: string
accessory?: ReactNode
path?: string
action?: () => void
}[]
}
export default function CommandKClient({
onQueryChange,
sections = [],
}: {
onQueryChange?: (query: string) => Promise<CommandKSection[]>
sections?: CommandKSection[]
}) {
const [isOpen, setIsOpen] = useState(false);
const [queryRaw, setQueryRaw] = useState('');
const [queryDebounced] = useDebounce(queryRaw, 500, { trailing: true });
const [isLoading, setIsLoading] = useState(false);
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
const { setTheme } = useTheme();
const router = useRouter();
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsOpen((open) => !open);
}
};
document.addEventListener(LISTENER_KEYDOWN, down);
return () => document.removeEventListener(LISTENER_KEYDOWN, down);
}, []);
useEffect(() => {
if (queryDebounced.length >= MINIMUM_QUERY_LENGTH) {
setIsLoading(true);
onQueryChange?.(queryDebounced).then(querySections => {
setQueriedSections(querySections);
setIsLoading(false);
});
}
}, [queryDebounced, onQueryChange]);
useEffect(() => {
if (queryRaw === '') {
setQueriedSections([]);
} else if (queryRaw.length >= MINIMUM_QUERY_LENGTH) {
setIsLoading(true);
}
}, [queryRaw]);
useEffect(() => {
if (!isOpen) {
setQueryRaw('');
setQueriedSections([]);
setIsLoading(false);
}
}, [isOpen]);
const sectionTheme: CommandKSection = {
heading: 'Theme',
accessory: <IoInvertModeSharp
size={14}
className="translate-y-[0.5px] translate-x-[-1px]"
/>,
items: [{
label: 'Use System',
annotation: <BiDesktop />,
action: () => setTheme('system'),
}, {
label: 'Light Mode',
annotation: <BiSun size={16} className="translate-x-[1.25px]" />,
action: () => setTheme('light'),
}, {
label: 'Dark Mode',
annotation: <BiMoon className="translate-x-[1px]" />,
action: () => setTheme('dark'),
}],
};
return (
<Command.Dialog
open={isOpen}
onOpenChange={setIsOpen}
label="Global Command Menu"
filter={(value, search) =>
value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}
loop
>
<Modal
anchor='top'
onClose={() => setIsOpen(false)}
fast
>
<div className="space-y-1.5">
<div className="relative">
<Command.Input
onChangeCapture={(e) => setQueryRaw(e.currentTarget.value)}
className={clsx(
'w-full',
'focus:ring-0',
'!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">
<Spinner size={16} />
</span>}
</div>
<Command.List className="relative max-h-72 overflow-y-scroll">
<Command.Empty className="mt-1 pl-3 text-dim">
{isLoading ? 'Loading ...' : 'No results found'}
</Command.Empty>
{queriedSections
.concat(sections)
.concat(sectionTheme)
.filter(({ items }) => items.length > 0)
.map(({ heading, accessory, items }) =>
<Command.Group
key={heading}
heading={<div className={clsx(
'flex items-center',
'px-2',
)}>
{accessory &&
<div className="w-5">{accessory}</div>}
{heading}
</div>}
className={clsx(
'uppercase',
'select-none',
'[&>*:first-child]:py-1',
'[&>*:first-child]:font-medium',
'[&>*:first-child]:text-dim',
'[&>*:first-child]:text-xs',
'[&>*:first-child]:tracking-wider',
)}
>
{items.map(({
accessory,
label,
annotation,
annotationAria,
path,
action,
}) =>
<Command.Item
key={`${heading}-${label}`}
value={`${heading}-${label}`}
className={clsx(
'px-2',
accessory ? 'py-2' : 'py-1',
'rounded-md cursor-pointer tracking-wide',
'data-[selected=true]:bg-gray-100',
'data-[selected=true]:dark:bg-gray-900/75',
)}
onSelect={() => {
setIsOpen(false);
action?.();
if (path) {
router.push(path);
}
}}
>
<div className="flex items-center gap-2 sm:gap-3">
{accessory}
<span className="grow text-ellipsis truncate">
{label}
</span>
{annotation &&
<span
className="text-dim whitespace-nowrap"
aria-label={annotationAria}
>
<span aria-hidden={Boolean(annotationAria)}>
{annotation}
</span>
</span>}
</div>
</Command.Item>)}
</Command.Group>)}
</Command.List>
</div>
</Modal>
</Command.Dialog>
);
}

View File

@ -11,10 +11,18 @@ import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
export default function Modal({
onClosePath,
onClose,
className,
anchor = 'center',
children,
fast,
}: {
onClosePath?: string
onClose?: () => void
className?: string
anchor?: 'top' | 'center'
children: ReactNode
fast?: boolean
}) {
const router = useRouter();
@ -32,16 +40,25 @@ export default function Modal({
useClickInsideOutside({
htmlElements,
onClickOutside: () => router.push(
onClosePath ?? PATH_ROOT,
{ scroll: false },
),
onClickOutside: () => {
if (onClose) {
onClose();
} else {
router.push(
onClosePath ?? PATH_ROOT,
{ scroll: false },
);
}
},
});
return (
<motion.div
className={clsx(
'fixed inset-0 z-50 flex items-center justify-center',
'fixed inset-0 z-50 flex justify-center',
anchor === 'top'
? 'items-start pt-4 sm:pt-24'
: 'items-center',
'bg-black',
)}
initial={!prefersReducedMotion
@ -51,7 +68,7 @@ export default function Modal({
transition={{ duration: 0.3, easing: 'easeOut' }}
>
<AnimateItems
duration={0.3}
duration={fast ? 0.1 : 0.3}
items={[<div
key="modalContent"
className={clsx(
@ -59,6 +76,7 @@ export default function Modal({
'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}

View File

@ -1,6 +1,6 @@
import React, { ReactNode, useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import { clsx } from 'clsx/lite';
import { FiMoreHorizontal } from 'react-icons/fi';
import Link from 'next/link';

View File

@ -23,6 +23,8 @@ export default function PhotoTiny({
'active:brightness-75',
selected && 'brightness-50',
'min-w-[50px]',
'rounded-[0.15rem] overflow-hidden',
'border border-gray-200 dark:border-gray-800',
)}
>
<ImageTiny

View File

@ -24,6 +24,7 @@ import { getDimensionsFromSize } from '@/utility/size';
import ImageBlurFallback from '@/components/ImageBlurFallback';
import { BLUR_ENABLED } from '@/site/config';
import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string';
const THUMBNAIL_SIZE = 300;
@ -150,9 +151,8 @@ export default function PhotoForm({
sortTagsObjectWithoutFavs(uniqueTags ?? [])
.map(({ tag, count }) => ({
value: tag,
annotation: `× ${count}`,
annotationAria:
`tagged in ${count} photo${count === 1 ? '' : 's'}`,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count, 'tagged'),
}))
)
.map(([key, {

View File

@ -268,6 +268,7 @@ export type GetPhotosOptions = {
sortBy?: 'createdAt' | 'takenAt' | 'priority'
limit?: number
offset?: number
title?: string
tag?: string
camera?: Camera
simulation?: FilmSimulation
@ -313,6 +314,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
title,
tag,
camera,
simulation,
@ -338,6 +340,10 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
wheres.push(`taken_at <= $${valueIndex++}`);
values.push(takenAfterInclusive.toISOString());
}
if (title) {
wheres.push(`LOWER(title) LIKE $${valueIndex++}`);
values.push(`%${title.toLowerCase()}%`);
}
if (tag) {
wheres.push(`$${valueIndex++}=ANY(tags)`);
values.push(tag);

138
src/site/CommandK.tsx Normal file
View File

@ -0,0 +1,138 @@
import CommandKClient, { CommandKSection } from '@/components/CommandKClient';
import {
getUniqueCamerasCached,
getUniqueFilmSimulationsCached,
getUniqueTagsCached,
} from '@/photo/cache';
import {
PATH_ADMIN_CONFIGURATION,
PATH_ADMIN_PHOTOS,
PATH_ADMIN_TAGS,
PATH_ADMIN_UPLOADS,
pathForCamera,
pathForFilmSimulation,
pathForPhoto,
pathForTag,
} from './paths';
import { formatCameraText } from '@/camera';
import { authCached } from '@/auth/cache';
import { getPhotos } from '@/services/vercel-postgres';
import { 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 { sortTagsObject } from '@/tag';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { IoDocumentText } from 'react-icons/io5';
import { FaTag } from 'react-icons/fa';
import { TbPhoto } from 'react-icons/tb';
import { IoMdCamera } from 'react-icons/io';
export default async function CommandK() {
const [
tags,
cameras,
filmSimulations,
] = await Promise.all([
getUniqueTagsCached().catch(() => []),
getUniqueCamerasCached().catch(() => []),
getUniqueFilmSimulationsCached().catch(() => []),
]);
const session = await authCached().catch(() => null);
const showAdminPages = Boolean(session?.user?.email);
const SECTION_TAGS: CommandKSection = {
heading: 'Tags',
accessory: <FaTag
size={10}
className="translate-x-[1px] translate-y-[0.75px]"
/>,
items: sortTagsObject(tags).map(({ tag, count }) => ({
label: tag,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForTag(tag),
})),
};
const SECTION_CAMERAS: CommandKSection = {
heading: 'Cameras',
accessory: <IoMdCamera />,
items: cameras.map(({ camera, count }) => ({
label: formatCameraText(camera),
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForCamera(camera),
})),
};
const SECTION_FILM: CommandKSection = {
heading: 'Film Simulations',
accessory: <span className="w-3">
<PhotoFilmSimulationIcon />
</span>,
items: filmSimulations.map(({ simulation, count }) => ({
label: simulation,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count),
path: pathForFilmSimulation(simulation),
})),
};
const SECTION_PAGES: CommandKSection = {
heading: 'Pages',
accessory: <IoDocumentText size={14} className="translate-x-[-1px]" />,
items: ([{
label: 'Home',
path: '/',
}, {
label: 'Grid',
path:'/grid',
}] as CommandKSection['items']).concat(showAdminPages ? [{
label: 'Admin / Photos',
annotation: <BiLockAlt />,
path: PATH_ADMIN_PHOTOS,
}, {
label: 'Admin / Uploads',
annotation: <BiLockAlt />,
path: PATH_ADMIN_UPLOADS,
}, {
label: 'Admin / Tags',
annotation: <BiLockAlt />,
path: PATH_ADMIN_TAGS,
}, {
label: 'Admin / Config',
annotation: <BiLockAlt />,
path: PATH_ADMIN_CONFIGURATION,
}] : []),
};
return <CommandKClient
sections={[
SECTION_TAGS,
SECTION_CAMERAS,
SECTION_FILM,
SECTION_PAGES,
]}
onQueryChange={async (query) => {
'use server';
const photos = (await getPhotos({ title: query }))
.filter(({ title }) => Boolean(title));
return photos.length > 0
? [{
heading: 'Photos',
accessory: <TbPhoto size={14} />,
items: photos.map(photo => ({
accessory: <PhotoTiny photo={photo} />,
label: titleForPhoto(photo),
annotation: formatDate(photo.takenAt),
path: pathForPhoto(photo),
})),
}]
: [];
}}
/>;
}

View File

@ -24,3 +24,14 @@ export const parameterize = (string: string) =>
// Removes all non-alphanumeric characters
.replaceAll(/([^a-z0-9-])/gi, '')
.toLowerCase();
export const formatCount = (count: number) => `× ${count}`;
export const formatCountDescriptive = (
count: number,
verb = 'found',
noun = 'photo',
singular = '',
plural = 's',
) =>
`${verb} in ${count} ${noun}${count === 1 ? singular : plural}`;