a bunch of qol stuff

This commit is contained in:
Thomas G. Lopes 2025-06-16 20:42:51 +01:00
parent 6b46c49c57
commit 40bce7bfbd
11 changed files with 524 additions and 3 deletions

View file

@ -54,6 +54,7 @@ IDK, calm down
- [ ] delete conversations option
- [ ] conversation title generation
- [ ] kbd powered popover model picker
- [ ] autosize
### Extra

View file

@ -239,3 +239,169 @@
.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 col-start-1 row-start-1 max-h-screen w-11/12 max-w-[32rem] bg-neutral-100 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-top-left-radius: var(--modal-tl, var(--radius-box));
border-top-right-radius: var(--modal-tr, var(--radius-box));
border-bottom-left-radius: var(--modal-bl, var(--radius-box));
border-bottom-right-radius: var(--modal-br, var(--radius-box));
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;
}
}
}

View 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);
}
};
};
}

View file

@ -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 { Id } from './_generated/dataModel';
import { type Id } from './_generated/dataModel';
export const getAllFromConversation = query({
args: {

View file

@ -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 { Doc } from './_generated/dataModel';
import { type Doc } from './_generated/dataModel';
export const create = mutation({
args: {

View file

@ -0,0 +1,3 @@
import Modal from './modal.svelte';
export { Modal };

View file

@ -0,0 +1,35 @@
<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-body" {@attach clickOutside(() => (open = false))}>
<h3 class="text-lg font-bold">Hello!</h3>
<p class="py-4">Press ESC key or click the button below to close</p>
{@render children()}
<div class="modal-action">
<form method="dialog">
<!-- if there is a button in form, it will close the modal -->
<button class="btn">Close</button>
</form>
</div>
</div>
</dialog>

View file

@ -1,8 +1,14 @@
<script lang="ts">
import { cn } from '$lib/utils/utils';
import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
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>
<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',
className
)}
{@attach (node) => {
if (!autosize) return;
autosizeTextarea.attachment(node);
}}
></textarea>

View 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>

View 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;
};
};
}

View file

@ -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();
</script>
<svelte:head>
@ -106,6 +109,8 @@
}}
bind:this={form}
>
<!-- TODO: Figure out better autofocus solution -->
<!-- svelte-ignore a11y_autofocus -->
<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"