|
1 | 1 | <script lang="ts">
|
2 |
| - import { Settings, Funnel, AlertTriangle, Brain, Cog, Monitor, Sun, Moon } from '@lucide/svelte'; |
3 |
| - import { ChatSettingsFooter, ChatSettingsSection } from '$lib/components/app'; |
4 |
| - import { Checkbox } from '$lib/components/ui/checkbox'; |
| 2 | + import { |
| 3 | + Settings, |
| 4 | + Funnel, |
| 5 | + AlertTriangle, |
| 6 | + Brain, |
| 7 | + Cog, |
| 8 | + Monitor, |
| 9 | + Sun, |
| 10 | + Moon, |
| 11 | + ChevronLeft, |
| 12 | + ChevronRight |
| 13 | + } from '@lucide/svelte'; |
| 14 | + import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app'; |
5 | 15 | import * as Dialog from '$lib/components/ui/dialog';
|
6 |
| - import { Input } from '$lib/components/ui/input'; |
7 |
| - import Label from '$lib/components/ui/label/label.svelte'; |
8 | 16 | import { ScrollArea } from '$lib/components/ui/scroll-area';
|
9 |
| - import * as Select from '$lib/components/ui/select'; |
10 |
| - import { Textarea } from '$lib/components/ui/textarea'; |
11 |
| - import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config'; |
12 |
| - import { supportsVision } from '$lib/stores/server.svelte'; |
| 17 | + import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; |
13 | 18 | import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte';
|
14 | 19 | import { setMode } from 'mode-watcher';
|
15 | 20 | import type { Component } from 'svelte';
|
|
224 | 229 | let localConfig: SettingsConfigType = $state({ ...config() });
|
225 | 230 | let originalTheme: string = $state('');
|
226 | 231 |
|
| 232 | + let canScrollLeft = $state(false); |
| 233 | + let canScrollRight = $state(false); |
| 234 | + let scrollContainer: HTMLDivElement | undefined = $state(); |
| 235 | +
|
227 | 236 | function handleThemeChange(newTheme: string) {
|
228 | 237 | localConfig.theme = newTheme;
|
229 | 238 |
|
230 | 239 | setMode(newTheme as 'light' | 'dark' | 'system');
|
231 | 240 | }
|
232 | 241 |
|
| 242 | + function handleConfigChange(key: string, value: string | boolean) { |
| 243 | + localConfig[key] = value; |
| 244 | + } |
| 245 | +
|
233 | 246 | function handleClose() {
|
234 | 247 | if (localConfig.theme !== originalTheme) {
|
235 | 248 | setMode(originalTheme as 'light' | 'dark' | 'system');
|
|
298 | 311 | onOpenChange?.(false);
|
299 | 312 | }
|
300 | 313 |
|
| 314 | + function scrollToCenter(element: HTMLElement) { |
| 315 | + if (!scrollContainer) return; |
| 316 | +
|
| 317 | + const containerRect = scrollContainer.getBoundingClientRect(); |
| 318 | + const elementRect = element.getBoundingClientRect(); |
| 319 | +
|
| 320 | + const elementCenter = elementRect.left + elementRect.width / 2; |
| 321 | + const containerCenter = containerRect.left + containerRect.width / 2; |
| 322 | + const scrollOffset = elementCenter - containerCenter; |
| 323 | +
|
| 324 | + scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' }); |
| 325 | + } |
| 326 | +
|
| 327 | + function scrollLeft() { |
| 328 | + if (!scrollContainer) return; |
| 329 | +
|
| 330 | + scrollContainer.scrollBy({ left: -250, behavior: 'smooth' }); |
| 331 | + } |
| 332 | +
|
| 333 | + function scrollRight() { |
| 334 | + if (!scrollContainer) return; |
| 335 | +
|
| 336 | + scrollContainer.scrollBy({ left: 250, behavior: 'smooth' }); |
| 337 | + } |
| 338 | +
|
| 339 | + function updateScrollButtons() { |
| 340 | + if (!scrollContainer) return; |
| 341 | +
|
| 342 | + const { scrollLeft, scrollWidth, clientWidth } = scrollContainer; |
| 343 | + canScrollLeft = scrollLeft > 0; |
| 344 | + canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding |
| 345 | + } |
| 346 | +
|
301 | 347 | $effect(() => {
|
302 | 348 | if (open) {
|
303 | 349 | localConfig = { ...config() };
|
304 | 350 | originalTheme = config().theme as string;
|
| 351 | +
|
| 352 | + setTimeout(updateScrollButtons, 100); |
| 353 | + } |
| 354 | + }); |
| 355 | +
|
| 356 | + $effect(() => { |
| 357 | + if (scrollContainer) { |
| 358 | + updateScrollButtons(); |
305 | 359 | }
|
306 | 360 | });
|
307 | 361 | </script>
|
308 | 362 |
|
309 | 363 | <Dialog.Root {open} onOpenChange={handleClose}>
|
310 |
| - <Dialog.Content class="flex h-[64vh] flex-col gap-0 p-0" style="max-width: 48rem;"> |
311 |
| - <div class="flex flex-1 overflow-hidden"> |
312 |
| - <div class="w-64 border-r border-border/30 p-6"> |
| 364 | + <Dialog.Content |
| 365 | + class="z-999999 flex h-[100vh] flex-col gap-0 rounded-none p-0 md:h-[64vh] md:rounded-lg" |
| 366 | + style="max-width: 48rem;" |
| 367 | + > |
| 368 | + <div class="flex flex-1 flex-col overflow-hidden md:flex-row"> |
| 369 | + <!-- Desktop Sidebar --> |
| 370 | + <div class="hidden w-64 border-r border-border/30 p-6 md:block"> |
313 | 371 | <nav class="space-y-1 py-2">
|
314 | 372 | <Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
|
315 | 373 |
|
|
329 | 387 | </nav>
|
330 | 388 | </div>
|
331 | 389 |
|
332 |
| - <ScrollArea class="flex-1"> |
333 |
| - <div class="space-y-6 p-6"> |
334 |
| - <ChatSettingsSection title={currentSection.title} Icon={currentSection.icon}> |
335 |
| - {#each currentSection.fields as field (field.key)} |
336 |
| - <div class="space-y-2"> |
337 |
| - {#if field.type === 'input'} |
338 |
| - <Label for={field.key} class="block text-sm font-medium"> |
339 |
| - {field.label} |
340 |
| - </Label> |
341 |
| - |
342 |
| - <Input |
343 |
| - id={field.key} |
344 |
| - value={String(localConfig[field.key] || '')} |
345 |
| - onchange={(e) => (localConfig[field.key] = e.currentTarget.value)} |
346 |
| - placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`} |
347 |
| - class="max-w-md" |
348 |
| - /> |
349 |
| - {#if field.help || SETTING_CONFIG_INFO[field.key]} |
350 |
| - <p class="mt-1 text-xs text-muted-foreground"> |
351 |
| - {field.help || SETTING_CONFIG_INFO[field.key]} |
352 |
| - </p> |
353 |
| - {/if} |
354 |
| - {:else if field.type === 'textarea'} |
355 |
| - <Label for={field.key} class="block text-sm font-medium"> |
356 |
| - {field.label} |
357 |
| - </Label> |
358 |
| - |
359 |
| - <Textarea |
360 |
| - id={field.key} |
361 |
| - value={String(localConfig[field.key] || '')} |
362 |
| - onchange={(e) => (localConfig[field.key] = e.currentTarget.value)} |
363 |
| - placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`} |
364 |
| - class="min-h-[100px] max-w-2xl" |
365 |
| - /> |
366 |
| - {#if field.help || SETTING_CONFIG_INFO[field.key]} |
367 |
| - <p class="mt-1 text-xs text-muted-foreground"> |
368 |
| - {field.help || SETTING_CONFIG_INFO[field.key]} |
369 |
| - </p> |
370 |
| - {/if} |
371 |
| - {:else if field.type === 'select'} |
372 |
| - {@const selectedOption = field.options?.find( |
373 |
| - (opt: { value: string; label: string; icon?: Component }) => |
374 |
| - opt.value === localConfig[field.key] |
375 |
| - )} |
376 |
| - |
377 |
| - <Label for={field.key} class="block text-sm font-medium"> |
378 |
| - {field.label} |
379 |
| - </Label> |
380 |
| - |
381 |
| - <Select.Root |
382 |
| - type="single" |
383 |
| - value={localConfig[field.key]} |
384 |
| - onValueChange={(value) => { |
385 |
| - if (field.key === 'theme' && value) { |
386 |
| - handleThemeChange(value); |
387 |
| - } else { |
388 |
| - localConfig[field.key] = value; |
389 |
| - } |
| 390 | + <!-- Mobile Header with Horizontal Scrollable Menu --> |
| 391 | + <div class="flex flex-col md:hidden"> |
| 392 | + <div class="border-b border-border/30 py-4"> |
| 393 | + <Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title> |
| 394 | + |
| 395 | + <!-- Horizontal Scrollable Category Menu with Navigation --> |
| 396 | + <div class="relative flex items-center" style="scroll-padding: 1rem;"> |
| 397 | + <button |
| 398 | + class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft |
| 399 | + ? 'opacity-100' |
| 400 | + : 'pointer-events-none opacity-0'}" |
| 401 | + onclick={scrollLeft} |
| 402 | + aria-label="Scroll left" |
| 403 | + > |
| 404 | + <ChevronLeft class="h-4 w-4" /> |
| 405 | + </button> |
| 406 | + |
| 407 | + <div |
| 408 | + class="scrollbar-hide overflow-x-auto py-2" |
| 409 | + bind:this={scrollContainer} |
| 410 | + onscroll={updateScrollButtons} |
| 411 | + > |
| 412 | + <div class="flex min-w-max gap-2"> |
| 413 | + {#each settingSections as section (section.title)} |
| 414 | + <button |
| 415 | + class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection === |
| 416 | + section.title |
| 417 | + ? 'bg-accent text-accent-foreground' |
| 418 | + : 'text-muted-foreground'}" |
| 419 | + onclick={(e: MouseEvent) => { |
| 420 | + activeSection = section.title; |
| 421 | + scrollToCenter(e.currentTarget as HTMLElement); |
390 | 422 | }}
|
391 | 423 | >
|
392 |
| - <Select.Trigger class="max-w-md"> |
393 |
| - <div class="flex items-center gap-2"> |
394 |
| - {#if selectedOption?.icon} |
395 |
| - {@const IconComponent = selectedOption.icon} |
396 |
| - <IconComponent class="h-4 w-4" /> |
397 |
| - {/if} |
398 |
| - |
399 |
| - {selectedOption?.label || `Select ${field.label.toLowerCase()}`} |
400 |
| - </div> |
401 |
| - </Select.Trigger> |
402 |
| - <Select.Content> |
403 |
| - {#if field.options} |
404 |
| - {#each field.options as option (option.value)} |
405 |
| - <Select.Item value={option.value} label={option.label}> |
406 |
| - <div class="flex items-center gap-2"> |
407 |
| - {#if option.icon} |
408 |
| - {@const IconComponent = option.icon} |
409 |
| - <IconComponent class="h-4 w-4" /> |
410 |
| - {/if} |
411 |
| - {option.label} |
412 |
| - </div> |
413 |
| - </Select.Item> |
414 |
| - {/each} |
415 |
| - {/if} |
416 |
| - </Select.Content> |
417 |
| - </Select.Root> |
418 |
| - {#if field.help || SETTING_CONFIG_INFO[field.key]} |
419 |
| - <p class="mt-1 text-xs text-muted-foreground"> |
420 |
| - {field.help || SETTING_CONFIG_INFO[field.key]} |
421 |
| - </p> |
422 |
| - {/if} |
423 |
| - {:else if field.type === 'checkbox'} |
424 |
| - {@const isDisabled = field.key === 'pdfAsImage' && !supportsVision()} |
425 |
| - <div class="flex items-start space-x-3"> |
426 |
| - <Checkbox |
427 |
| - id={field.key} |
428 |
| - checked={Boolean(localConfig[field.key])} |
429 |
| - disabled={isDisabled} |
430 |
| - onCheckedChange={(checked) => (localConfig[field.key] = checked)} |
431 |
| - class="mt-1" |
432 |
| - /> |
433 |
| - |
434 |
| - <div class="space-y-1"> |
435 |
| - <label |
436 |
| - for={field.key} |
437 |
| - class="cursor-pointer text-sm leading-none font-medium {isDisabled |
438 |
| - ? 'text-muted-foreground' |
439 |
| - : ''}" |
440 |
| - > |
441 |
| - {field.label} |
442 |
| - </label> |
443 |
| - |
444 |
| - {#if field.help || SETTING_CONFIG_INFO[field.key]} |
445 |
| - <p class="text-xs text-muted-foreground"> |
446 |
| - {field.help || SETTING_CONFIG_INFO[field.key]} |
447 |
| - </p> |
448 |
| - {:else if field.key === 'pdfAsImage' && !supportsVision()} |
449 |
| - <p class="text-xs text-muted-foreground"> |
450 |
| - PDF-to-image processing requires a vision-capable model. PDFs will be |
451 |
| - processed as text. |
452 |
| - </p> |
453 |
| - {/if} |
454 |
| - </div> |
455 |
| - </div> |
456 |
| - {/if} |
| 424 | + <section.icon class="h-4 w-4 flex-shrink-0" /> |
| 425 | + <span>{section.title}</span> |
| 426 | + </button> |
| 427 | + {/each} |
457 | 428 | </div>
|
458 |
| - {/each} |
459 |
| - </ChatSettingsSection> |
| 429 | + </div> |
| 430 | + |
| 431 | + <button |
| 432 | + class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight |
| 433 | + ? 'opacity-100' |
| 434 | + : 'pointer-events-none opacity-0'}" |
| 435 | + onclick={scrollRight} |
| 436 | + aria-label="Scroll right" |
| 437 | + > |
| 438 | + <ChevronRight class="h-4 w-4" /> |
| 439 | + </button> |
| 440 | + </div> |
| 441 | + </div> |
| 442 | + </div> |
| 443 | + |
| 444 | + <ScrollArea class="max-h-[calc(100vh-13.5rem)] flex-1"> |
| 445 | + <div class="space-y-6 p-4 md:p-6"> |
| 446 | + <div> |
| 447 | + <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex"> |
| 448 | + <currentSection.icon class="h-5 w-5" /> |
| 449 | + |
| 450 | + <h3 class="text-lg font-semibold">{currentSection.title}</h3> |
| 451 | + </div> |
| 452 | + |
| 453 | + <div class="space-y-6"> |
| 454 | + <ChatSettingsFields |
| 455 | + fields={currentSection.fields} |
| 456 | + {localConfig} |
| 457 | + onConfigChange={handleConfigChange} |
| 458 | + onThemeChange={handleThemeChange} |
| 459 | + isMobile={false} |
| 460 | + /> |
| 461 | + </div> |
| 462 | + </div> |
460 | 463 |
|
461 | 464 | <div class="mt-8 border-t pt-6">
|
462 | 465 | <p class="text-xs text-muted-foreground">
|
|
467 | 470 | </ScrollArea>
|
468 | 471 | </div>
|
469 | 472 |
|
470 |
| - <ChatSettingsFooter onClose={handleClose} onReset={handleReset} onSave={handleSave} /> |
| 473 | + <ChatSettingsFooter onReset={handleReset} onSave={handleSave} /> |
471 | 474 | </Dialog.Content>
|
472 | 475 | </Dialog.Root>
|
0 commit comments