Merge branch 'dialog'
This commit is contained in:
commit
93b8ba9c3b
17 changed files with 691 additions and 9 deletions
|
|
@ -54,6 +54,7 @@ IDK, calm down
|
||||||
- [ ] delete conversations option
|
- [ ] delete conversations option
|
||||||
- [ ] conversation title generation
|
- [ ] conversation title generation
|
||||||
- [ ] kbd powered popover model picker
|
- [ ] kbd powered popover model picker
|
||||||
|
- [ ] autosize
|
||||||
|
|
||||||
### Extra
|
### Extra
|
||||||
|
|
||||||
|
|
|
||||||
165
src/app.css
165
src/app.css
|
|
@ -239,3 +239,168 @@
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
display: none; /* Chrome, Safari, and Opera */
|
display: none; /* Chrome, Safari, and Opera */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Modal is from DaisyUI */
|
||||||
|
.modal {
|
||||||
|
@apply pointer-events-none invisible fixed inset-0 m-0 grid h-full max-h-none w-full max-w-none items-center justify-items-center bg-transparent p-0 text-[inherit];
|
||||||
|
overflow-x: hidden;
|
||||||
|
transition:
|
||||||
|
translate 0.3s ease-out,
|
||||||
|
visibility 0.3s allow-discrete,
|
||||||
|
background-color 0.3s ease-out,
|
||||||
|
opacity 0.1s ease-out;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
z-index: 999;
|
||||||
|
&::backdrop {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.modal-open,
|
||||||
|
&[open],
|
||||||
|
&:target {
|
||||||
|
@apply pointer-events-auto visible opacity-100;
|
||||||
|
background-color: oklch(0% 0 0/ 0.4);
|
||||||
|
/* this cause glitch on Chrome */
|
||||||
|
/* transition:
|
||||||
|
translate 0.3s ease-out,
|
||||||
|
background-color 0.3s ease-out,
|
||||||
|
opacity 0.1s ease-out; */
|
||||||
|
.modal-box {
|
||||||
|
translate: 0 0;
|
||||||
|
scale: 1;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@starting-style {
|
||||||
|
&.modal-open,
|
||||||
|
&[open],
|
||||||
|
&:target {
|
||||||
|
@apply invisible opacity-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-action {
|
||||||
|
@apply mt-6 flex justify-end gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-toggle {
|
||||||
|
@apply fixed h-0 w-0 appearance-none opacity-0;
|
||||||
|
|
||||||
|
&:checked + .modal {
|
||||||
|
@apply pointer-events-auto visible opacity-100;
|
||||||
|
background-color: oklch(0% 0 0/ 0.4);
|
||||||
|
.modal-box {
|
||||||
|
translate: 0 0;
|
||||||
|
scale: 1;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@starting-style {
|
||||||
|
&:checked + .modal {
|
||||||
|
@apply invisible opacity-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
@apply col-start-1 row-start-1 grid self-stretch justify-self-stretch text-transparent;
|
||||||
|
z-index: -1;
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
@apply bg-background border-border col-start-1 row-start-1 max-h-screen w-11/12 max-w-[32rem] p-6;
|
||||||
|
transition:
|
||||||
|
translate 0.3s ease-out,
|
||||||
|
scale 0.3s ease-out,
|
||||||
|
opacity 0.2s ease-out 0.05s,
|
||||||
|
box-shadow 0.3s ease-out;
|
||||||
|
|
||||||
|
border-radius: var(--radius);
|
||||||
|
|
||||||
|
scale: 95%;
|
||||||
|
opacity: 0;
|
||||||
|
box-shadow: oklch(0% 0 0/ 0.25) 0px 25px 50px -12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-top {
|
||||||
|
@apply place-items-start;
|
||||||
|
|
||||||
|
:where(.modal-box) {
|
||||||
|
@apply h-auto w-full max-w-none;
|
||||||
|
max-height: calc(100vh - 5em);
|
||||||
|
translate: 0 -100%;
|
||||||
|
scale: 1;
|
||||||
|
--modal-tl: 0;
|
||||||
|
--modal-tr: 0;
|
||||||
|
--modal-bl: var(--radius-box);
|
||||||
|
--modal-br: var(--radius-box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-middle {
|
||||||
|
@apply place-items-center;
|
||||||
|
|
||||||
|
:where(.modal-box) {
|
||||||
|
@apply h-auto w-11/12 max-w-[32rem];
|
||||||
|
max-height: calc(100vh - 5em);
|
||||||
|
translate: 0 2%;
|
||||||
|
scale: 98%;
|
||||||
|
--modal-tl: var(--radius-box);
|
||||||
|
--modal-tr: var(--radius-box);
|
||||||
|
--modal-bl: var(--radius-box);
|
||||||
|
--modal-br: var(--radius-box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-bottom {
|
||||||
|
@apply place-items-end;
|
||||||
|
|
||||||
|
:where(.modal-box) {
|
||||||
|
@apply h-auto w-full max-w-none;
|
||||||
|
max-height: calc(100vh - 5em);
|
||||||
|
translate: 0 100%;
|
||||||
|
scale: 1;
|
||||||
|
--modal-tl: var(--radius-box);
|
||||||
|
--modal-tr: var(--radius-box);
|
||||||
|
--modal-bl: 0;
|
||||||
|
--modal-br: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-start {
|
||||||
|
@apply place-items-start;
|
||||||
|
|
||||||
|
:where(.modal-box) {
|
||||||
|
@apply h-screen max-h-none w-auto max-w-none;
|
||||||
|
translate: -100% 0;
|
||||||
|
scale: 1;
|
||||||
|
--modal-tl: 0;
|
||||||
|
--modal-tr: var(--radius-box);
|
||||||
|
--modal-bl: 0;
|
||||||
|
--modal-br: var(--radius-box);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-end {
|
||||||
|
@apply place-items-end;
|
||||||
|
|
||||||
|
:where(.modal-box) {
|
||||||
|
@apply h-screen max-h-none w-auto max-w-none;
|
||||||
|
translate: 100% 0;
|
||||||
|
scale: 1;
|
||||||
|
--modal-tl: var(--radius-box);
|
||||||
|
--modal-tr: 0;
|
||||||
|
--modal-bl: var(--radius-box);
|
||||||
|
--modal-br: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
48
src/lib/attachments/click-outside.svelte.ts
Normal file
48
src/lib/attachments/click-outside.svelte.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import type { Attachment } from 'svelte/attachments';
|
||||||
|
|
||||||
|
export function clickOutside(callback: () => void): Attachment {
|
||||||
|
return (node) => {
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
if (window.getSelection()?.toString()) {
|
||||||
|
// Don't close if text is selected
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dialog elements, check if click was on the backdrop
|
||||||
|
if (node instanceof HTMLDialogElement) {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const isInDialog =
|
||||||
|
event.clientX >= rect.left &&
|
||||||
|
event.clientX <= rect.right &&
|
||||||
|
event.clientY >= rect.top &&
|
||||||
|
event.clientY <= rect.bottom;
|
||||||
|
|
||||||
|
if (!isInDialog) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-dialog elements, use the standard contains check
|
||||||
|
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dialogs, listen on the element itself
|
||||||
|
if (node instanceof HTMLDialogElement) {
|
||||||
|
node.addEventListener('click', handleClick);
|
||||||
|
} else {
|
||||||
|
// For other elements, listen on the document
|
||||||
|
document.addEventListener('click', handleClick, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (node instanceof HTMLDialogElement) {
|
||||||
|
node.removeEventListener('click', handleClick);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', handleClick, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { v } from 'convex/values';
|
||||||
import { mutation, query } from './_generated/server';
|
import { mutation, query } from './_generated/server';
|
||||||
import { api } from './_generated/api';
|
import { api } from './_generated/api';
|
||||||
import { messageRoleValidator, providerValidator } from './schema';
|
import { messageRoleValidator, providerValidator } from './schema';
|
||||||
import type { Id } from './_generated/dataModel';
|
import { type Id } from './_generated/dataModel';
|
||||||
|
|
||||||
export const getAllFromConversation = query({
|
export const getAllFromConversation = query({
|
||||||
args: {
|
args: {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { v } from 'convex/values';
|
||||||
import { mutation, query } from './_generated/server';
|
import { mutation, query } from './_generated/server';
|
||||||
import { internal } from './_generated/api';
|
import { internal } from './_generated/api';
|
||||||
import { ruleAttachValidator } from './schema';
|
import { ruleAttachValidator } from './schema';
|
||||||
import type { Doc } from './_generated/dataModel';
|
import { type Doc } from './_generated/dataModel';
|
||||||
|
|
||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
|
|
|
||||||
56
src/lib/components/ui/modal/global-modal.svelte
Normal file
56
src/lib/components/ui/modal/global-modal.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<script lang="ts" module>
|
||||||
|
import type { ButtonVariant } from '../button';
|
||||||
|
|
||||||
|
// We can extend the generics to include form fields if needed
|
||||||
|
type CallModalArgs<Action extends string> = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actions?: Record<Action, ButtonVariant>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let modalArgs = $state(null) as null | CallModalArgs<string>;
|
||||||
|
let resolve: (v: string | null) => void;
|
||||||
|
|
||||||
|
export function callModal<Action extends string>(
|
||||||
|
args: CallModalArgs<Action>
|
||||||
|
): Promise<Action | null> {
|
||||||
|
modalArgs = args;
|
||||||
|
|
||||||
|
return new Promise<Action | null>((res) => {
|
||||||
|
resolve = res as (v: string | null) => void;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Button from '../button/button.svelte';
|
||||||
|
import Modal from './modal.svelte';
|
||||||
|
|
||||||
|
let open = $derived(!!modalArgs);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
bind:open={
|
||||||
|
() => open,
|
||||||
|
(v) => {
|
||||||
|
if (v) return;
|
||||||
|
open = false;
|
||||||
|
setTimeout(() => (modalArgs = null), 200);
|
||||||
|
resolve?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-bold">{modalArgs?.title}</h3>
|
||||||
|
<p class="py-4">{modalArgs?.description}</p>
|
||||||
|
{#if modalArgs?.actions}
|
||||||
|
<div class="modal-action">
|
||||||
|
{#each Object.entries(modalArgs.actions) as [action, variant] (action)}
|
||||||
|
<form method="dialog" onsubmit={() => resolve(action)}>
|
||||||
|
<Button {variant} type="submit" class="capitalize">
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
3
src/lib/components/ui/modal/index.ts
Normal file
3
src/lib/components/ui/modal/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import Modal from './modal.svelte';
|
||||||
|
|
||||||
|
export { Modal };
|
||||||
32
src/lib/components/ui/modal/modal.svelte
Normal file
32
src/lib/components/ui/modal/modal.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { clickOutside } from '$lib/attachments/click-outside.svelte.js';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement | undefined = $state();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
dialog?.showModal();
|
||||||
|
} else {
|
||||||
|
dialog?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog class="modal" bind:this={dialog} onclose={() => (open = false)}>
|
||||||
|
<div
|
||||||
|
class="modal-box"
|
||||||
|
{@attach clickOutside(() => {
|
||||||
|
if (open) open = false;
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils/utils';
|
import { cn } from '$lib/utils/utils';
|
||||||
|
import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
|
||||||
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let { value = $bindable(''), class: className, ...rest }: HTMLTextareaAttributes = $props();
|
type Props = HTMLTextareaAttributes & {
|
||||||
|
autosize?: boolean;
|
||||||
|
};
|
||||||
|
let { value = $bindable(''), class: className, autosize = false, ...rest }: Props = $props();
|
||||||
|
|
||||||
|
const autosizeTextarea = new TextareaAutosize();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -12,4 +18,8 @@
|
||||||
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
{@attach (node) => {
|
||||||
|
if (!autosize) return;
|
||||||
|
autosizeTextarea.attachment(node);
|
||||||
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
|
||||||
71
src/lib/components/ui/tooltip.svelte
Normal file
71
src/lib/components/ui/tooltip.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getters, type ComponentProps, type Extracted } from 'melt';
|
||||||
|
import { Tooltip, type TooltipProps } from 'melt/builders';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type FloatingConfig = NonNullable<Extracted<TooltipProps['floatingConfig']>>;
|
||||||
|
|
||||||
|
interface Props extends Omit<ComponentProps<TooltipProps>, 'floatingConfig'> {
|
||||||
|
children: Snippet;
|
||||||
|
trigger: Snippet<[Tooltip]>;
|
||||||
|
placement?: NonNullable<FloatingConfig['computePosition']>['placement'];
|
||||||
|
openDelay?: ComponentProps<TooltipProps>['openDelay'];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
trigger,
|
||||||
|
placement = 'top',
|
||||||
|
openDelay = 500,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
const tooltip = new Tooltip({
|
||||||
|
forceVisible: true,
|
||||||
|
floatingConfig: () => ({
|
||||||
|
computePosition: { placement },
|
||||||
|
flip: {
|
||||||
|
fallbackPlacements: ['bottom'],
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
open: () => open,
|
||||||
|
onOpenChange(v) {
|
||||||
|
if (disabled) open = false;
|
||||||
|
else open = v;
|
||||||
|
},
|
||||||
|
openDelay: () => openDelay,
|
||||||
|
...getters(rest),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render trigger(tooltip)}
|
||||||
|
|
||||||
|
<div {...tooltip.content} class="rounded-xl bg-white p-0 shadow-xl dark:bg-stone-700">
|
||||||
|
<div {...tooltip.arrow} class="size-2 rounded-tl"></div>
|
||||||
|
<p class="px-4 py-1 text-stone-700 dark:text-white">{@render children()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[data-melt-tooltip-content] {
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
transform: scale(0.9);
|
||||||
|
|
||||||
|
transition: 0.3s;
|
||||||
|
transition-property: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-melt-tooltip-content][data-open] {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
182
src/lib/spells/textarea-autosize.svelte.ts
Normal file
182
src/lib/spells/textarea-autosize.svelte.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { useResizeObserver, watch, extract } from 'runed';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import type { Attachment } from 'svelte/attachments';
|
||||||
|
import { on } from 'svelte/events';
|
||||||
|
|
||||||
|
export interface TextareaAutosizeOptions {
|
||||||
|
/** Function called when the textarea size changes. */
|
||||||
|
onResize?: () => void;
|
||||||
|
/**
|
||||||
|
* Specify the style property that will be used to manipulate height. Can be `height | minHeight`.
|
||||||
|
* @default `height`
|
||||||
|
**/
|
||||||
|
styleProp?: 'height' | 'minHeight';
|
||||||
|
/**
|
||||||
|
* Maximum height of the textarea before enabling scrolling.
|
||||||
|
* @default `undefined` (no maximum)
|
||||||
|
*/
|
||||||
|
maxHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TextareaAutosize {
|
||||||
|
#options: TextareaAutosizeOptions;
|
||||||
|
#resizeTimeout: number | null = null;
|
||||||
|
#hiddenTextarea: HTMLTextAreaElement | null = null;
|
||||||
|
|
||||||
|
element: HTMLTextAreaElement | undefined;
|
||||||
|
#input = '';
|
||||||
|
styleProp = $derived.by(() => extract(this.#options.styleProp, 'height'));
|
||||||
|
maxHeight = $derived.by(() => extract(this.#options.maxHeight, undefined));
|
||||||
|
textareaHeight = $state(0);
|
||||||
|
textareaOldWidth = $state(0);
|
||||||
|
|
||||||
|
constructor(options: TextareaAutosizeOptions = {}) {
|
||||||
|
this.#options = options;
|
||||||
|
|
||||||
|
// Create hidden textarea for measurements
|
||||||
|
this.#createHiddenTextarea();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => this.textareaHeight,
|
||||||
|
() => options?.onResize?.()
|
||||||
|
);
|
||||||
|
|
||||||
|
useResizeObserver(
|
||||||
|
() => this.element,
|
||||||
|
([entry]) => {
|
||||||
|
if (!entry) return;
|
||||||
|
const { contentRect } = entry;
|
||||||
|
if (this.textareaOldWidth === contentRect.width) return;
|
||||||
|
|
||||||
|
this.textareaOldWidth = contentRect.width;
|
||||||
|
this.triggerResize();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
// Clean up
|
||||||
|
if (this.#hiddenTextarea) {
|
||||||
|
this.#hiddenTextarea.remove();
|
||||||
|
this.#hiddenTextarea = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#resizeTimeout) {
|
||||||
|
window.cancelAnimationFrame(this.#resizeTimeout);
|
||||||
|
this.#resizeTimeout = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#createHiddenTextarea() {
|
||||||
|
// Create a hidden textarea that will be used for measurements
|
||||||
|
// This avoids layout shifts caused by manipulating the actual textarea
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
this.#hiddenTextarea = document.createElement('textarea');
|
||||||
|
const style = this.#hiddenTextarea.style;
|
||||||
|
|
||||||
|
// Make it invisible but keep same text layout properties
|
||||||
|
style.visibility = 'hidden';
|
||||||
|
style.position = 'absolute';
|
||||||
|
style.overflow = 'hidden';
|
||||||
|
style.height = '0';
|
||||||
|
style.top = '0';
|
||||||
|
style.left = '-9999px';
|
||||||
|
|
||||||
|
document.body.appendChild(this.#hiddenTextarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyStyles() {
|
||||||
|
if (!this.element || !this.#hiddenTextarea) return;
|
||||||
|
|
||||||
|
const computed = window.getComputedStyle(this.element);
|
||||||
|
|
||||||
|
// Copy all the styles that affect text layout
|
||||||
|
const stylesToCopy = [
|
||||||
|
'box-sizing',
|
||||||
|
'width',
|
||||||
|
'padding-top',
|
||||||
|
'padding-right',
|
||||||
|
'padding-bottom',
|
||||||
|
'padding-left',
|
||||||
|
'border-top-width',
|
||||||
|
'border-right-width',
|
||||||
|
'border-bottom-width',
|
||||||
|
'border-left-width',
|
||||||
|
'font-family',
|
||||||
|
'font-size',
|
||||||
|
'font-weight',
|
||||||
|
'font-style',
|
||||||
|
'letter-spacing',
|
||||||
|
'text-indent',
|
||||||
|
'text-transform',
|
||||||
|
'line-height',
|
||||||
|
'word-spacing',
|
||||||
|
'word-wrap',
|
||||||
|
'word-break',
|
||||||
|
'white-space',
|
||||||
|
];
|
||||||
|
|
||||||
|
stylesToCopy.forEach((style) => {
|
||||||
|
this.#hiddenTextarea!.style.setProperty(style, computed.getPropertyValue(style));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure the width matches exactly
|
||||||
|
this.#hiddenTextarea.style.width = `${this.element.clientWidth}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerResize = () => {
|
||||||
|
if (!this.element || !this.#hiddenTextarea) return;
|
||||||
|
|
||||||
|
// Copy current styles and content to hidden textarea
|
||||||
|
this.#copyStyles();
|
||||||
|
this.#hiddenTextarea.value = this.#input || '';
|
||||||
|
|
||||||
|
// Measure the hidden textarea
|
||||||
|
const scrollHeight = this.#hiddenTextarea.scrollHeight;
|
||||||
|
|
||||||
|
// Apply the height, respecting maxHeight if set
|
||||||
|
let newHeight = scrollHeight;
|
||||||
|
if (this.maxHeight && newHeight > this.maxHeight) {
|
||||||
|
newHeight = this.maxHeight;
|
||||||
|
this.element.style.overflowY = 'auto';
|
||||||
|
} else {
|
||||||
|
this.element.style.overflowY = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if height actually changed
|
||||||
|
if (this.textareaHeight !== newHeight) {
|
||||||
|
this.textareaHeight = newHeight;
|
||||||
|
this.element.style[this.styleProp] = `${newHeight}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attachment: Attachment<HTMLTextAreaElement> = (node) => {
|
||||||
|
this.element = node;
|
||||||
|
this.#input = node.value;
|
||||||
|
|
||||||
|
// Detect programmatic changes
|
||||||
|
const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!;
|
||||||
|
Object.defineProperty(node, 'value', {
|
||||||
|
get: desc.get,
|
||||||
|
set: (v) => {
|
||||||
|
this.#input = v;
|
||||||
|
queueMicrotask(this.triggerResize);
|
||||||
|
desc.set?.call(node, v);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queueMicrotask(this.triggerResize);
|
||||||
|
|
||||||
|
const removeListener = on(node, 'input', () => {
|
||||||
|
this.#input = node.value;
|
||||||
|
this.triggerResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeListener();
|
||||||
|
this.#input = '';
|
||||||
|
this.element = undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
64
src/lib/utils/fuzzy-search.ts
Normal file
64
src/lib/utils/fuzzy-search.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Generic fuzzy search function that searches through arrays and returns matching items
|
||||||
|
*
|
||||||
|
* @param options Configuration object for the fuzzy search
|
||||||
|
* @returns Array of items that match the search criteria
|
||||||
|
*/
|
||||||
|
export default function fuzzysearch<T>(options: {
|
||||||
|
needle: string;
|
||||||
|
haystack: T[];
|
||||||
|
property: keyof T | ((item: T) => string);
|
||||||
|
}): T[] {
|
||||||
|
const { needle, haystack, property } = options;
|
||||||
|
|
||||||
|
if (!Array.isArray(haystack)) {
|
||||||
|
throw new Error('Haystack must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!property) {
|
||||||
|
throw new Error('Property selector is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert needle to lowercase for case-insensitive matching
|
||||||
|
const lowerNeedle = needle.toLowerCase();
|
||||||
|
|
||||||
|
// Filter the haystack to find matching items
|
||||||
|
return haystack.filter((item) => {
|
||||||
|
// Extract the string value from the item based on the property selector
|
||||||
|
const value = typeof property === 'function' ? property(item) : String(item[property]);
|
||||||
|
|
||||||
|
// Convert to lowercase for case-insensitive matching
|
||||||
|
const lowerValue = value.toLowerCase();
|
||||||
|
|
||||||
|
// Perform the fuzzy search
|
||||||
|
return fuzzyMatchString(lowerNeedle, lowerValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helper function that performs the actual fuzzy string matching
|
||||||
|
*/
|
||||||
|
function fuzzyMatchString(needle: string, haystack: string): boolean {
|
||||||
|
const hlen = haystack.length;
|
||||||
|
const nlen = needle.length;
|
||||||
|
|
||||||
|
if (nlen > hlen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nlen === hlen) {
|
||||||
|
return needle === haystack;
|
||||||
|
}
|
||||||
|
|
||||||
|
outer: for (let i = 0, j = 0; i < nlen; i++) {
|
||||||
|
const nch = needle.charCodeAt(i);
|
||||||
|
while (j < hlen) {
|
||||||
|
if (haystack.charCodeAt(j++) === nch) {
|
||||||
|
continue outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
@ -20,3 +20,39 @@ export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pi
|
||||||
K
|
K
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms an object into a new object by applying a mapping function
|
||||||
|
* to each of its key-value pairs.
|
||||||
|
*
|
||||||
|
* @template KIn The type of the keys in the input object.
|
||||||
|
* @template VIn The type of the values in the input object.
|
||||||
|
* @template KOut The type of the keys in the output object.
|
||||||
|
* @template VOut The type of the values in the output object.
|
||||||
|
*
|
||||||
|
* @param obj The input object to transform.
|
||||||
|
* @param mapper A function that takes a key and its value from the input object
|
||||||
|
* and returns a tuple `[KOut, VOut]` representing the new key
|
||||||
|
* and new value for the output object.
|
||||||
|
* @returns A new object with the transformed keys and values.
|
||||||
|
*/
|
||||||
|
export function objectMap<
|
||||||
|
KIn extends string | number | symbol,
|
||||||
|
VIn,
|
||||||
|
KOut extends string | number | symbol,
|
||||||
|
VOut,
|
||||||
|
>(obj: Record<KIn, VIn>, mapper: (key: KIn, value: VIn) => [KOut, VOut]): Record<KOut, VOut> {
|
||||||
|
const result: Record<KOut, VOut> = {} as Record<KOut, VOut>;
|
||||||
|
|
||||||
|
for (const rawKey in obj) {
|
||||||
|
// Ensure we only process own properties (not inherited ones)
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, rawKey)) {
|
||||||
|
const key = rawKey as KIn; // Cast to KIn as rawKey is initially string
|
||||||
|
const value = obj[key];
|
||||||
|
const [newKey, newValue] = mapper(key, value);
|
||||||
|
result[newKey] = newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { ModeWatcher } from 'mode-watcher';
|
import { ModeWatcher } from 'mode-watcher';
|
||||||
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||||
import { models } from '$lib/state/models.svelte';
|
import { models } from '$lib/state/models.svelte';
|
||||||
|
import GlobalModal from '$lib/components/ui/modal/global-modal.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|
@ -13,3 +14,5 @@
|
||||||
|
|
||||||
<ModeWatcher />
|
<ModeWatcher />
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
|
<GlobalModal />
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import { LocalToasts } from '$lib/builders/local-toasts.svelte';
|
import { LocalToasts } from '$lib/builders/local-toasts.svelte';
|
||||||
import { ResultAsync } from 'neverthrow';
|
import { ResultAsync } from 'neverthrow';
|
||||||
import TrashIcon from '~icons/lucide/trash';
|
import TrashIcon from '~icons/lucide/trash';
|
||||||
|
import { callModal } from '$lib/components/ui/modal/global-modal.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rule: Doc<'user_rules'>;
|
rule: Doc<'user_rules'>;
|
||||||
|
|
@ -57,6 +58,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRule() {
|
async function deleteRule() {
|
||||||
|
const action = await callModal({
|
||||||
|
title: 'Delete Rule',
|
||||||
|
description: 'Are you sure you want to delete this rule?',
|
||||||
|
actions: {
|
||||||
|
delete: 'destructive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (action !== 'delete') return;
|
||||||
|
|
||||||
deleting = true;
|
deleting = true;
|
||||||
|
|
||||||
await client.mutation(api.user_rules.remove, {
|
await client.mutation(api.user_rules.remove, {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import XIcon from '~icons/lucide/x';
|
import XIcon from '~icons/lucide/x';
|
||||||
import PlusIcon from '~icons/lucide/plus';
|
import PlusIcon from '~icons/lucide/plus';
|
||||||
import { models } from '$lib/state/models.svelte';
|
import { models } from '$lib/state/models.svelte';
|
||||||
|
import fuzzysearch from '$lib/utils/fuzzy-search';
|
||||||
|
|
||||||
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
|
||||||
provider: Provider.OpenRouter,
|
provider: Provider.OpenRouter,
|
||||||
|
|
@ -30,12 +31,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const openRouterModels = $derived(
|
const openRouterModels = $derived(
|
||||||
models.from(Provider.OpenRouter).filter((model) => {
|
fuzzysearch({ haystack: models.from(Provider.OpenRouter), needle: search, property: 'name' })
|
||||||
if (search !== '' && !hasOpenRouterKey) return false;
|
|
||||||
if (!openRouterToggle.value) return false;
|
|
||||||
|
|
||||||
return model.name.toLowerCase().includes(search.toLowerCase());
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { useCachedQuery } from '$lib/cache/cached-query.svelte.js';
|
import { useCachedQuery } from '$lib/cache/cached-query.svelte.js';
|
||||||
import { api } from '$lib/backend/convex/_generated/api.js';
|
import { api } from '$lib/backend/convex/_generated/api.js';
|
||||||
|
import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
|
|
@ -45,6 +46,8 @@
|
||||||
const conversationsQuery = useCachedQuery(api.conversations.get, {
|
const conversationsQuery = useCachedQuery(api.conversations.get, {
|
||||||
session_token: session.current?.session.token ?? '',
|
session_token: session.current?.session.token ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const _autosize = new TextareaAutosize();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -108,6 +111,8 @@
|
||||||
}}
|
}}
|
||||||
bind:this={form}
|
bind:this={form}
|
||||||
>
|
>
|
||||||
|
<!-- TODO: Figure out better autofocus solution -->
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<textarea
|
<textarea
|
||||||
bind:this={textarea}
|
bind:this={textarea}
|
||||||
class="border-input bg-background ring-ring ring-offset-background h-full w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
|
class="border-input bg-background ring-ring ring-offset-background h-full w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue