Skip to content

Commit 558a72d

Browse files
authored
UI Improvements (#219)
- use react-resizable-panels for UI - improve icons for buttons - improve mobile layout with drag/resize panels
1 parent dc42cf3 commit 558a72d

File tree

6 files changed

+264
-147
lines changed

6 files changed

+264
-147
lines changed

ui/package-lock.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"@tanstack/react-query": "^5.80.6",
1515
"react": "^19.1.0",
1616
"react-dom": "^19.1.0",
17+
"react-icons": "^5.5.0",
18+
"react-resizable-panels": "^3.0.4",
1719
"react-router-dom": "^7.6.2",
1820
"tailwindcss": "^4.1.8"
1921
},
@@ -30,4 +32,4 @@
3032
"typescript-eslint": "^8.30.1",
3133
"vite": "^6.3.5"
3234
}
33-
}
35+
}

ui/src/App.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@ import { APIProvider } from "./contexts/APIProvider";
44
import LogViewerPage from "./pages/LogViewer";
55
import ModelPage from "./pages/Models";
66
import ActivityPage from "./pages/Activity";
7+
import { RiSunFill, RiMoonFill } from "react-icons/ri";
78

89
function App() {
9-
const theme = useTheme();
10+
const { isNarrow, toggleTheme, isDarkMode } = useTheme();
11+
1012
return (
1113
<Router basename="/ui/">
1214
<APIProvider>
13-
<div>
15+
<div className="flex flex-col h-screen">
1416
<nav className="bg-surface border-b border-border p-2 h-[75px]">
1517
<div className="flex items-center justify-between mx-auto px-4 h-full">
16-
<h1 className="flex items-center p-0">llama-swap</h1>
18+
{!isNarrow && <h1 className="flex items-center p-0">llama-swap</h1>}
1719
<div className="flex items-center space-x-4">
1820
<NavLink to="/" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
1921
Logs
@@ -26,14 +28,14 @@ function App() {
2628
<NavLink to="/activity" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
2729
Activity
2830
</NavLink>
29-
<button className="btn btn--sm" onClick={theme.toggleTheme}>
30-
{theme.isDarkMode ? "🌙" : "☀️"}
31+
<button className="" onClick={toggleTheme}>
32+
{isDarkMode ? <RiMoonFill /> : <RiSunFill />}
3133
</button>
3234
</div>
3335
</div>
3436
</nav>
3537

36-
<main className="mx-auto py-4 px-4">
38+
<main className="flex-1 overflow-auto p-4">
3739
<Routes>
3840
<Route path="/" element={<LogViewerPage />} />
3941
<Route path="/models" element={<ModelPage />} />

ui/src/contexts/ThemeProvider.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { createContext, useContext, useEffect, type ReactNode } from "react";
1+
import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react";
22
import { usePersistentState } from "../hooks/usePersistentState";
33

4+
type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
45
type ThemeContextType = {
56
isDarkMode: boolean;
7+
screenWidth: ScreenWidth;
8+
isNarrow: boolean;
69
toggleTheme: () => void;
710
};
811

@@ -14,14 +17,46 @@ type ThemeProviderProps = {
1417

1518
export function ThemeProvider({ children }: ThemeProviderProps) {
1619
const [isDarkMode, setIsDarkMode] = usePersistentState<boolean>("theme", false);
20+
const [screenWidth, setScreenWidth] = useState<ScreenWidth>("md"); // Default to md
21+
22+
// matches tailwind classes
23+
// https://tailwindcss.com/docs/responsive-design
24+
useEffect(() => {
25+
const checkInnerWidth = () => {
26+
const innerWidth = window.innerWidth;
27+
if (innerWidth < 640) {
28+
setScreenWidth("xs");
29+
} else if (innerWidth < 768) {
30+
setScreenWidth("sm");
31+
} else if (innerWidth < 1024) {
32+
setScreenWidth("md");
33+
} else if (innerWidth < 1280) {
34+
setScreenWidth("lg");
35+
} else if (innerWidth < 1536) {
36+
setScreenWidth("xl");
37+
} else {
38+
setScreenWidth("2xl");
39+
}
40+
};
41+
42+
checkInnerWidth();
43+
window.addEventListener("resize", checkInnerWidth);
44+
45+
return () => window.removeEventListener("resize", checkInnerWidth);
46+
}, []);
1747

1848
useEffect(() => {
1949
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
2050
}, [isDarkMode]);
2151

2252
const toggleTheme = () => setIsDarkMode((prev) => !prev);
53+
const isNarrow = useMemo(() => {
54+
return screenWidth === "xs" || screenWidth === "sm" || screenWidth === "md";
55+
}, [screenWidth]);
2356

24-
return <ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>{children}</ThemeContext.Provider>;
57+
return (
58+
<ThemeContext.Provider value={{ isDarkMode, toggleTheme, screenWidth, isNarrow }}>{children}</ThemeContext.Provider>
59+
);
2560
}
2661

2762
export function useTheme(): ThemeContextType {

ui/src/pages/LogViewer.tsx

Lines changed: 78 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,54 @@
11
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
22
import { useAPI } from "../contexts/APIProvider";
33
import { usePersistentState } from "../hooks/usePersistentState";
4+
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
5+
import {
6+
RiTextWrap,
7+
RiAlignJustify,
8+
RiFontSize,
9+
RiMenuSearchLine,
10+
RiMenuSearchFill,
11+
RiCloseCircleFill,
12+
} from "react-icons/ri";
13+
import { useTheme } from "../contexts/ThemeProvider";
414

515
const LogViewer = () => {
616
const { proxyLogs, upstreamLogs } = useAPI();
17+
const { isNarrow } = useTheme();
18+
const direction = isNarrow ? "vertical" : "horizontal";
719

820
return (
9-
<div className="flex flex-col lg:flex-row gap-5" style={{ height: "calc(100vh - 125px)" }}>
10-
<LogPanel id="proxy" title="Proxy Logs" logData={proxyLogs} />
11-
<LogPanel id="upstream" title="Upstream Logs" logData={upstreamLogs} />
12-
</div>
21+
<PanelGroup direction={direction} className="gap-2" autoSaveId={`logviewer-panel-group-${direction}`}>
22+
<Panel id="proxy" defaultSize={50} minSize={5} maxSize={100} collapsible={true}>
23+
<LogPanel id="proxy" title="Proxy Logs" logData={proxyLogs} />
24+
</Panel>
25+
<PanelResizeHandle
26+
className={
27+
direction === "horizontal"
28+
? "w-2 h-full bg-primary hover:bg-success transition-colors rounded"
29+
: "w-full h-2 bg-primary hover:bg-success transition-colors rounded"
30+
}
31+
/>
32+
<Panel id="upstream" defaultSize={50} minSize={5} maxSize={100} collapsible={true}>
33+
<LogPanel id="upstream" title="Upstream Logs" logData={upstreamLogs} />
34+
</Panel>
35+
</PanelGroup>
1336
);
1437
};
1538

1639
interface LogPanelProps {
1740
id: string;
1841
title: string;
1942
logData: string;
20-
className?: string;
2143
}
22-
23-
export const LogPanel = ({ id, title, logData, className }: LogPanelProps) => {
24-
const [isCollapsed, setIsCollapsed] = usePersistentState(`logPanel-${id}-isCollapsed`, false);
44+
export const LogPanel = ({ id, title, logData }: LogPanelProps) => {
2545
const [filterRegex, setFilterRegex] = useState("");
2646
const [fontSize, setFontSize] = usePersistentState<"xxs" | "xs" | "small" | "normal">(
2747
`logPanel-${id}-fontSize`,
2848
"normal"
2949
);
3050
const [wrapText, setTextWrap] = usePersistentState(`logPanel-${id}-wrapText`, false);
51+
const [showFilter, setShowFilter] = usePersistentState(`logPanel-${id}-showFilter`, false);
3152

3253
const textWrapClass = useMemo(() => {
3354
return wrapText ? "whitespace-pre-wrap" : "whitespace-pre";
@@ -48,6 +69,19 @@ export const LogPanel = ({ id, title, logData, className }: LogPanelProps) => {
4869
});
4970
}, []);
5071

72+
const toggleWrapText = useCallback(() => {
73+
setTextWrap((prev) => !prev);
74+
}, []);
75+
76+
const toggleFilter = useCallback(() => {
77+
if (showFilter) {
78+
setShowFilter(false);
79+
setFilterRegex(""); // Clear filter when closing
80+
} else {
81+
setShowFilter(true);
82+
}
83+
}, [filterRegex, setFilterRegex, showFilter]);
84+
5185
const fontSizeClass = useMemo(() => {
5286
switch (fontSize) {
5387
case "xxs":
@@ -81,58 +115,47 @@ export const LogPanel = ({ id, title, logData, className }: LogPanelProps) => {
81115
}, [filteredLogs]);
82116

83117
return (
84-
<div
85-
className={`bg-surface border border-border rounded-lg overflow-hidden flex flex-col ${
86-
!isCollapsed && "h-full"
87-
} ${className || ""}`}
88-
>
118+
<div className="bg-surface border border-border rounded-lg overflow-hidden flex flex-col h-full">
89119
<div className="p-4 border-b border-border bg-secondary">
90-
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
91-
{/* Title - Always full width on mobile, normal on desktop */}
92-
<div className="w-full md:w-auto" onClick={() => setIsCollapsed(!isCollapsed)}>
93-
<h3 className="m-0 text-lg p-0">{title}</h3>
94-
</div>
120+
<div className="flex items-center justify-between">
121+
<h3 className="m-0 text-lg p-0">{title}</h3>
95122

96-
{!isCollapsed && (
97-
<div className="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
98-
{/* Sizing Buttons - Stacks vertically on mobile */}
99-
<div className="flex flex-wrap gap-2">
100-
<button className="btn" onClick={toggleFontSize}>
101-
font: {fontSize}
102-
</button>
103-
<button className="btn" onClick={() => setTextWrap((prev) => !prev)}>
104-
{wrapText ? "wrap" : "wrap off"}
105-
</button>
106-
</div>
123+
<div className="flex gap-2 items-center">
124+
<button className="btn" onClick={toggleFontSize}>
125+
<RiFontSize />
126+
</button>
127+
<button className="btn" onClick={toggleWrapText}>
128+
{wrapText ? <RiTextWrap /> : <RiAlignJustify />}
129+
</button>
130+
<button className="btn" onClick={toggleFilter}>
131+
{showFilter ? <RiMenuSearchFill /> : <RiMenuSearchLine />}
132+
</button>
133+
</div>
134+
</div>
107135

108-
{/* Filtering Options - Full width on mobile, normal on desktop */}
109-
<div className="flex flex-1 min-w-0 gap-2">
110-
<input
111-
type="text"
112-
className="flex-1 min-w-[120px] text-sm border p-2 rounded"
113-
placeholder="Filter logs..."
114-
value={filterRegex}
115-
onChange={(e) => setFilterRegex(e.target.value)}
116-
/>
117-
<button className="btn" onClick={() => setFilterRegex("")}>
118-
Clear
119-
</button>
120-
</div>
136+
{/* Filtering Options - Full width on mobile, normal on desktop */}
137+
{showFilter && (
138+
<div className="mt-2 w-full">
139+
<div className="flex gap-2 items-center w-full">
140+
<input
141+
type="text"
142+
className="w-full text-sm border p-2 rounded"
143+
placeholder="Filter logs..."
144+
value={filterRegex}
145+
onChange={(e) => setFilterRegex(e.target.value)}
146+
/>
147+
<button className="pl-2" onClick={() => setFilterRegex("")}>
148+
<RiCloseCircleFill size="24" />
149+
</button>
121150
</div>
122-
)}
123-
</div>
151+
</div>
152+
)}
153+
</div>
154+
<div className="bg-background font-mono text-sm flex-1 overflow-hidden">
155+
<pre ref={preTagRef} className={`${textWrapClass} ${fontSizeClass} h-full overflow-auto p-4`}>
156+
{filteredLogs}
157+
</pre>
124158
</div>
125-
126-
{!isCollapsed && (
127-
<div className="flex-1 bg-background font-mono text-sm p-3 overflow-hidden">
128-
<pre
129-
ref={preTagRef}
130-
className={`h-full p-4 overflow-y-auto whitespace-pre min-h-0 ${textWrapClass} ${fontSizeClass}`}
131-
>
132-
{filteredLogs}
133-
</pre>
134-
</div>
135-
)}
136159
</div>
137160
);
138161
};

0 commit comments

Comments
 (0)