Add live photo querying to Command-K menu
This commit is contained in:
parent
2cdbf43309
commit
47ea5b9086
@ -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}
|
||||
|
||||
@ -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({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{accessory}
|
||||
{label}
|
||||
</div>
|
||||
</Command.Item>)}
|
||||
</Command.Group>)}
|
||||
</Command.List>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
})),
|
||||
}]
|
||||
: [];
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user