melty grid command
This commit is contained in:
parent
22a4d2a7f3
commit
dc0a92d963
8 changed files with 525 additions and 185 deletions
|
|
@ -55,6 +55,7 @@ TODO: add instructions
|
||||||
- [x] kbd powered popover model picker
|
- [x] kbd powered popover model picker
|
||||||
- [x] autosize
|
- [x] autosize
|
||||||
- [x] AbortController for message generation
|
- [x] AbortController for message generation
|
||||||
|
- [ ] Per route msg persistance
|
||||||
|
|
||||||
### Extra
|
### Extra
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,6 @@
|
||||||
"@fontsource-variable/nunito-sans": "^5.2.6",
|
"@fontsource-variable/nunito-sans": "^5.2.6",
|
||||||
"@fontsource-variable/open-sans": "^5.2.6",
|
"@fontsource-variable/open-sans": "^5.2.6",
|
||||||
"better-auth": "^1.2.9",
|
"better-auth": "^1.2.9",
|
||||||
"bits-ui": "^2.8.0",
|
|
||||||
"convex-helpers": "^0.1.94",
|
"convex-helpers": "^0.1.94",
|
||||||
"markdown-it-async": "^2.2.0",
|
"markdown-it-async": "^2.2.0",
|
||||||
"openai": "^5.3.0",
|
"openai": "^5.3.0",
|
||||||
|
|
|
||||||
54
pnpm-lock.yaml
generated
54
pnpm-lock.yaml
generated
|
|
@ -35,9 +35,6 @@ importers:
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.2.9
|
version: 1.2.9
|
||||||
bits-ui:
|
|
||||||
specifier: ^2.8.0
|
|
||||||
version: 2.8.0(@internationalized/date@3.8.2)(svelte@5.34.1)
|
|
||||||
convex-helpers:
|
convex-helpers:
|
||||||
specifier: ^0.1.94
|
specifier: ^0.1.94
|
||||||
version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64)
|
version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64)
|
||||||
|
|
@ -665,9 +662,6 @@ packages:
|
||||||
'@iconify/utils@2.3.0':
|
'@iconify/utils@2.3.0':
|
||||||
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
|
||||||
|
|
||||||
'@internationalized/date@3.8.2':
|
|
||||||
resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==}
|
|
||||||
|
|
||||||
'@isaacs/fs-minipass@4.0.1':
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
@ -912,9 +906,6 @@ packages:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
vite: ^6.0.0
|
vite: ^6.0.0
|
||||||
|
|
||||||
'@swc/helpers@0.5.17':
|
|
||||||
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
|
||||||
|
|
||||||
'@tailwindcss/node@4.1.10':
|
'@tailwindcss/node@4.1.10':
|
||||||
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
|
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
|
||||||
|
|
||||||
|
|
@ -1232,13 +1223,6 @@ packages:
|
||||||
better-call@1.0.9:
|
better-call@1.0.9:
|
||||||
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
resolution: {integrity: sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g==}
|
||||||
|
|
||||||
bits-ui@2.8.0:
|
|
||||||
resolution: {integrity: sha512-WiTZcCbYLm4Cx6/67NqXVSD0BkfNmdX8Abs84HpIaplX/wRRbg8tkMtJYlLw7mepgGvwGR3enLi6tFkcHU3JXA==}
|
|
||||||
engines: {node: '>=20', pnpm: '>=8.7.0'}
|
|
||||||
peerDependencies:
|
|
||||||
'@internationalized/date': ^3.8.1
|
|
||||||
svelte: ^5.33.0
|
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||||
|
|
||||||
|
|
@ -2397,12 +2381,6 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
svelte-toolbelt@0.9.2:
|
|
||||||
resolution: {integrity: sha512-REb1cENGnFbhNSmIdCb1SDIpjEa3n1kXhNVHqGNEesjmPX3bG87gUZiCG8cqOt9AAarqzTzOtI2jEEWr/ZbHwA==}
|
|
||||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
|
||||||
peerDependencies:
|
|
||||||
svelte: ^5.30.2
|
|
||||||
|
|
||||||
svelte@5.34.1:
|
svelte@5.34.1:
|
||||||
resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==}
|
resolution: {integrity: sha512-jWNnN2hZFNtnzKPptCcJHBWrD9CtbHPDwIRIODufOYaWkR0kLmAIlM384lMt4ucwuIRX4hCJwD2D8ZtEcGJQ0Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -2410,9 +2388,6 @@ packages:
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
tabbable@6.2.0:
|
|
||||||
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
|
||||||
|
|
||||||
tailwind-merge@3.0.2:
|
tailwind-merge@3.0.2:
|
||||||
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
|
||||||
|
|
||||||
|
|
@ -3086,10 +3061,6 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@internationalized/date@3.8.2':
|
|
||||||
dependencies:
|
|
||||||
'@swc/helpers': 0.5.17
|
|
||||||
|
|
||||||
'@isaacs/fs-minipass@4.0.1':
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 7.1.2
|
minipass: 7.1.2
|
||||||
|
|
@ -3335,10 +3306,6 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@swc/helpers@0.5.17':
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@tailwindcss/node@4.1.10':
|
'@tailwindcss/node@4.1.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
|
|
@ -3684,18 +3651,6 @@ snapshots:
|
||||||
set-cookie-parser: 2.7.1
|
set-cookie-parser: 2.7.1
|
||||||
uncrypto: 0.1.3
|
uncrypto: 0.1.3
|
||||||
|
|
||||||
bits-ui@2.8.0(@internationalized/date@3.8.2)(svelte@5.34.1):
|
|
||||||
dependencies:
|
|
||||||
'@floating-ui/core': 1.7.1
|
|
||||||
'@floating-ui/dom': 1.7.1
|
|
||||||
'@internationalized/date': 3.8.2
|
|
||||||
css.escape: 1.5.1
|
|
||||||
esm-env: 1.2.2
|
|
||||||
runed: 0.28.0(svelte@5.34.1)
|
|
||||||
svelte: 5.34.1
|
|
||||||
svelte-toolbelt: 0.9.2(svelte@5.34.1)
|
|
||||||
tabbable: 6.2.0
|
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
@ -4789,13 +4744,6 @@ snapshots:
|
||||||
style-to-object: 1.0.9
|
style-to-object: 1.0.9
|
||||||
svelte: 5.34.1
|
svelte: 5.34.1
|
||||||
|
|
||||||
svelte-toolbelt@0.9.2(svelte@5.34.1):
|
|
||||||
dependencies:
|
|
||||||
clsx: 2.1.1
|
|
||||||
runed: 0.28.0(svelte@5.34.1)
|
|
||||||
style-to-object: 1.0.9
|
|
||||||
svelte: 5.34.1
|
|
||||||
|
|
||||||
svelte@5.34.1:
|
svelte@5.34.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
|
|
@ -4815,8 +4763,6 @@ snapshots:
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
tabbable@6.2.0: {}
|
|
||||||
|
|
||||||
tailwind-merge@3.0.2: {}
|
tailwind-merge@3.0.2: {}
|
||||||
|
|
||||||
tailwind-merge@3.3.1: {}
|
tailwind-merge@3.3.1: {}
|
||||||
|
|
|
||||||
194
src/lib/builders/grid-command.svelte.ts
Normal file
194
src/lib/builders/grid-command.svelte.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { getNextMatrixItem, type Direction } from '$lib/utils/array';
|
||||||
|
import { dataAttr } from '$lib/utils/attribute';
|
||||||
|
import type { MaybeGetter } from 'melt';
|
||||||
|
import { extract } from 'runed';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { createAttachmentKey } from 'svelte/attachments';
|
||||||
|
import type { HTMLAttributes, HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
export type GridCommandProps = {
|
||||||
|
columns: MaybeGetter<number>;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
};
|
||||||
|
export class GridCommand {
|
||||||
|
/* State */
|
||||||
|
rootEl: HTMLElement | null = null;
|
||||||
|
highlighted = $state<string>();
|
||||||
|
inputValue = $state('');
|
||||||
|
|
||||||
|
/* Props */
|
||||||
|
columns: number;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
|
||||||
|
constructor(props: GridCommandProps) {
|
||||||
|
this.columns = $derived(extract(props.columns));
|
||||||
|
this.onSelect = props.onSelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
get root() {
|
||||||
|
return {
|
||||||
|
[createAttachmentKey()]: (node) => {
|
||||||
|
this.rootEl = node;
|
||||||
|
return () => {
|
||||||
|
if (this.rootEl !== node) return;
|
||||||
|
this.rootEl = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
get group() {
|
||||||
|
return {
|
||||||
|
'data-thom-grid-command-group': '',
|
||||||
|
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
get groupHeading() {
|
||||||
|
return {
|
||||||
|
'data-thom-grid-command-group-heading': '',
|
||||||
|
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
get input() {
|
||||||
|
return {
|
||||||
|
value: this.inputValue,
|
||||||
|
oninput: async (e) => {
|
||||||
|
this.inputValue = e.currentTarget.value;
|
||||||
|
await tick();
|
||||||
|
const items = this.getItems();
|
||||||
|
const highlightedEl = items.find((item) => item.dataset.value === this.highlighted);
|
||||||
|
if (!highlightedEl) {
|
||||||
|
const firstItem = items[0];
|
||||||
|
if (!firstItem) this.highlighted = undefined;
|
||||||
|
else this.highlighted = firstItem.dataset.value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onkeydown: (e) => {
|
||||||
|
const rows = this.getRows();
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const row = rows.findIndex((row) => {
|
||||||
|
return !!row.find((item) => {
|
||||||
|
return this.highlighted === item.dataset.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row === -1) return;
|
||||||
|
const col = rows[row]!.findIndex((item) => this.highlighted === item.dataset.value);
|
||||||
|
|
||||||
|
const dirMap: Record<string, Direction> = {
|
||||||
|
ArrowUp: 'up',
|
||||||
|
ArrowDown: 'down',
|
||||||
|
ArrowLeft: 'left',
|
||||||
|
ArrowRight: 'right',
|
||||||
|
};
|
||||||
|
const dir = dirMap[e.key];
|
||||||
|
if (dir) {
|
||||||
|
const next = getNextMatrixItem(rows, row, col, dir);
|
||||||
|
if (next) {
|
||||||
|
this.highlighted = next.dataset.value;
|
||||||
|
this.scrollToHighlighted();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && this.highlighted) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onSelect(this.highlighted);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as const satisfies HTMLInputAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToHighlighted() {
|
||||||
|
const groups = this.getGroups();
|
||||||
|
const items = groups.flatMap((group) => group);
|
||||||
|
const highlightedEl = items.find((item) => item.dataset.value === this.highlighted);
|
||||||
|
if (!highlightedEl) return;
|
||||||
|
|
||||||
|
const nextGroupIdx = groups.findIndex((group) => group.includes(highlightedEl));
|
||||||
|
const nextGroup = groups[nextGroupIdx]!;
|
||||||
|
const nextItemIdx = nextGroup.findIndex((item) => item === highlightedEl);
|
||||||
|
const nextItemRow = Math.floor(nextItemIdx / this.columns);
|
||||||
|
if (nextItemRow === 0) {
|
||||||
|
const nextGroupEl = Array.from(
|
||||||
|
this.rootEl!.querySelectorAll('[data-thom-grid-command-group]')
|
||||||
|
)[nextGroupIdx];
|
||||||
|
const nextGroupHeadingEl = nextGroupEl?.querySelector(
|
||||||
|
'[data-thom-grid-command-group-heading]'
|
||||||
|
);
|
||||||
|
if (nextGroupHeadingEl) {
|
||||||
|
nextGroupHeadingEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
highlightedEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
getRows() {
|
||||||
|
const groups = this.getGroups();
|
||||||
|
if (groups.length === 0) return [];
|
||||||
|
|
||||||
|
// split groups into rows
|
||||||
|
const rows: Array<HTMLElement[]> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < groups.length; i++) {
|
||||||
|
const items = [...groups[i]!];
|
||||||
|
|
||||||
|
let row: HTMLElement[] = [];
|
||||||
|
while (items.length > 0) {
|
||||||
|
const item = items.shift()!;
|
||||||
|
row.push(item);
|
||||||
|
if (row.length === this.columns) {
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.length > 0) {
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroups() {
|
||||||
|
if (!this.rootEl) return [];
|
||||||
|
|
||||||
|
const groups = Array.from(this.rootEl.querySelectorAll('[data-thom-grid-command-group]'));
|
||||||
|
|
||||||
|
const result: Array<HTMLElement[]> = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const groupItems = Array.from(
|
||||||
|
group.querySelectorAll('[data-thom-grid-command-item]')
|
||||||
|
) as HTMLElement[];
|
||||||
|
|
||||||
|
result.push(groupItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getItems() {
|
||||||
|
return this.getRows().flatMap((row) => row);
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(value: string) {
|
||||||
|
return {
|
||||||
|
'data-thom-grid-command-item': '',
|
||||||
|
'data-highlighted': dataAttr(value === this.highlighted),
|
||||||
|
'data-value': dataAttr(value),
|
||||||
|
onmouseover: () => {
|
||||||
|
this.highlighted = value;
|
||||||
|
},
|
||||||
|
onclick: () => {
|
||||||
|
this.onSelect(value);
|
||||||
|
},
|
||||||
|
[createAttachmentKey()]: () => {
|
||||||
|
if (!this.highlighted) {
|
||||||
|
this.highlighted = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as const satisfies HTMLAttributes<HTMLElement>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -126,3 +126,107 @@ export function* iterate<T>(array: T[]): Generator<IterateReturn<T>> {
|
||||||
export function last<T>(arr: T[]): T | undefined {
|
export function last<T>(arr: T[]): T | undefined {
|
||||||
return arr[arr.length - 1];
|
return arr[arr.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the possible directions for movement within the matrix.
|
||||||
|
*/
|
||||||
|
export type Direction = 'up' | 'down' | 'left' | 'right';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the next item in a matrix based on a current position and a direction.
|
||||||
|
* This function is designed to work with "jagged" arrays (rows can have different lengths).
|
||||||
|
*
|
||||||
|
* @template T The type of items stored in the matrix.
|
||||||
|
* @param matrix The matrix (an array of arrays) where rows can have varying lengths.
|
||||||
|
* @param currentRow The 0-based index of the current row.
|
||||||
|
* @param currentCol The 0-based index of the current column.
|
||||||
|
* @param direction The direction to move ('up', 'down', 'left', 'right').
|
||||||
|
* @returns The item at the next position, or `undefined` if the current position is invalid,
|
||||||
|
* the next position is out of bounds, or the matrix itself is invalid.
|
||||||
|
*/
|
||||||
|
export function getNextMatrixItem<T>(
|
||||||
|
matrix: T[][],
|
||||||
|
currentRow: number,
|
||||||
|
currentCol: number,
|
||||||
|
direction: Direction
|
||||||
|
): T | undefined {
|
||||||
|
// --- 1. Input Validation: Matrix and Current Position ---
|
||||||
|
|
||||||
|
// Ensure the matrix is a valid array and not empty
|
||||||
|
if (!matrix || !Array.isArray(matrix) || matrix.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the current row index
|
||||||
|
if (currentRow < 0 || currentRow >= matrix.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current row array to validate the current column
|
||||||
|
const currentRowArray = matrix[currentRow];
|
||||||
|
|
||||||
|
// Ensure the current row is a valid array and validate current column index
|
||||||
|
if (
|
||||||
|
!currentRowArray ||
|
||||||
|
!Array.isArray(currentRowArray) ||
|
||||||
|
currentCol < 0 ||
|
||||||
|
currentCol >= currentRowArray.length
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Calculate Tentative Next Coordinates ---
|
||||||
|
|
||||||
|
let nextRow = currentRow;
|
||||||
|
let nextCol = currentCol; // Start with the same column
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case 'up':
|
||||||
|
nextRow--;
|
||||||
|
break;
|
||||||
|
case 'down':
|
||||||
|
nextRow++;
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
nextCol--; // Column changes for horizontal movement
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
nextCol++; // Column changes for horizontal movement
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Validate and Adjust Next Coordinates Against Matrix Bounds ---
|
||||||
|
|
||||||
|
// Check if the calculated next row is within the matrix's vertical bounds
|
||||||
|
if (nextRow < 0 || nextRow >= matrix.length) {
|
||||||
|
return undefined; // Out of vertical bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the array for the target row. This is crucial for jagged arrays.
|
||||||
|
const nextRowArray = matrix[nextRow];
|
||||||
|
|
||||||
|
// Ensure the target row is a valid array before checking its length
|
||||||
|
if (!nextRowArray || !Array.isArray(nextRowArray)) {
|
||||||
|
return undefined; // The row itself is malformed or non-existent
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NEW LOGIC: Adjust nextCol for vertical movements if it's out of bounds ---
|
||||||
|
if (direction === 'up' || direction === 'down') {
|
||||||
|
// If the tentative nextCol is beyond the length of the target row,
|
||||||
|
// clamp it to the last valid index of that row.
|
||||||
|
// If nextRowArray.length is 0, then nextRowArray.length - 1 is -1.
|
||||||
|
// Math.min(currentCol, -1) would result in -1, which will then correctly
|
||||||
|
// be caught by the next `if (nextCol < 0)` check.
|
||||||
|
nextCol = Math.min(nextCol, nextRowArray.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the calculated next column is within the target row's horizontal bounds
|
||||||
|
// This applies to ALL directions, including after potential clamping for up/down.
|
||||||
|
if (nextCol < 0 || nextCol >= nextRowArray.length) {
|
||||||
|
return undefined; // Out of horizontal bounds for the specific nextRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Return the Item ---
|
||||||
|
|
||||||
|
return nextRowArray[nextCol];
|
||||||
|
}
|
||||||
|
|
|
||||||
50
src/lib/utils/attribute.ts
Normal file
50
src/lib/utils/attribute.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import type { ObjToString } from './types';
|
||||||
|
|
||||||
|
type DataReturn<T> = T extends true ? '' : T extends false ? undefined : T;
|
||||||
|
|
||||||
|
export function dataAttr<T>(value: T): DataReturn<T> {
|
||||||
|
return (value === true ? '' : value === false ? undefined : value) as DataReturn<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisabledReturn<T> = T extends true ? true : undefined;
|
||||||
|
export function disabledAttr<V extends boolean>(value?: V): DisabledReturn<V> {
|
||||||
|
return (value === true ? true : undefined) as DisabledReturn<V>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function styleAttr<T extends Record<string, string>>(value: T): ObjToString<T> {
|
||||||
|
return Object.entries(value)
|
||||||
|
.map(([key, value]) => `${key}: ${value};`)
|
||||||
|
.join(' ') as ObjToString<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique, safe ID attribute from a string using hash-based approach
|
||||||
|
*/
|
||||||
|
export function idAttr(value: string): string {
|
||||||
|
if (!value || typeof value !== 'string') {
|
||||||
|
return 'id-empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple but effective hash function (djb2 algorithm)
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
hash = (hash << 5) + hash + value.charCodeAt(i);
|
||||||
|
hash = hash & hash; // Convert to 32-bit integer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to positive number and then to base36 for compactness
|
||||||
|
const hashStr = Math.abs(hash).toString(36);
|
||||||
|
|
||||||
|
// Create readable prefix from original string
|
||||||
|
let prefix = value
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
// Ensure prefix starts with a letter
|
||||||
|
if (!prefix || /^[0-9]/.test(prefix)) {
|
||||||
|
prefix = 'id' + prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}-${hashStr}`;
|
||||||
|
}
|
||||||
47
src/lib/utils/types.ts
Normal file
47
src/lib/utils/types.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
export type ValueOf<T> = T[keyof T];
|
||||||
|
|
||||||
|
/* God forgive me */
|
||||||
|
export type Expand<T> = T extends infer O ? O : never;
|
||||||
|
|
||||||
|
export type ExpandDeep<T> = T extends object
|
||||||
|
? T extends infer O
|
||||||
|
? { [K in keyof O]: ExpandDeep<O[K]> }
|
||||||
|
: never
|
||||||
|
: T;
|
||||||
|
|
||||||
|
type What<
|
||||||
|
T extends Record<string, string | number | boolean>,
|
||||||
|
Sep extends string = '; ',
|
||||||
|
K extends string = '',
|
||||||
|
> = ExpandDeep<{
|
||||||
|
[key in keyof T]: key extends string
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
{} extends Omit<T, key>
|
||||||
|
? `${K}${key}: ${T[key]}`
|
||||||
|
: What<Omit<T, key>, Sep, `${K}${key}: ${T[key]}${Sep}`>
|
||||||
|
: never;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type UnionToParm<U> = U extends unknown ? (k: U) => void : never;
|
||||||
|
type UnionToSect<U> = UnionToParm<U> extends (k: infer I) => void ? I : never;
|
||||||
|
type ExtractParm<F> = F extends { (a: infer A): void } ? A : never;
|
||||||
|
|
||||||
|
type SpliceOne<Union> = Exclude<Union, ExtractOne<Union>>;
|
||||||
|
type ExtractOne<Union> = ExtractParm<UnionToSect<UnionToParm<Union>>>;
|
||||||
|
|
||||||
|
type ToTuple<Union> = ToTupleRec<Union, []>;
|
||||||
|
type ToTupleRec<Union, Rslt extends unknown[]> =
|
||||||
|
SpliceOne<Union> extends never
|
||||||
|
? [ExtractOne<Union>, ...Rslt]
|
||||||
|
: ToTupleRec<SpliceOne<Union>, [ExtractOne<Union>, ...Rslt]>;
|
||||||
|
|
||||||
|
// @ts-expect-error - this is hacky, but it works
|
||||||
|
type FindWhat<T> = ToTuple<T extends string ? T : FindWhat<T[keyof T]>>[0];
|
||||||
|
|
||||||
|
export type ObjToString<T extends Record<string, string | number | boolean>> = FindWhat<What<T>>;
|
||||||
|
|
||||||
|
export type Expect<T extends true> = T;
|
||||||
|
export type Equal<X, Y> =
|
||||||
|
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
|
||||||
|
|
||||||
|
export type FalseIfUndefined<T extends boolean | undefined> = T extends undefined ? false : T;
|
||||||
|
|
@ -1,35 +1,37 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/backend/convex/_generated/api';
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
|
import { GridCommand } from '$lib/builders/grid-command.svelte';
|
||||||
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
|
||||||
|
import Cohere from '$lib/components/icons/cohere.svelte';
|
||||||
|
import Deepseek from '$lib/components/icons/deepseek.svelte';
|
||||||
|
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
||||||
|
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||||
|
import { models as modelsState } from '$lib/state/models.svelte';
|
||||||
import { session } from '$lib/state/session.svelte';
|
import { session } from '$lib/state/session.svelte';
|
||||||
import { settings } from '$lib/state/settings.svelte';
|
import { settings } from '$lib/state/settings.svelte';
|
||||||
|
import { Provider } from '$lib/types';
|
||||||
|
import fuzzysearch from '$lib/utils/fuzzy-search';
|
||||||
|
import { supportsImages } from '$lib/utils/model-capabilities';
|
||||||
|
import { capitalize } from '$lib/utils/strings';
|
||||||
import { cn } from '$lib/utils/utils';
|
import { cn } from '$lib/utils/utils';
|
||||||
import { Command } from 'bits-ui';
|
import { mergeAttrs } from 'melt';
|
||||||
|
import { Popover } from 'melt/builders';
|
||||||
|
import { tick, type Component } from 'svelte';
|
||||||
|
import { type HTMLAttributes } from 'svelte/elements';
|
||||||
|
import LogosClaudeIcon from '~icons/logos/claude-icon';
|
||||||
|
import LogosMistralAiIcon from '~icons/logos/mistral-ai-icon';
|
||||||
|
import BrainIcon from '~icons/lucide/brain';
|
||||||
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
import ChevronDownIcon from '~icons/lucide/chevron-down';
|
||||||
import SearchIcon from '~icons/lucide/search';
|
import CpuIcon from '~icons/lucide/cpu';
|
||||||
import EyeIcon from '~icons/lucide/eye';
|
import EyeIcon from '~icons/lucide/eye';
|
||||||
|
import SearchIcon from '~icons/lucide/search';
|
||||||
|
import ZapIcon from '~icons/lucide/zap';
|
||||||
|
import MaterialIconThemeGeminiAi from '~icons/material-icon-theme/gemini-ai';
|
||||||
import GoogleIcon from '~icons/simple-icons/google';
|
import GoogleIcon from '~icons/simple-icons/google';
|
||||||
import MetaIcon from '~icons/simple-icons/meta';
|
import MetaIcon from '~icons/simple-icons/meta';
|
||||||
import MicrosoftIcon from '~icons/simple-icons/microsoft';
|
import MicrosoftIcon from '~icons/simple-icons/microsoft';
|
||||||
import OpenaiIcon from '~icons/simple-icons/openai';
|
import OpenaiIcon from '~icons/simple-icons/openai';
|
||||||
import XIcon from '~icons/simple-icons/x';
|
import XIcon from '~icons/simple-icons/x';
|
||||||
import BrainIcon from '~icons/lucide/brain';
|
|
||||||
import CpuIcon from '~icons/lucide/cpu';
|
|
||||||
import ZapIcon from '~icons/lucide/zap';
|
|
||||||
import Cohere from '$lib/components/icons/cohere.svelte';
|
|
||||||
import Deepseek from '$lib/components/icons/deepseek.svelte';
|
|
||||||
import { Popover } from 'melt/builders';
|
|
||||||
import type { Component } from 'svelte';
|
|
||||||
import LogosClaudeIcon from '~icons/logos/claude-icon';
|
|
||||||
import LogosMistralAiIcon from '~icons/logos/mistral-ai-icon';
|
|
||||||
import MaterialIconThemeGeminiAi from '~icons/material-icon-theme/gemini-ai';
|
|
||||||
import { capitalize } from '$lib/utils/strings';
|
|
||||||
import { supportsImages } from '$lib/utils/model-capabilities';
|
|
||||||
import { models as modelsState } from '$lib/state/models.svelte';
|
|
||||||
import { Provider } from '$lib/types';
|
|
||||||
import Tooltip from '$lib/components/ui/tooltip.svelte';
|
|
||||||
import fuzzysearch from '$lib/utils/fuzzy-search';
|
|
||||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -41,6 +43,15 @@
|
||||||
session_token: session.current?.session.token ?? '',
|
session_token: session.current?.session.token ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gridCommand = new GridCommand({
|
||||||
|
columns: () => (isMobile.current ? 1 : 4),
|
||||||
|
onSelect: (value) => {
|
||||||
|
settings.modelId = value;
|
||||||
|
popover.open = false;
|
||||||
|
gridCommand.inputValue = '';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
|
const enabledArr = $derived(Object.values(enabledModelsQuery.data ?? {}));
|
||||||
|
|
||||||
modelsState.init();
|
modelsState.init();
|
||||||
|
|
@ -111,12 +122,10 @@
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
let search = $state('');
|
|
||||||
|
|
||||||
const filteredModels = $derived(
|
const filteredModels = $derived(
|
||||||
fuzzysearch({
|
fuzzysearch({
|
||||||
haystack: enabledArr,
|
haystack: enabledArr,
|
||||||
needle: search,
|
needle: gridCommand.inputValue,
|
||||||
property: 'model_id',
|
property: 'model_id',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -151,18 +160,17 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function selectModel(modelId: string) {
|
|
||||||
settings.modelId = modelId;
|
|
||||||
popover.open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
const popover = new Popover({
|
const popover = new Popover({
|
||||||
open: () => open,
|
open: () => open,
|
||||||
onOpenChange: (v) => {
|
onOpenChange: (v) => {
|
||||||
if (v === open) return;
|
if (v === open) return;
|
||||||
open = v;
|
open = v;
|
||||||
if (v) return;
|
if (v) {
|
||||||
|
tick().then(() => {
|
||||||
|
gridCommand.scrollToHighlighted();
|
||||||
|
});
|
||||||
|
}
|
||||||
document.getElementById(popover.trigger.id)?.focus();
|
document.getElementById(popover.trigger.id)?.focus();
|
||||||
},
|
},
|
||||||
floatingConfig: {
|
floatingConfig: {
|
||||||
|
|
@ -230,19 +238,14 @@
|
||||||
{...popover.content}
|
{...popover.content}
|
||||||
class="border-border bg-popover mt-1 max-h-200 min-w-80 flex-col overflow-hidden rounded-xl border p-0 backdrop-blur-sm data-[open]:flex"
|
class="border-border bg-popover mt-1 max-h-200 min-w-80 flex-col overflow-hidden rounded-xl border p-0 backdrop-blur-sm data-[open]:flex"
|
||||||
>
|
>
|
||||||
<Command.Root
|
<div class="flex h-full flex-col overflow-hidden md:w-[572px]" {...gridCommand.root}>
|
||||||
shouldFilter={false}
|
|
||||||
class="flex h-full flex-col overflow-hidden md:w-[572px]"
|
|
||||||
columns={isMobile.current ? undefined : 4}
|
|
||||||
>
|
|
||||||
<label
|
<label
|
||||||
class="group/label border-border relative flex items-center gap-2 border-b px-4 py-3 text-sm"
|
class="group/label border-border relative flex items-center gap-2 border-b px-4 py-3 text-sm"
|
||||||
>
|
>
|
||||||
<SearchIcon class="text-muted-foreground" />
|
<SearchIcon class="text-muted-foreground" />
|
||||||
<Command.Input
|
<input
|
||||||
class="w-full outline-none"
|
class="w-full outline-none"
|
||||||
placeholder="Search models..."
|
placeholder="Search models..."
|
||||||
bind:value={search}
|
|
||||||
{@attach (node) => {
|
{@attach (node) => {
|
||||||
if (popover.open) {
|
if (popover.open) {
|
||||||
node.focus();
|
node.focus();
|
||||||
|
|
@ -251,36 +254,40 @@
|
||||||
node.value = '';
|
node.value = '';
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
|
{...mergeAttrs(gridCommand.input as unknown as HTMLAttributes<HTMLElement>, {
|
||||||
|
onkeydown: (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
popover.open = false;
|
||||||
|
gridCommand.inputValue = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Command.List class="h-[300px] overflow-y-auto md:h-[430px]">
|
<div class="h-[300px] overflow-y-auto md:h-[430px]">
|
||||||
<Command.Viewport>
|
|
||||||
<Command.Empty
|
|
||||||
class="text-muted-foreground flex items-center justify-center p-4 text-sm md:h-[120px]"
|
|
||||||
>
|
|
||||||
No models available. Enable some models in the account settings.
|
|
||||||
</Command.Empty>
|
|
||||||
{#each groupedModels as [company, models] (company)}
|
{#each groupedModels as [company, models] (company)}
|
||||||
<Command.Group class="space-y-2">
|
<div {...gridCommand.group} class="space-y-2">
|
||||||
<Command.GroupHeading
|
<p
|
||||||
class="text-heading/75 flex items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize md:scroll-m-[180px]"
|
class="text-heading/75 flex scroll-m-2 items-center gap-2 px-3 pt-3 pb-1 text-xs font-semibold tracking-wide capitalize"
|
||||||
|
{...gridCommand.groupHeading}
|
||||||
>
|
>
|
||||||
{company}
|
{company}
|
||||||
</Command.GroupHeading>
|
</p>
|
||||||
<Command.GroupItems
|
<div class="flex flex-col gap-2 px-3 pb-3 md:grid md:grid-cols-4 md:gap-3">
|
||||||
class="flex flex-col gap-2 px-3 pb-3 md:grid md:grid-cols-4 md:gap-3"
|
|
||||||
>
|
|
||||||
{#each models as model (model._id)}
|
{#each models as model (model._id)}
|
||||||
{@const isSelected = settings.modelId === model.model_id}
|
{@const isSelected = settings.modelId === model.model_id}
|
||||||
{@const formatted = formatModelName(model.model_id)}
|
{@const formatted = formatModelName(model.model_id)}
|
||||||
|
{@const openRouterModel = modelsState
|
||||||
|
.from(Provider.OpenRouter)
|
||||||
|
.find((m) => m.id === model.model_id)}
|
||||||
|
|
||||||
{#if isMobile.current}
|
{#if isMobile.current}
|
||||||
<Command.Item
|
<div
|
||||||
value={model.model_id}
|
{...gridCommand.getItem(model.model_id)}
|
||||||
onSelect={() => selectModel(model.model_id)}
|
|
||||||
class={cn(
|
class={cn(
|
||||||
'border-border flex h-10 items-center justify-between rounded-lg border p-2',
|
'border-border flex h-10 items-center justify-between rounded-lg border p-2',
|
||||||
'relative scroll-m-2 select-none',
|
'relative scroll-m-2 select-none',
|
||||||
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
|
'data-highlighted:bg-accent/50 data-highlighted:text-accent-foreground',
|
||||||
isSelected && 'border-reflect border-none'
|
isSelected && 'border-reflect border-none'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -294,9 +301,6 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{@const openRouterModel = modelsState
|
|
||||||
.from(Provider.OpenRouter)
|
|
||||||
.find((m) => m.id === model.model_id)}
|
|
||||||
{#if openRouterModel && supportsImages(openRouterModel)}
|
{#if openRouterModel && supportsImages(openRouterModel)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
{#snippet trigger(tooltip)}
|
{#snippet trigger(tooltip)}
|
||||||
|
|
@ -307,15 +311,14 @@
|
||||||
Supports image anaylsis
|
Supports image anaylsis
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
</Command.Item>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Command.Item
|
<div
|
||||||
value={model.model_id}
|
{...gridCommand.getItem(model.model_id)}
|
||||||
onSelect={() => selectModel(model.model_id)}
|
|
||||||
class={cn(
|
class={cn(
|
||||||
'border-border flex h-40 w-32 flex-col items-center justify-center rounded-lg border p-2',
|
'border-border flex h-40 w-32 scroll-m-2 flex-col items-center justify-center rounded-lg border p-2',
|
||||||
'relative select-none',
|
'relative select-none',
|
||||||
'data-selected:bg-accent/50 data-selected:text-accent-foreground',
|
'data-highlighted:bg-accent/50 data-highlighted:text-accent-foreground',
|
||||||
isSelected && 'border-reflect border-none'
|
isSelected && 'border-reflect border-none'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -330,9 +333,6 @@
|
||||||
{formatted.secondary}
|
{formatted.secondary}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{@const openRouterModel = modelsState
|
|
||||||
.from(Provider.OpenRouter)
|
|
||||||
.find((m) => m.id === model.model_id)}
|
|
||||||
{#if openRouterModel && supportsImages(openRouterModel)}
|
{#if openRouterModel && supportsImages(openRouterModel)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
{#snippet trigger(tooltip)}
|
{#snippet trigger(tooltip)}
|
||||||
|
|
@ -346,14 +346,13 @@
|
||||||
Supports image anaylsis
|
Supports image anaylsis
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/if}
|
{/if}
|
||||||
</Command.Item>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</Command.GroupItems>
|
</div>
|
||||||
</Command.Group>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</Command.Viewport>
|
</div>
|
||||||
</Command.List>
|
</div>
|
||||||
</Command.Root>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue