Files
papra/apps/papra-client/src/modules/command-palette/command-palette.provider.tsx
2025-10-13 17:03:25 +02:00

178 lines
5.6 KiB
TypeScript

import type { Accessor, ParentComponent } from 'solid-js';
import type { Document } from '../documents/documents.types';
import { safely } from '@corentinth/chisels';
import { useNavigate, useParams } from '@solidjs/router';
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
import { getDocumentIcon } from '../documents/document.models';
import { searchDocuments } from '../documents/documents.services';
import { useI18n } from '../i18n/i18n.provider';
import { cn } from '../shared/style/cn';
import { debounce } from '../shared/utils/timing';
import { useThemeStore } from '../theme/theme.store';
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
const CommandPaletteContext = createContext<{
getIsCommandPaletteOpen: Accessor<boolean>;
openCommandPalette: () => void;
closeCommandPalette: () => void;
}>();
export function useCommandPalette() {
const context = useContext(CommandPaletteContext);
if (!context) {
throw new Error('CommandPalette context not found');
}
return context;
}
export const CommandPaletteProvider: ParentComponent = (props) => {
const [getIsCommandPaletteOpen, setIsCommandPaletteOpen] = createSignal(false);
const [getMatchingDocuments, setMatchingDocuments] = createSignal<Document[]>([]);
const [getSearchQuery, setSearchQuery] = createSignal('');
const [getIsLoading, setIsLoading] = createSignal(false);
const params = useParams();
const { t } = useI18n();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setIsCommandPaletteOpen(true);
}
};
onMount(() => {
document.addEventListener('keydown', handleKeyDown);
});
onCleanup(() => {
document.removeEventListener('keydown', handleKeyDown);
});
const navigate = useNavigate();
const { setColorMode } = useThemeStore();
const searchDocs = debounce(async ({ searchQuery }: { searchQuery: string }) => {
const [result] = await safely(searchDocuments({ searchQuery, organizationId: params.organizationId, pageIndex: 0, pageSize: 5 }));
const { documents = [] } = result ?? {};
setMatchingDocuments(documents);
setIsLoading(false);
}, 300);
createEffect(on(
getSearchQuery,
(searchQuery) => {
setMatchingDocuments([]);
if (searchQuery.length > 1) {
setIsLoading(true);
searchDocs({ searchQuery });
}
},
));
createEffect(on(
getIsCommandPaletteOpen,
(isCommandPaletteOpen) => {
if (isCommandPaletteOpen) {
setMatchingDocuments([]);
}
},
));
const getCommandData = (): {
label: string;
forceMatch?: boolean;
options: { label: string; icon: string; action: () => void; forceMatch?: boolean }[];
}[] => [
{
label: t('command-palette.sections.documents'),
forceMatch: true,
options: getMatchingDocuments().map(document => ({
label: document.name,
icon: getDocumentIcon({ document }),
action: () => navigate(`/organizations/${params.organizationId}/documents/${document.id}`),
forceMatch: true,
})),
},
{
label: t('command-palette.sections.theme'),
options: [
{
label: t('layout.theme.light'),
icon: 'i-tabler-sun',
action: () => setColorMode({ mode: 'light' }),
},
{
label: t('layout.theme.dark'),
icon: 'i-tabler-moon',
action: () => setColorMode({ mode: 'dark' }),
},
{
label: t('layout.theme.system'),
icon: 'i-tabler-device-laptop',
action: () => setColorMode({ mode: 'system' }),
},
],
},
];
const onCommandSelect = ({ action }: { action: () => void }) => {
action();
setIsCommandPaletteOpen(false);
};
return (
<CommandPaletteContext.Provider value={{
getIsCommandPaletteOpen,
openCommandPalette: () => setIsCommandPaletteOpen(true),
closeCommandPalette: () => setIsCommandPaletteOpen(false),
}}
>
<CommandDialog
class="rounded-lg border shadow-md"
open={getIsCommandPaletteOpen()}
onOpenChange={setIsCommandPaletteOpen}
>
<CommandInput onValueChange={setSearchQuery} placeholder={t('command-palette.search.placeholder')} />
<CommandList>
<Show when={getIsLoading()}>
<CommandLoading>
<div class="i-tabler-loader-2 size-6 animate-spin text-muted-foreground mx-auto"></div>
</CommandLoading>
</Show>
<Show when={!getIsLoading()}>
<Show when={getMatchingDocuments().length === 0}>
<CommandEmpty>
{t('command-palette.no-results')}
</CommandEmpty>
</Show>
<For each={getCommandData().filter(section => section.options.length > 0)}>
{section => (
<CommandGroup heading={section.label} forceMount={section.forceMatch ?? false}>
<For each={section.options}>
{item => (
<CommandItem onSelect={() => onCommandSelect(item)} forceMount={item.forceMatch ?? false}>
<span class={cn('mr-2 ml-2 size-4 text-primary', item.icon)} />
<span>{item.label}</span>
</CommandItem>
)}
</For>
</CommandGroup>
)}
</For>
</Show>
</CommandList>
</CommandDialog>
{props.children}
</CommandPaletteContext.Provider>
);
};