Files
llama-swap/ui-svelte/src/components/ResizablePanels.svelte
T
Benson Wong 4384315b44 ui-svelte: add Svelte port of React UI (#487)
Trying out svelte for the UI. The port was done by Claude Code on the iOS app w/ Opus 4.5. 

---

* ui: add Svelte port of React UI

Port the React-based UI to Svelte 5 with the following changes:

- Create new ui-svelte directory with complete Svelte 5 implementation
- Use Svelte stores instead of React contexts for state management
- Implement custom ResizablePanels component to replace react-resizable-panels
- Port all pages: LogViewer, Models, Activity
- Port all components: Header, ConnectionStatus, LogPanel, ModelsPanel, etc.
- Use svelte-spa-router for client-side routing
- Same build output directory (proxy/ui_dist) and base path (/ui/)
- Tailwind CSS 4 with same theme configuration

https://claude.ai/code/session_01F3xXLYsd62gePVSFv7aboP

* ui-svelte: simplify state management

- Remove redundant state syncing pattern in LogPanel and ModelsPanel
- Use store values directly with $ syntax instead of manual subscriptions
- Consolidate duplicate title sync logic in App.svelte
- Use existing syncTitleToDocument() from theme.ts

https://claude.ai/code/session_01F3xXLYsd62gePVSFv7aboP

* ui-svelte: use idiomatic Svelte 5 patterns

- Use $effect for document side effects (theme, title) instead of
  store subscriptions
- Use class: directive for active nav links in Header
- Remove SSR guards (unnecessary for client-only SPA)
- Remove leaked subscription in syncThemeToDocument
- Simplify theme.ts by removing sync functions

https://claude.ai/code/session_01F3xXLYsd62gePVSFv7aboP

* ui-svelte: fix build warnings and improve accessibility

Fix Svelte build warnings and add proper accessibility support
to interactive components.

- add aria-labels to buttons for screen readers
- implement keyboard navigation for resizable separator
- suppress intentional state initialization warnings
- update Makefile to use ui-svelte build directory
- add peer:true to package-lock.json dependencies

* ui-svelte: reorganize navigation and add log view toggle

Make Models the default landing page and add view mode toggle
to the Logs page with persistent state.

- set Models as default route at /
- move Logs to /logs route
- reorder navigation: Models, Activity, Logs
- add view toggle with three modes: Panels, Proxy only, Upstream only
- fix horizontal overflow with width constraints
2026-01-28 21:37:29 -08:00

153 lines
4.6 KiB
Svelte

<script lang="ts">
import type { Snippet } from "svelte";
import { onMount } from "svelte";
interface Props {
direction: "horizontal" | "vertical";
storageKey: string;
leftPanel: Snippet;
rightPanel: Snippet;
defaultSize?: number;
minSize?: number;
}
let { direction, storageKey, leftPanel, rightPanel, defaultSize = 50, minSize = 5 }: Props = $props();
let containerRef: HTMLDivElement;
let isDragging = $state(false);
// svelte-ignore state_referenced_locally
let leftSize = $state(defaultSize);
// Load saved size from localStorage
onMount(() => {
const saved = localStorage.getItem(`panel-size-${storageKey}`);
if (saved) {
const parsed = parseFloat(saved);
if (!isNaN(parsed) && parsed >= minSize && parsed <= 100 - minSize) {
leftSize = parsed;
}
}
});
function saveSize(): void {
localStorage.setItem(`panel-size-${storageKey}`, String(leftSize));
}
function handleMouseDown(e: MouseEvent): void {
e.preventDefault();
isDragging = true;
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
function handleTouchStart(_e: TouchEvent): void {
isDragging = true;
document.addEventListener("touchmove", handleTouchMove);
document.addEventListener("touchend", handleTouchEnd);
}
function handleMouseMove(e: MouseEvent): void {
if (!isDragging || !containerRef) return;
updateSize(e.clientX, e.clientY);
}
function handleTouchMove(e: TouchEvent): void {
if (!isDragging || !containerRef || e.touches.length === 0) return;
updateSize(e.touches[0].clientX, e.touches[0].clientY);
}
function updateSize(clientX: number, clientY: number): void {
const rect = containerRef.getBoundingClientRect();
let newSize: number;
if (direction === "horizontal") {
newSize = ((clientX - rect.left) / rect.width) * 100;
} else {
newSize = ((clientY - rect.top) / rect.height) * 100;
}
// Clamp size
newSize = Math.max(minSize, Math.min(100 - minSize, newSize));
leftSize = newSize;
}
function handleMouseUp(): void {
isDragging = false;
saveSize();
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
function handleTouchEnd(): void {
isDragging = false;
saveSize();
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
}
function handleKeyDown(e: KeyboardEvent): void {
const step = 2; // 2% increment for keyboard navigation
const key = e.key;
if (direction === "horizontal" && (key === "ArrowLeft" || key === "ArrowRight")) {
e.preventDefault();
const delta = key === "ArrowLeft" ? -step : step;
const newSize = Math.max(minSize, Math.min(100 - minSize, leftSize + delta));
leftSize = newSize;
saveSize();
} else if (direction === "vertical" && (key === "ArrowUp" || key === "ArrowDown")) {
e.preventDefault();
const delta = key === "ArrowUp" ? -step : step;
const newSize = Math.max(minSize, Math.min(100 - minSize, leftSize + delta));
leftSize = newSize;
saveSize();
}
}
let containerClass = $derived(direction === "horizontal" ? "flex-row" : "flex-col");
let handleClass = $derived(
direction === "horizontal"
? "w-2 h-full cursor-col-resize"
: "w-full h-2 cursor-row-resize"
);
let leftStyle = $derived(
direction === "horizontal"
? `width: ${leftSize}%; min-width: ${minSize}%`
: `height: ${leftSize}%; min-height: ${minSize}%`
);
let rightStyle = $derived(
direction === "horizontal"
? `width: ${100 - leftSize}%; min-width: ${minSize}%`
: `height: ${100 - leftSize}%; min-height: ${minSize}%`
);
</script>
<div bind:this={containerRef} class="flex {containerClass} h-full w-full gap-2">
<div style={leftStyle} class="overflow-hidden">
{@render leftPanel()}
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
role="separator"
tabindex="0"
class="{handleClass} bg-primary hover:bg-success transition-colors rounded flex-shrink-0"
onmousedown={handleMouseDown}
ontouchstart={handleTouchStart}
onkeydown={handleKeyDown}
aria-label="Resize panels"
aria-orientation={direction}
aria-valuenow={Math.round(leftSize)}
aria-valuemin={minSize}
aria-valuemax={100 - minSize}
></div>
<div style={rightStyle} class="overflow-hidden">
{@render rightPanel()}
</div>
</div>