diff --git a/README.md b/README.md index f96b1a2..5796910 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ IDK, calm down - [ ] delete conversations option - [ ] conversation title generation - [ ] kbd powered popover model picker +- [ ] autosize ### Extra diff --git a/src/app.css b/src/app.css index 1304726..9cccaed 100644 --- a/src/app.css +++ b/src/app.css @@ -239,3 +239,168 @@ .scrollbar-hide::-webkit-scrollbar { 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; + } + } +} diff --git a/src/lib/attachments/click-outside.svelte.ts b/src/lib/attachments/click-outside.svelte.ts new file mode 100644 index 0000000..409c81e --- /dev/null +++ b/src/lib/attachments/click-outside.svelte.ts @@ -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); + } + }; + }; +} diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index 2f110a9..102b362 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -2,7 +2,7 @@ import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import { api } from './_generated/api'; import { messageRoleValidator, providerValidator } from './schema'; -import type { Id } from './_generated/dataModel'; +import { type Id } from './_generated/dataModel'; export const getAllFromConversation = query({ args: { diff --git a/src/lib/backend/convex/user_rules.ts b/src/lib/backend/convex/user_rules.ts index 503a477..07778eb 100644 --- a/src/lib/backend/convex/user_rules.ts +++ b/src/lib/backend/convex/user_rules.ts @@ -2,7 +2,7 @@ import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; import { internal } from './_generated/api'; import { ruleAttachValidator } from './schema'; -import type { Doc } from './_generated/dataModel'; +import { type Doc } from './_generated/dataModel'; export const create = mutation({ args: { diff --git a/src/lib/components/ui/modal/global-modal.svelte b/src/lib/components/ui/modal/global-modal.svelte new file mode 100644 index 0000000..c9b92d0 --- /dev/null +++ b/src/lib/components/ui/modal/global-modal.svelte @@ -0,0 +1,56 @@ + + + + + open, + (v) => { + if (v) return; + open = false; + setTimeout(() => (modalArgs = null), 200); + resolve?.(null); + } + } +> +

{modalArgs?.title}

+

{modalArgs?.description}

+ {#if modalArgs?.actions} + + {/if} +
diff --git a/src/lib/components/ui/modal/index.ts b/src/lib/components/ui/modal/index.ts new file mode 100644 index 0000000..5885b85 --- /dev/null +++ b/src/lib/components/ui/modal/index.ts @@ -0,0 +1,3 @@ +import Modal from './modal.svelte'; + +export { Modal }; diff --git a/src/lib/components/ui/modal/modal.svelte b/src/lib/components/ui/modal/modal.svelte new file mode 100644 index 0000000..2e86c73 --- /dev/null +++ b/src/lib/components/ui/modal/modal.svelte @@ -0,0 +1,32 @@ + + + (open = false)}> + + diff --git a/src/lib/components/ui/textarea/textarea.svelte b/src/lib/components/ui/textarea/textarea.svelte index b0185ed..24352c6 100644 --- a/src/lib/components/ui/textarea/textarea.svelte +++ b/src/lib/components/ui/textarea/textarea.svelte @@ -1,8 +1,14 @@ diff --git a/src/lib/components/ui/tooltip.svelte b/src/lib/components/ui/tooltip.svelte new file mode 100644 index 0000000..32da1d4 --- /dev/null +++ b/src/lib/components/ui/tooltip.svelte @@ -0,0 +1,71 @@ + + +{@render trigger(tooltip)} + +
+
+

{@render children()}

+
+ + diff --git a/src/lib/spells/textarea-autosize.svelte.ts b/src/lib/spells/textarea-autosize.svelte.ts new file mode 100644 index 0000000..4df4767 --- /dev/null +++ b/src/lib/spells/textarea-autosize.svelte.ts @@ -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 = (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; + }; + }; +} diff --git a/src/lib/utils/fuzzy-search.ts b/src/lib/utils/fuzzy-search.ts new file mode 100644 index 0000000..69299fe --- /dev/null +++ b/src/lib/utils/fuzzy-search.ts @@ -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(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; +} diff --git a/src/lib/utils/object.ts b/src/lib/utils/object.ts index 12c8749..a9a2b70 100644 --- a/src/lib/utils/object.ts +++ b/src/lib/utils/object.ts @@ -20,3 +20,39 @@ export function pick(obj: T, keys: K[]): Pi 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, mapper: (key: KIn, value: VIn) => [KOut, VOut]): Record { + const result: Record = {} as Record; + + 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; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d78b20e..ee0dbaa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { ModeWatcher } from 'mode-watcher'; import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { models } from '$lib/state/models.svelte'; + import GlobalModal from '$lib/components/ui/modal/global-modal.svelte'; let { children } = $props(); @@ -13,3 +14,5 @@ {@render children()} + + diff --git a/src/routes/account/customization/rule.svelte b/src/routes/account/customization/rule.svelte index 228ff3e..f5b94b3 100644 --- a/src/routes/account/customization/rule.svelte +++ b/src/routes/account/customization/rule.svelte @@ -10,6 +10,7 @@ import { LocalToasts } from '$lib/builders/local-toasts.svelte'; import { ResultAsync } from 'neverthrow'; import TrashIcon from '~icons/lucide/trash'; + import { callModal } from '$lib/components/ui/modal/global-modal.svelte'; type Props = { rule: Doc<'user_rules'>; @@ -57,6 +58,15 @@ } 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; await client.mutation(api.user_rules.remove, { diff --git a/src/routes/account/models/+page.svelte b/src/routes/account/models/+page.svelte index 8329efe..003c1ec 100644 --- a/src/routes/account/models/+page.svelte +++ b/src/routes/account/models/+page.svelte @@ -11,6 +11,7 @@ import XIcon from '~icons/lucide/x'; import PlusIcon from '~icons/lucide/plus'; import { models } from '$lib/state/models.svelte'; + import fuzzysearch from '$lib/utils/fuzzy-search'; const openRouterKeyQuery = useCachedQuery(api.user_keys.get, { provider: Provider.OpenRouter, @@ -30,12 +31,7 @@ }); const openRouterModels = $derived( - models.from(Provider.OpenRouter).filter((model) => { - if (search !== '' && !hasOpenRouterKey) return false; - if (!openRouterToggle.value) return false; - - return model.name.toLowerCase().includes(search.toLowerCase()); - }) + fuzzysearch({ haystack: models.from(Provider.OpenRouter), needle: search, property: 'name' }) ); diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 662e21e..e6b37ca 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -14,6 +14,7 @@ import { goto } from '$app/navigation'; import { useCachedQuery } from '$lib/cache/cached-query.svelte.js'; import { api } from '$lib/backend/convex/_generated/api.js'; + import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js'; let { data, children } = $props(); @@ -45,6 +46,8 @@ const conversationsQuery = useCachedQuery(api.conversations.get, { session_token: session.current?.session.token ?? '', }); + + const _autosize = new TextareaAutosize(); @@ -108,6 +111,8 @@ }} bind:this={form} > + +