Add sort options to cmd-k menu

This commit is contained in:
Sam Becker 2025-07-17 20:32:09 -05:00
parent c2b1be5fb4
commit c78c6fd7e0
10 changed files with 196 additions and 44 deletions

View File

@ -3,7 +3,6 @@ import SwitcherItem from '@/components/switcher/SwitcherItem';
import IconFull from '@/components/icons/IconFull';
import IconGrid from '@/components/icons/IconGrid';
import {
doesPathOfferSort,
PATH_FULL_INFERRED,
PATH_GRID_INFERRED,
} from '@/app/path';
@ -23,7 +22,7 @@ import { usePathname } from 'next/navigation';
import { KEY_COMMANDS } from '@/photo/key-commands';
import { useAppText } from '@/i18n/state/client';
import IconSort from '@/components/icons/IconSort';
import { getSortConfigFromPath } from '@/photo/sort/path';
import { getSortStateFromPath } from '@/photo/sort/path';
import { motion } from 'framer-motion';
import SortMenu from '@/photo/sort/SortMenu';
import { SWR_KEYS } from '@/swr';
@ -53,10 +52,11 @@ export default function AppViewSwitcher({
invalidateSwr,
} = useAppState();
const sortConfig = useMemo(() => getSortConfigFromPath(pathname), [pathname]);
const sortConfig = useMemo(() => getSortStateFromPath(pathname), [pathname]);
const {
sortBy,
doesPathOfferSort,
isSortedByDefault,
isAscending,
pathGrid,
@ -66,7 +66,7 @@ export default function AppViewSwitcher({
const showSortControl =
NAV_SORT_CONTROL !== 'none' &&
doesPathOfferSort(pathname);
doesPathOfferSort;
const hasLoadedRef = useRef(false);
useEffect(() => {
@ -194,7 +194,7 @@ export default function AppViewSwitcher({
/>}
tooltip={{
...!isSortMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: 'Sort',
content: appText.sort.sort,
},
}}
width="narrow"
@ -210,11 +210,11 @@ export default function AppViewSwitcher({
sort={isAscending ? 'asc' : 'desc'}
className="translate-x-[0.5px] translate-y-[1px]"
/>}
tooltip={{
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: isAscending
? appText.sort.newest
: appText.sort.oldest,
}}
? appText.sort.viewNewest
: appText.sort.viewOldest,
}}}
width="narrow"
noPadding
/>}

View File

@ -40,7 +40,7 @@ import Spinner from '../components/Spinner';
import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { BiDesktop, BiLockAlt, BiMoon, BiSun } from 'react-icons/bi';
import { IoInvertModeSharp } from 'react-icons/io5';
import { IoClose, IoInvertModeSharp } from 'react-icons/io5';
import { useAppState } from '@/app/AppState';
import { searchPhotosAction } from '@/photo/actions';
import { RiToolsFill } from 'react-icons/ri';
@ -86,10 +86,12 @@ import IconFavs from '@/components/icons/IconFavs';
import { useAppText } from '@/i18n/state/client';
import LoaderButton from '@/components/primitives/LoaderButton';
import IconRecents from '@/components/icons/IconRecents';
import { CgFileDocument } from 'react-icons/cg';
import { CgClose, CgFileDocument } from 'react-icons/cg';
import { FaRegUserCircle } from 'react-icons/fa';
import { formatDistanceToNow } from 'date-fns';
import IconCheck from '@/components/icons/IconCheck';
import { getSortStateFromPath } from '@/photo/sort/path';
import IconSort from '@/components/icons/IconSort';
const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@ -116,6 +118,11 @@ type CommandKSection = {
items: CommandKItem[]
}
const renderCheck = (isChecked?: boolean) =>
isChecked
? <IconCheck size={12} className="translate-y-[-0.5px]" />
: undefined;
const renderToggle = (
label: string,
onToggle?: Dispatch<SetStateAction<boolean>>,
@ -123,7 +130,7 @@ const renderToggle = (
): CommandKItem => ({
label: `Toggle ${label}`,
action: () => onToggle?.(prev => !prev),
annotation: isEnabled ? <IconCheck size={12} /> : undefined,
annotation: renderCheck(isEnabled),
});
export default function CommandKClient({
@ -172,6 +179,19 @@ export default function CommandKClient({
setShouldDebugRecipeOverlays,
} = useAppState();
const {
doesPathOfferSort,
isSortedByDefault,
pathNewest,
pathOldest,
pathTakenAt,
pathUploadedAt,
pathClearSort,
isAscending,
isTakenAt,
isUploadedAt,
} = useMemo(() => getSortStateFromPath(pathname), [pathname]);
const appText = useAppText();
const isOpenRef = useRef(isOpen);
@ -209,7 +229,7 @@ export default function CommandKClient({
}, [isWaiting, setIsOpen]);
// Raw query values
const [queryLiveRaw, setQueryLive] = useState('');
const [queryLiveRaw, setQueryLiveRaw] = useState('');
const [queryDebouncedRaw] =
useDebounce(queryLiveRaw, 500, { trailing: true });
@ -289,7 +309,7 @@ export default function CommandKClient({
useEffect(() => {
if (!isOpen) {
setQueryLive('');
setQueryLiveRaw('');
setQueriedSections([]);
setIsLoading(false);
}
@ -305,6 +325,7 @@ export default function CommandKClient({
return count ? { count, subhead } : undefined;
}, [recent, appText]);
// Years only accessible by search
const years = useMemo(() =>
_years.filter(({ year }) => queryLive && year.includes(queryLive))
, [_years, queryLive]);
@ -503,6 +524,40 @@ export default function CommandKClient({
});
}
const sortItems = [{
label: appText.sort.newestFirst,
path: pathNewest,
annotation: renderCheck(!isAscending),
}, {
label: appText.sort.oldestFirst,
path: pathOldest,
annotation: renderCheck(isAscending),
}, {
label: appText.sort.byTakenAt,
path: pathTakenAt,
annotation: renderCheck(isTakenAt),
}, {
label: appText.sort.byUploadedAt,
path: pathUploadedAt,
annotation: renderCheck(isUploadedAt),
}];
if (!isSortedByDefault) {
sortItems.push({
label: appText.sort.clearSort,
path: pathClearSort,
annotation: <CgClose />,
});
}
const sortSection: CommandKSection = {
heading: appText.sort.sort,
accessory: <IconSort size={14} className="translate-x-[0.5px]" />,
items: doesPathOfferSort
? sortItems
: [],
};
const pageFull: CommandKItem = {
label: GRID_HOMEPAGE_ENABLED
? appText.nav.full
@ -522,13 +577,13 @@ export default function CommandKClient({
: [pageFull, pageGrid];
const sectionPages: CommandKSection = {
heading: 'Pages',
heading: appText.cmdk.pages,
accessory: <CgFileDocument size={14} className="translate-x-[-0.5px]" />,
items: pageItems,
};
const adminSection: CommandKSection = {
heading: 'Admin',
heading: appText.nav.admin,
accessory: <FaRegUserCircle
size={13}
className="translate-x-[-0.5px] translate-y-[0.5px]"
@ -649,8 +704,9 @@ export default function CommandKClient({
)}>
<Command.Input
ref={refInput}
onChangeCapture={(e) => {
setQueryLive(e.currentTarget.value);
value={queryLiveRaw}
onValueChange={value => {
setQueryLiveRaw(value);
updateMask();
}}
className={clsx(
@ -667,20 +723,30 @@ export default function CommandKClient({
disabled={isPending}
/>
{isLoading && !isPending
? <span className="mr-1 translate-y-[2px]">
<Spinner size={16} />
? <span className="translate-y-[2px]">
<Spinner size={16} className="-mr-1" />
</span>
: <span className="max-sm:hidden">
<LoaderButton
className={clsx(
'h-auto! px-1.5 py-1 -mr-1.5',
'h-auto! py-1 -mr-2',
'border-medium shadow-none',
queryLiveRaw ? 'px-1' : 'px-1.5',
'text-[12px]',
'text-gray-400/90 dark:text-gray-700',
)}
onClick={() => setIsOpen?.(false)}
onClick={() => {
if (queryLiveRaw) {
setQueryLiveRaw('');
updateMask();
} else {
setIsOpen?.(false);
}
}}
>
ESC
{queryLiveRaw
? <IoClose size={17} className="text-dim" />
: 'ESC'}
</LoaderButton>
</span>}
</div>
@ -698,6 +764,7 @@ export default function CommandKClient({
</Command.Empty>
{queriedSections
.concat(categorySections)
.concat(sortSection)
.concat(sectionPages)
.concat(adminSection)
.concat(clientSections)

View File

@ -55,13 +55,25 @@ export const TEXT: I18N = {
nextShort: 'পরবর্তী',
},
sort: {
newest: 'নতুনতম দেখুন',
oldest: 'পুরাতনতম দেখুন',
sort: 'সাজান',
newest: 'নতুনতম',
oldest: 'পুরাতনতম',
newestFirst: 'নতুনতম প্রথমে',
oldestFirst: 'পুরাতনতম প্রথমে',
viewNewest: 'নতুনতম দেখুন',
viewOldest: 'পুরাতনতম দেখুন',
takenAt: 'তোলা হয়েছে',
byTakenAt: 'তোলার সময় অনুযায়ী',
uploadedAt: 'আপলোড হয়েছে',
byUploadedAt: 'আপলোডের সময় অনুযায়ী',
uploadedAtShort: 'আপলোড',
clearSort: 'সাজানো মুছুন',
},
cmdk: {
placeholder: 'ছবি, ভিউ, সেটিংস অনুসন্ধান করুন ...',
searching: 'অনুসন্ধান হচ্ছে ...',
noResults: 'কোনো ফলাফল পাওয়া যায়নি',
pages: 'পৃষ্ঠাসমূহ',
},
tooltip: {
'35mm': '৩৫মিমি সমতুল্য',

View File

@ -53,13 +53,25 @@ export const TEXT = {
nextShort: 'Next',
},
sort: {
newest: 'View newest',
oldest: 'View oldest',
sort: 'Sort',
newest: 'Newest',
oldest: 'Oldest',
newestFirst: 'Newest first',
oldestFirst: 'Oldest first',
viewNewest: 'View newest',
viewOldest: 'View oldest',
takenAt: 'Taken at',
byTakenAt: 'By taken at',
uploadedAt: 'Uploaded at',
byUploadedAt: 'By uploaded at',
uploadedAtShort: 'Uploaded',
clearSort: 'Clear sort',
},
cmdk: {
placeholder: 'Search photos, views, settings ...',
searching: 'Searching ...',
noResults: 'No results found',
pages: 'Pages',
},
tooltip: {
'35mm': '35mm Equivalent',

View File

@ -54,13 +54,25 @@ export const TEXT: I18N = {
nextShort: 'Brkt',
},
sort: {
newest: 'Lihat terbaru',
oldest: 'Lihat terlama',
sort: 'Urutkan',
newest: 'Terbaru',
oldest: 'Terlama',
newestFirst: 'Terbaru dulu',
oldestFirst: 'Terlama dulu',
viewNewest: 'Lihat terbaru',
viewOldest: 'Lihat terlama',
takenAt: 'Diambil pada',
byTakenAt: 'Berdasarkan waktu pengambilan',
uploadedAt: 'Diunggah pada',
byUploadedAt: 'Berdasarkan waktu unggahan',
uploadedAtShort: 'Diunggah',
clearSort: 'Hapus pengurutan',
},
cmdk: {
placeholder: 'Cari foto, tampilan, pengaturan ...',
searching: 'Mencari ...',
noResults: 'Tidak ada hasil ditemukan',
pages: 'Halaman',
},
tooltip: {
'35mm': 'Setara 35mm',

View File

@ -54,13 +54,25 @@ export const TEXT: I18N = {
nextShort: 'Próx',
},
sort: {
newest: 'Ver mais recentes',
oldest: 'Ver mais antigas',
sort: 'Ordenar',
newest: 'Mais recentes',
oldest: 'Mais antigas',
newestFirst: 'Mais recentes primeiro',
oldestFirst: 'Mais antigas primeiro',
viewNewest: 'Ver mais recentes',
viewOldest: 'Ver mais antigas',
takenAt: 'Tirado em',
byTakenAt: 'Por data de captura',
uploadedAt: 'Enviado em',
byUploadedAt: 'Por data de envio',
uploadedAtShort: 'Enviado',
clearSort: 'Limpar ordenação',
},
cmdk: {
placeholder: 'Pesquisar fotos, visualizações, configurações ...',
searching: 'Pesquisando ...',
noResults: 'Nenhum resultado encontrado',
pages: 'Páginas',
},
tooltip: {
'35mm': 'Equivalente em 35mm',

View File

@ -54,13 +54,25 @@ export const TEXT: I18N = {
nextShort: 'Próx',
},
sort: {
newest: 'Ver mais recentes',
oldest: 'Ver mais antigas',
sort: 'Ordenar',
newest: 'Mais recentes',
oldest: 'Mais antigas',
newestFirst: 'Mais recentes primeiro',
oldestFirst: 'Mais antigas primeiro',
viewNewest: 'Ver mais recentes',
viewOldest: 'Ver mais antigas',
takenAt: 'Tirado em',
byTakenAt: 'Por data de captura',
uploadedAt: 'Enviado em',
byUploadedAt: 'Por data de envio',
uploadedAtShort: 'Enviado',
clearSort: 'Limpar ordenação',
},
cmdk: {
placeholder: 'Pesquisar fotografias, visualizações, configurações ...',
searching: 'A pesquisar ...',
noResults: 'Nenhum resultado encontrado',
pages: 'Páginas',
},
tooltip: {
'35mm': 'Equivalente em 35mm',

View File

@ -54,13 +54,25 @@ export const TEXT: I18N = {
nextShort: '下一页',
},
sort: {
newest: '查看最新',
oldest: '查看最旧',
sort: '排序',
newest: '最新',
oldest: '最旧',
newestFirst: '最新优先',
oldestFirst: '最旧优先',
viewNewest: '查看最新',
viewOldest: '查看最旧',
takenAt: '拍摄时间',
byTakenAt: '按拍摄时间',
uploadedAt: '上传时间',
byUploadedAt: '按上传时间',
uploadedAtShort: '上传',
clearSort: '清除排序',
},
cmdk: {
placeholder: '搜索照片、视图、设置...',
searching: '搜索中...',
noResults: '未找到结果',
pages: '页面',
},
tooltip: {
'35mm': '35mm 等效焦距',

View File

@ -1,8 +1,9 @@
import IconSort from '@/components/icons/IconSort';
import SwitcherItemMenu from '@/components/switcher/SwitcherItemMenu';
import { getSortConfigFromPath } from './path';
import { getSortStateFromPath } from './path';
import IconCheck from '@/components/icons/IconCheck';
import { clsx } from 'clsx/lite';
import { useAppText } from '@/i18n/state/client';
export default function SortMenu({
isOpen,
@ -17,7 +18,9 @@ export default function SortMenu({
}: {
isOpen?: boolean
setIsOpen?: (isOpen: boolean) => void
} & ReturnType<typeof getSortConfigFromPath>) {
} & ReturnType<typeof getSortStateFromPath>) {
const appText = useAppText();
const renderIcon = (isChecked: boolean) => isChecked
? <IconCheck size={13} className="translate-x-[-2px]" />
: <span />;
@ -38,21 +41,21 @@ export default function SortMenu({
/>}
sections={[{
items: [{
...renderLabel('Newest', !isAscending),
...renderLabel(appText.sort.newest, !isAscending),
icon: renderIcon(!isAscending),
href: pathNewest,
}, {
...renderLabel('Oldest', isAscending),
...renderLabel(appText.sort.oldest, isAscending),
icon: renderIcon(isAscending),
href: pathOldest,
}],
}, {
items: [{
...renderLabel('Taken at', isTakenAt),
...renderLabel(appText.sort.takenAt, isTakenAt),
icon: renderIcon(isTakenAt),
href: pathTakenAt,
}, {
...renderLabel('Uploaded', isUploadedAt),
...renderLabel(appText.sort.uploadedAtShort, isUploadedAt),
icon: renderIcon(isUploadedAt),
href: pathUploadedAt,
}],

View File

@ -2,6 +2,7 @@
// to avoid circular dependencies
import {
doesPathOfferSort as _doesPathOfferSort,
PARAM_SORT_ORDER_NEWEST,
PARAM_SORT_ORDER_OLDEST,
PARAM_SORT_TYPE_TAKEN_AT,
@ -16,7 +17,7 @@ import {
USER_DEFAULT_SORT_WITH_PRIORITY,
} from '@/app/config';
export const getSortByComponents = (sortBy: SortBy): {
const getSortByComponents = (sortBy: SortBy): {
sortType: string
sortOrder: string
} => {
@ -40,7 +41,7 @@ export const getSortByComponents = (sortBy: SortBy): {
}
};
const {
export const {
sortType: DEFAULT_SORT_TYPE,
sortOrder: DEFAULT_SORT_ORDER,
} = getSortByComponents(USER_DEFAULT_SORT_BY);
@ -95,7 +96,9 @@ const getPathSortComponents = (pathname: string) => {
};
};
export const getSortConfigFromPath = (pathname: string) => {
export const getSortStateFromPath = (pathname: string) => {
const doesPathOfferSort = _doesPathOfferSort(pathname);
const {
gridOrFull: _gridOrFull,
sortType,
@ -153,8 +156,14 @@ export const getSortConfigFromPath = (pathname: string) => {
const pathUploadedAt =
getPath({ sortType: PARAM_SORT_TYPE_UPLOADED_AT, sortOrder });
// Sort clear
const pathClearSort = _gridOrFull === 'grid'
? PATH_GRID_INFERRED
: PATH_FULL_INFERRED;
return {
sortBy,
doesPathOfferSort,
isSortedByDefault,
isAscending,
isTakenAt,
@ -165,6 +174,7 @@ export const getSortConfigFromPath = (pathname: string) => {
pathOldest,
pathTakenAt,
pathUploadedAt,
pathClearSort,
pathSortToggle,
};
};