4384315b44
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
153 lines
4.6 KiB
Svelte
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>
|