fix: side bar height
CI / CI - Validation Technique (push) Successful in 36s
Publish & Deploy (Versioned) / Build and Push Docker Image (push) Successful in 30s
Publish & Deploy (Versioned) / Deploy via SSH (push) Successful in 8s

This commit is contained in:
2026-04-28 18:27:08 +02:00
parent e2266b7bd7
commit c4ebb599b6
11 changed files with 495 additions and 162 deletions
+1 -2
View File
@@ -141,5 +141,4 @@ git tag -d v1.0.0; git push origin :refs/tags/v1.0.0; git tag -a v1.0.0 -m "Rele
## To implement ## To implement
- Better Tailwind CSS classes - Token refresh
- See keycloak user infos
+201 -26
View File
@@ -31,38 +31,213 @@
// ) // )
// } // }
'use client' // 'use client';
import { useEffect, useState } from 'react' // import { useEffect, useState } from 'react';
import Spinner from '@/components/spinner' // import Spinner from '@/components/spinner';
export default function AboutPage() { // export default function AboutPage() {
const [data, setData] = useState<any>(null) // const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true) // const [loading, setLoading] = useState(true);
useEffect(() => { // useEffect(() => {
fetch((process.env.NEXT_PUBLIC_API_URL as string) + '/docs.json') // fetch((process.env.NEXT_PUBLIC_API_URL as string) + '/docs.json')
.then((res) => res.json()) // .then((res) => res.json())
.then((result) => { // .then((result) => {
setData(result) // setData(result);
setLoading(false) // setLoading(false);
}) // })
.catch(() => { // .catch(() => {
setLoading(false) // setLoading(false);
}) // });
}, []) // }, []);
if (loading) { // if (loading) {
return ( // return (
<div className="flex items-center justify-center min-h-screen"> // <div className="flex items-center justify-center min-h-screen">
<div className="h-12 w-12 rounded-full border-4 border-gray-300 border-t-blue-600 animate-spin" /> // <div className="h-12 w-12 rounded-full border-4 border-gray-300 border-t-blue-600 animate-spin" />
</div> // </div>
) // );
// }
// return (
// <div className="flex items-center justify-center min-h-screen bg-primary text-primary transition-theme">
// <Spinner />
// </div>
// );
// }
'use client';
import Sidebar from '@/components/Sidebar';
import { SidebarPanel } from '@/components/Sidebar';
import {
Slider,
NumberInput,
TextInput,
Toggle,
} from '../../../components/Inputs';
type PanelProps = {
settings: Settings;
setSettings: React.Dispatch<React.SetStateAction<Settings>>;
};
export type Settings = {
temp: number;
topP: number;
topK: number | undefined;
maxTokens: number | undefined;
numCtx: number | undefined;
stop: string[] | undefined;
repeatPenalty: number;
seed: number | undefined;
stream: boolean;
keepAlive: string | undefined;
};
function SettingsPanel({ settings, setSettings }: PanelProps) {
const set =
<K extends keyof Settings>(key: K) =>
(val: Settings[K]) =>
setSettings((prev) => ({ ...prev, [key]: val }));
const sections = [
{
title: 'Sampling',
fields: (s: any, set: any) => (
<>
<Slider
label="Temperature"
value={s.temp}
setValue={set('temp')}
min={0}
max={2}
step={0.1}
/>
<Slider
label="Top P"
value={s.topP}
setValue={set('topP')}
min={0}
max={1}
step={0.05}
/>
<NumberInput label="Top K" value={s.topK} setValue={set('topK')} />
</>
),
},
{
title: 'Generation',
fields: (s: any, set: any) => (
<>
<NumberInput
label="Max Tokens"
value={s.maxTokens}
setValue={set('maxTokens')}
/>
<NumberInput
label="Context Size"
value={s.numCtx}
setValue={set('numCtx')}
/>
<TextInput
label="Stop Sequences"
value={s.stop?.join(', ')}
setValue={(val) =>
set('stop')(val ? val.split(',').map((s) => s.trim()) : undefined)
} }
placeholder="Comma separated"
/>
</>
),
},
{
title: 'Control',
fields: (s: any, set: any) => (
<>
<Slider
label="Repeat Penalty"
value={s.repeatPenalty}
setValue={set('repeatPenalty')}
min={0}
max={2}
step={0.1}
/>
<NumberInput label="Seed" value={s.seed} setValue={set('seed')} />
</>
),
},
{
title: 'System',
fields: (s: any, set: any) => (
<>
<Toggle label="Streaming" value={s.stream} setValue={set('stream')} />
<TextInput
label="Keep Alive"
value={s.keepAlive}
setValue={set('keepAlive')}
placeholder="e.g. 5m"
/>
</>
),
},
];
return ( return (
<div className="flex items-center justify-center min-h-screen bg-primary text-primary transition-theme"> <div className="space-y-6 px-1">
<Spinner/> {sections.map((section) => (
<div key={section.title} className="space-y-4">
<h3 className="text-xs uppercase text-accent tracking-widest">
{section.title}
</h3>
{section.fields(settings, set)}
</div> </div>
) ))}
</div>
);
}
export const SIDEBAR_PANELS = ({
settings,
setSettings,
}: PanelProps): SidebarPanel[] => [
{
id: 'settings',
label: 'Settings',
icon: (
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path
fillRule="evenodd"
d="M7.84 1.804A1 1 0 018.82 1h2.36a1 1 0 01.98.804l.331 1.652a6.993 6.993 0 011.929 1.115l1.598-.54a1 1 0 011.186.447l1.18 2.044a1 1 0 01-.205 1.251l-1.267 1.113a7.047 7.047 0 010 2.228l1.267 1.113a1 1 0 01.205 1.251l-1.18 2.044a1 1 0 01-1.186.447l-1.598-.54a6.993 6.993 0 01-1.929 1.115l-.33 1.652a1 1 0 01-.98.804H8.82a1 1 0 01-.98-.804l-.331-1.652a6.993 6.993 0 01-1.929-1.115l-1.598.54a1 1 0 01-1.186-.447l-1.18-2.044a1 1 0 01.205-1.251l1.267-1.114a7.05 7.05 0 010-2.227L1.821 7.773a1 1 0 01-.206-1.25l1.18-2.045a1 1 0 011.187-.447l1.598.54A6.993 6.993 0 017.51 3.456l.33-1.652zM10 13a3 3 0 100-6 3 3 0 000 6z"
clipRule="evenodd"
/>
</svg>
),
component: <SettingsPanel settings={settings} setSettings={setSettings} />,
},
];
import { useState } from 'react';
export default function DocPage() {
const [settings, setSettings] = useState<Settings>({
temp: 0.7,
topP: 0.9,
topK: 40,
maxTokens: 1000,
numCtx: 128000,
stop: [''],
repeatPenalty: 1.1,
seed: 0,
stream: true,
keepAlive: '5m',
});
return (
<div className="flex flex-1 overflow-hidden min-h-0">
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
</div>
);
} }
+4 -2
View File
@@ -40,11 +40,13 @@ export default async function RootLayout(props: {
return ( return (
<html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}> <html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}>
<body className="min-h-screen flex flex-col bg-primary text-primary transition-theme"> <body className="h-screen flex flex-col bg-primary text-primary transition-theme">
<Providers locale={locale} messages={messages} session={session}> <Providers locale={locale} messages={messages} session={session}>
<Navbar /> <Navbar />
<main className="flex-1 pt-[var(--header-height)] flex flex-col">{props.children}</main> <main className="flex-1 pt-[var(--header-height)] flex overflow-hidden">
{props.children}
</main>
<Footer /> <Footer />
</Providers> </Providers>
+4 -1
View File
@@ -67,7 +67,10 @@ export default function Home() {
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1> <h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1>
<p className="mt-4 text-gray-400">{t('welcome')}</p> <p className="mt-4 text-gray-400">{t('welcome')}</p>
<button className="mt-6 px-6 py-3 bg-cyan-500 rounded-xl" onClick={() => signIn('keycloak')}> <button
className="mt-6 px-6 py-3 bg-cyan-500 rounded-xl"
onClick={() => signIn('keycloak')}
>
{t('start')} {t('start')}
</button> </button>
</div> </div>
+2 -10
View File
@@ -40,11 +40,7 @@ type NumberInputProps = {
setValue: (val: number | undefined) => void; setValue: (val: number | undefined) => void;
}; };
export function NumberInput({ export function NumberInput({ label, value, setValue }: NumberInputProps) {
label,
value,
setValue,
}: NumberInputProps) {
return ( return (
<div> <div>
<label className="text-xs uppercase tracking-widest">{label}</label> <label className="text-xs uppercase tracking-widest">{label}</label>
@@ -93,11 +89,7 @@ type ToggleProps = {
setValue: (val: boolean) => void; setValue: (val: boolean) => void;
}; };
export function Toggle({ export function Toggle({ label, value, setValue }: ToggleProps) {
label,
value,
setValue,
}: ToggleProps) {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm">{label}</span> <span className="text-sm">{label}</span>
+131 -55
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useSyncExternalStore } from 'react'; import { useState, useSyncExternalStore } from 'react';
import { CloseIcon } from './icons/CloseIcon';
export type SidebarPanel = { export type SidebarPanel = {
id: string; id: string;
@@ -9,104 +10,179 @@ export type SidebarPanel = {
component: React.ReactNode; component: React.ReactNode;
}; };
type SidebarProps = {
panels: SidebarPanel[];
};
function useIsWide(): boolean { function useIsWide(): boolean {
return useSyncExternalStore( return useSyncExternalStore(
(cb) => { (cb) => {
window.addEventListener('resize', cb); window.addEventListener('resize', cb);
return () => window.removeEventListener('resize', cb); return () => window.removeEventListener('resize', cb);
}, },
() => window.innerWidth >= 768, // client snapshot () => window.innerWidth >= 768,
() => false, // server snapshot — must match initial SSR HTML () => false,
); );
} }
const CLOSED = '__closed__'; /* -----------------------------
Shared sidebar logic
export default function Sidebar({ panels }: SidebarProps) { ------------------------------*/
const isWide = useIsWide(); function useSidebarState() {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const effectiveId = const setClosed = () => setActiveId(null);
activeId === CLOSED ? null
: activeId ?? (isWide ? (panels[0]?.id ?? null) : null);
const activePanel = panels.find((p) => p.id === effectiveId); const toggle = (id: string, effectiveId: string | null) => {
const isOpen = !!activePanel; setActiveId((prev) =>
prev === id || (effectiveId === id && prev === null) ? null : id,
);
};
const toggle = (id: string) => const resolveEffectiveId = (isWide: boolean, panels: SidebarPanel[]) => {
setActiveId((prev) => (prev === id || (effectiveId === id && prev === null) ? CLOSED : id)); return activeId === null
? null
: (activeId ?? (isWide ? (panels[0]?.id ?? null) : null));
};
return {
activeId,
setActiveId,
setClosed,
toggle,
resolveEffectiveId,
};
}
/* -----------------------------
Desktop UI
------------------------------*/
function DesktopSidebar({
activePanel,
isOpen,
setClosed,
}: {
activePanel?: SidebarPanel;
isOpen: boolean;
setClosed: () => void;
}) {
return ( return (
<div className="flex border-r border-t border-muted">
{/* Expanded panel */}
<div <div
className={` className={`
${isWide flex transition-all duration-300 ease-in-out overflow-hidden
? `flex transition-all duration-300 ease-in-out overflow-hidden ${ ${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'}
isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'
}`
: isOpen
? 'fixed left-14 top-(--var(header-height)) z-30 bg-primary'
: 'hidden'
}
`} `}
> >
{activePanel && ( {activePanel && (
<div <div className="w-64 flex flex-col border-r border-muted">
className={`w-64 flex flex-col border-muted ${ <div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0">
isWide ? 'border-r' : 'border' <span className="text-sm font-semibold text-accent">
}`}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-muted">
<span className="text-sm font-semibold tracking-wide text-accent">
{activePanel.label} {activePanel.label}
</span> </span>
<button <button onClick={setClosed}>
onClick={() => setActiveId(CLOSED)} <CloseIcon className="w-4 h-4" />
className="text-primary hover:text-white transition-colors"
>
<svg viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3 min-h-0">
{activePanel.component} {activePanel.component}
</div> </div>
</div> </div>
)} )}
</div> </div>
);
}
/* -----------------------------
Mobile UI (drawer)
------------------------------*/
function MobileSidebar({
activePanel,
setClosed,
}: {
activePanel?: SidebarPanel;
setClosed: () => void;
}) {
if (!activePanel) return null;
return (
<>
{/* Backdrop */} {/* Backdrop */}
{isOpen && !isWide && ( <div className="fixed inset-0 bg-black/40 z-20" onClick={setClosed} />
<div
className="fixed inset-0 bg-black/40 z-20"
onClick={() => setActiveId(CLOSED)}
/>
)}
{/* Icon rail */} {/* Drawer */}
<div className="fixed left-14 top-[var(--header-height)] bottom-0 z-30 bg-primary overflow-y-auto border border-muted w-64">
<div className="flex items-center justify-between px-4 py-3 border-b border-muted">
<span className="text-sm font-semibold text-accent">
{activePanel.label}
</span>
<button onClick={setClosed}>
<CloseIcon className="w-4 h-4" />
</button>
</div>
<div className="p-3">{activePanel.component}</div>
</div>
</>
);
}
/* -----------------------------
Icon rail (shared)
------------------------------*/
function IconRail({
panels,
effectiveId,
toggle,
}: {
panels: SidebarPanel[];
effectiveId: string | null;
toggle: (id: string, effectiveId: string | null) => void;
}) {
return (
<div className="w-12 flex flex-col items-center py-3 gap-1 z-30"> <div className="w-12 flex flex-col items-center py-3 gap-1 z-30">
{panels.map((panel) => ( {panels.map((panel) => (
<button <button
key={panel.id} key={panel.id}
onClick={() => toggle(panel.id)} onClick={() => toggle(panel.id, effectiveId)}
title={panel.label} title={panel.label}
className={`w-8 h-8 rounded-lg flex items-center justify-center text-primary transition-theme ${ className={`w-8 h-8 rounded-lg flex items-center justify-center transition-theme ${
effectiveId === panel.id effectiveId === panel.id ? 'bg-accent2' : ''
? 'bg-accent2'
: ''
}`} }`}
> >
{panel.icon} {panel.icon}
</button> </button>
))} ))}
</div> </div>
);
}
/* -----------------------------
Main Sidebar (controller)
------------------------------*/
export default function Sidebar({ panels }: { panels: SidebarPanel[] }) {
const isWide = useIsWide();
const { setClosed, toggle, resolveEffectiveId } = useSidebarState();
const effectiveId = resolveEffectiveId(isWide, panels);
const activePanel = panels.find((p) => p.id === effectiveId);
const isOpen = !!activePanel;
return (
<div className="flex border-r border-t border-muted">
{/* Desktop */}
{isWide && (
<DesktopSidebar
activePanel={activePanel}
isOpen={isOpen}
setClosed={setClosed}
/>
)}
{/* Mobile */}
{!isWide && (
<MobileSidebar activePanel={activePanel} setClosed={setClosed} />
)}
{/* Icon rail */}
<IconRail panels={panels} effectiveId={effectiveId} toggle={toggle} />
</div> </div>
); );
} }
+14 -2
View File
@@ -25,6 +25,7 @@ export default function ChatUI({
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [notification, setNotification] = useState<string | null>(null);
const [settings, setSettings] = useState<Settings>({ const [settings, setSettings] = useState<Settings>({
temp: 0.7, temp: 0.7,
topP: 0.9, topP: 0.9,
@@ -34,7 +35,7 @@ export default function ChatUI({
stop: [''], stop: [''],
repeatPenalty: 1.1, repeatPenalty: 1.1,
seed: 0, seed: 0,
stream: true, stream: false,
keepAlive: '5m', keepAlive: '5m',
}); });
@@ -76,8 +77,13 @@ export default function ChatUI({
const data = await res.json(); const data = await res.json();
const assistant = data?.choices?.[0]?.message; const assistant = data?.choices?.[0]?.message;
if (assistant) setMessages((prev) => [...prev, assistant]); if (assistant) setMessages((prev) => [...prev, assistant]);
} catch (err) { } catch (err: any) {
console.error(err); console.error(err);
setNotification(
err?.message || 'Something went wrong while sending request',
);
setTimeout(() => setNotification(null), 3000);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -85,6 +91,12 @@ export default function ChatUI({
return ( return (
<div className="flex flex-1 bg-primary text-primary transition-theme"> <div className="flex flex-1 bg-primary text-primary transition-theme">
{notification && (
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50">
{notification}
</div>
)}
{/* ── Sidebar (icon rail + sliding panel) ── */} {/* ── Sidebar (icon rail + sliding panel) ── */}
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} /> <Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
+94 -24
View File
@@ -48,32 +48,99 @@ function SettingsPanel({ settings, setSettings }: PanelProps) {
(val: Settings[K]) => (val: Settings[K]) =>
setSettings((prev) => ({ ...prev, [key]: val })); setSettings((prev) => ({ ...prev, [key]: val }));
const sections = [
{
title: 'Sampling',
fields: (s: any, set: any) => (
<>
<Slider
label="Temperature"
value={s.temp}
setValue={set('temp')}
min={0}
max={2}
step={0.1}
/>
<Slider
label="Top P"
value={s.topP}
setValue={set('topP')}
min={0}
max={1}
step={0.05}
/>
<NumberInput label="Top K" value={s.topK} setValue={set('topK')} />
</>
),
},
{
title: 'Generation',
fields: (s: any, set: any) => (
<>
<NumberInput
label="Max Tokens"
value={s.maxTokens}
setValue={set('maxTokens')}
/>
<NumberInput
label="Context Size"
value={s.numCtx}
setValue={set('numCtx')}
/>
<TextInput
label="Stop Sequences"
value={s.stop?.join(', ')}
setValue={(val) =>
set('stop')(val ? val.split(',').map((s) => s.trim()) : undefined)
}
placeholder="Comma separated"
/>
</>
),
},
{
title: 'Control',
fields: (s: any, set: any) => (
<>
<Slider
label="Repeat Penalty"
value={s.repeatPenalty}
setValue={set('repeatPenalty')}
min={0}
max={2}
step={0.1}
/>
<NumberInput label="Seed" value={s.seed} setValue={set('seed')} />
</>
),
},
{
title: 'System',
fields: (s: any, set: any) => (
<>
<Toggle label="Streaming" value={s.stream} setValue={set('stream')} />
<TextInput
label="Keep Alive"
value={s.keepAlive}
setValue={set('keepAlive')}
placeholder="e.g. 5m"
/>
</>
),
},
];
return ( return (
<div className="space-y-6 px-1"> <div className="space-y-6 px-1">
<div className="space-y-4"> {sections.map((section) => (
<h3 className="text-xs uppercase text-accent tracking-widest">Sampling</h3> <div key={section.title} className="space-y-4">
<Slider label="Temperature" value={settings.temp} setValue={set('temp')} min={0} max={2} step={0.1} /> <h3 className="text-xs uppercase text-accent tracking-widest">
<Slider label="Top P" value={settings.topP} setValue={set('topP')} min={0} max={1} step={0.05} /> {section.title}
<NumberInput label="Top K" value={settings.topK} setValue={set('topK')} /> </h3>
</div>
<div className="space-y-4"> {section.fields(settings, set)}
<h3 className="text-xs uppercase text-accent tracking-widest">Generation</h3>
<NumberInput label="Max Tokens" value={settings.maxTokens} setValue={set('maxTokens')} />
<NumberInput label="Context Size" value={settings.numCtx} setValue={set('numCtx')} />
<TextInput label="Stop Sequences" value={settings.stop?.join(', ')} setValue={(val) =>
set('stop')(val ? val.split(',').map((s) => s.trim()) : undefined)
} placeholder="Comma separated" />
</div>
<div className="space-y-4">
<h3 className="text-xs uppercase text-accent tracking-widest">Control</h3>
<Slider label="Repeat Penalty" value={settings.repeatPenalty} setValue={set('repeatPenalty')} min={0} max={2} step={0.1} />
<NumberInput label="Seed" value={settings.seed} setValue={set('seed')} />
</div>
<div className="space-y-4">
<h3 className="text-xs uppercase text-accent tracking-widest">System</h3>
<Toggle label="Streaming" value={settings.stream} setValue={set('stream')} />
<TextInput label="Keep Alive" value={settings.keepAlive} setValue={set('keepAlive')} placeholder="e.g. 5m" />
</div> </div>
))}
</div> </div>
); );
} }
@@ -102,7 +169,10 @@ function InfoPanel() {
// ─── Panel registry — add yours here ───────────────────────────────────────── // ─── Panel registry — add yours here ─────────────────────────────────────────
export const SIDEBAR_PANELS = ({ settings, setSettings }: PanelProps): SidebarPanel[] => [ export const SIDEBAR_PANELS = ({
settings,
setSettings,
}: PanelProps): SidebarPanel[] => [
{ {
id: 'history', id: 'history',
label: 'History', label: 'History',
+8
View File
@@ -0,0 +1,8 @@
// src/components/icons/CloseIcon.tsx
export function CloseIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 20 20" fill="currentColor" {...props}>
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
);
}
+1 -5
View File
@@ -40,11 +40,7 @@ export type ChatCompletionResponse = {
usage?: Usage; usage?: Usage;
}; };
export type FinishReason = export type FinishReason = 'Stop' | 'Length' | 'ContentFilter' | 'ToolCalls';
| 'Stop'
| 'Length'
| 'ContentFilter'
| 'ToolCalls';
export type ChatChoice = { export type ChatChoice = {
index: number; index: number;