Add live photo querying to Command-K menu

This commit is contained in:
Sam Becker 2024-02-19 22:43:33 -06:00
parent 2cdbf43309
commit 47ea5b9086
5 changed files with 70 additions and 21 deletions

View File

@ -72,13 +72,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

@ -1,7 +1,7 @@
'use client';
import { Command } from 'cmdk';
import { useEffect, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import Modal from './Modal';
import { clsx } from 'clsx/lite';
import { useDebounce } from 'use-debounce';
@ -13,26 +13,29 @@ const LISTENER_KEYDOWN = 'keydown';
export type CommandKSection = {
heading: string
accessory?: ReactNode
items: {
label: string
accessory?: ReactNode
path?: string
action?: () => void
}[]
}
export default function CommandKClient({
isLoading,
onQueryChange,
sections = [],
}: {
isLoading?: boolean
onQueryChange?: (query: string) => void
onQueryChange?: (query: string) => Promise<CommandKSection[]>
sections?: CommandKSection[]
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [queryDebounced] = useDebounce(query, 1000);
const [isLoading, setIsLoading] = useState(false);
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
const { setTheme } = useTheme();
const router = useRouter();
@ -50,10 +53,20 @@ export default function CommandKClient({
useEffect(() => {
if (queryDebounced) {
onQueryChange?.(queryDebounced);
setIsLoading(true);
onQueryChange?.(queryDebounced).then(querySections => {
setQueriedSections(querySections);
setIsLoading(false);
});
}
}, [queryDebounced, onQueryChange]);
useEffect(() => {
if (query === '') {
setQueriedSections([]);
}
}, [query]);
const sectionTheme: CommandKSection = {
heading: 'Theme',
items: [{
@ -73,19 +86,23 @@ export default function CommandKClient({
open={open}
onOpenChange={setOpen}
label="Global Command Menu"
loop
>
<Modal
anchor='top'
onClose={() => setOpen(false)}
fast
>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="relative">
<Command.Input
onChangeCapture={(e) => setQuery(e.currentTarget.value)}
className={clsx(
'w-full',
'placeholder:text-gray-400',
'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' }}
@ -97,8 +114,14 @@ export default function CommandKClient({
</span>}
</div>
<Command.List className="relative max-h-72 overflow-y-scroll">
<Command.Empty>No results found.</Command.Empty>
{sections
<Command.Empty
hidden={isLoading}
className="mt-1 pl-3 text-dim"
>
No results found
</Command.Empty>
{queriedSections
.concat(sections)
.concat(sectionTheme)
.filter(({ items }) => items.length > 0)
.map(({ heading, items }) =>
@ -108,21 +131,23 @@ export default function CommandKClient({
className={clsx(
'uppercase',
'select-none',
'[&>*:first-child]:py-1.5',
'[&>*:first-child]:py-1',
'[&>*:first-child]:font-medium',
'[&>*:first-child]:text-dim',
'[&>*:first-child]:text-xs',
'[&>*:first-child]:tracking-wider',
)}
>
{items.map(({ label, path, action }) =>
{items.map(({ accessory, label, path, action }) =>
<Command.Item
key={`${heading}-${label}`}
value={`${heading}-${label}`}
className={clsx(
'py-1 px-2 rounded-md cursor-pointer',
'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',
'data-[active=true]:bg-green-400'
)}
onSelect={() => {
action?.();
@ -132,7 +157,10 @@ export default function CommandKClient({
}
}}
>
{label}
<div className="flex items-center gap-2">
{accessory}
{label}
</div>
</Command.Item>)}
</Command.Group>)}
</Command.List>

View File

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

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
@ -309,6 +310,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
limit = PHOTO_DEFAULT_LIMIT,
offset = 0,
title,
tag,
camera,
simulation,
@ -334,6 +336,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);

View File

@ -11,10 +11,14 @@ import {
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';
export default async function CommandK() {
const [
@ -85,5 +89,20 @@ export default async function CommandK() {
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',
items: photos.map(photo => ({
accessory: <PhotoTiny photo={photo} />,
label: titleForPhoto(photo),
path: pathForPhoto(photo),
})),
}]
: [];
}}
/>;
}