Bläddra i källkod

feat(webui): app shell, sidebar nav, project selector, toasts

Fszontagh 2 månader sedan
förälder
incheckning
211db78e2a

+ 24 - 20
webui/src/App.tsx

@@ -2,6 +2,14 @@ import { Navigate, Outlet } from 'react-router-dom'
 import type { RouteObject } from 'react-router-dom'
 import { useAuthStore } from '@/stores/authStore'
 import Login from '@/pages/Login'
+import { AppShell } from '@/components/AppShell'
+import Dashboard from '@/pages/Dashboard'
+import Collections from '@/pages/Collections'
+import Documents from '@/pages/Documents'
+import RagTester from '@/pages/RagTester'
+import Settings from '@/pages/Settings'
+import ProjectsAdmin from '@/pages/ProjectsAdmin'
+import KeysAdmin from '@/pages/KeysAdmin'
 
 function ProtectedRoute() {
   const loggedIn = useAuthStore((s) => s.loggedIn)
@@ -9,25 +17,10 @@ function ProtectedRoute() {
   return <AppShell />
 }
 
-// Minimal placeholder shell — replaced by the real AppShell in W4.
-function AppShell() {
-  const logout = useAuthStore((s) => s.logout)
-  return (
-    <div className="min-h-screen bg-[#0b0f17] text-slate-100">
-      <header className="flex items-center justify-between px-6 py-3 border-b border-slate-800">
-        <span className="font-semibold text-slate-200">VectorAPI</span>
-        <button
-          onClick={() => logout()}
-          className="text-sm text-slate-400 hover:text-slate-200 transition"
-        >
-          Sign out
-        </button>
-      </header>
-      <main className="p-8">
-        <Outlet />
-      </main>
-    </div>
-  )
+function AdminRoute() {
+  const admin = useAuthStore((s) => s.admin)
+  if (!admin) return <Navigate to="/" replace />
+  return <Outlet />
 }
 
 export const routes: RouteObject[] = [
@@ -35,7 +28,18 @@ export const routes: RouteObject[] = [
   {
     element: <ProtectedRoute />,
     children: [
-      { index: true, element: <div className="text-xl text-slate-300">Dashboard — coming in W5</div> },
+      { index: true, element: <Dashboard /> },
+      { path: 'collections', element: <Collections /> },
+      { path: 'documents', element: <Documents /> },
+      { path: 'rag', element: <RagTester /> },
+      {
+        element: <AdminRoute />,
+        children: [
+          { path: 'settings', element: <Settings /> },
+          { path: 'projects', element: <ProjectsAdmin /> },
+          { path: 'keys', element: <KeysAdmin /> },
+        ],
+      },
     ],
   },
 ]

+ 120 - 0
webui/src/components/AppShell.tsx

@@ -0,0 +1,120 @@
+import { useNavigate, NavLink, Outlet } from 'react-router-dom'
+import {
+  LayoutDashboard,
+  Database,
+  FileText,
+  Search,
+  Settings,
+  FolderKanban,
+  KeyRound,
+  LogOut,
+} from 'lucide-react'
+import { useAuthStore } from '@/stores/authStore'
+import { ProjectSelector } from './ProjectSelector'
+import { Toast } from './Toast'
+
+interface NavItem {
+  to: string
+  label: string
+  icon: React.ReactNode
+}
+
+const mainNav: NavItem[] = [
+  { to: '/', label: 'Dashboard', icon: <LayoutDashboard size={18} /> },
+  { to: '/collections', label: 'Collections', icon: <Database size={18} /> },
+  { to: '/documents', label: 'Documents', icon: <FileText size={18} /> },
+  { to: '/rag', label: 'RAG Tester', icon: <Search size={18} /> },
+]
+
+const adminNav: NavItem[] = [
+  { to: '/settings', label: 'Settings', icon: <Settings size={18} /> },
+  { to: '/projects', label: 'Projects', icon: <FolderKanban size={18} /> },
+  { to: '/keys', label: 'Keys', icon: <KeyRound size={18} /> },
+]
+
+function SidebarLink({ item }: { item: NavItem }) {
+  return (
+    <NavLink
+      to={item.to}
+      end={item.to === '/'}
+      className={({ isActive }) =>
+        [
+          'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition',
+          isActive
+            ? 'bg-indigo-600/30 text-indigo-300'
+            : 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/50',
+        ].join(' ')
+      }
+    >
+      {item.icon}
+      {item.label}
+    </NavLink>
+  )
+}
+
+export function AppShell() {
+  const admin = useAuthStore((s) => s.admin)
+  const logout = useAuthStore((s) => s.logout)
+  const navigate = useNavigate()
+
+  const handleLogout = () => {
+    logout().finally(() => {
+      navigate('/login', { replace: true })
+    })
+  }
+
+  return (
+    <div className="flex min-h-screen bg-[#0b0f17] text-slate-100">
+      {/* Sidebar */}
+      <aside className="w-56 shrink-0 flex flex-col bg-[#080c12] border-r border-slate-800">
+        {/* Brand */}
+        <div className="px-4 py-5 border-b border-slate-800">
+          <span className="text-lg font-bold tracking-tight text-indigo-300">VectorAPI</span>
+        </div>
+
+        {/* Nav */}
+        <nav className="flex-1 px-3 py-4 flex flex-col gap-1">
+          {mainNav.map((item) => (
+            <SidebarLink key={item.to} item={item} />
+          ))}
+
+          {admin && (
+            <>
+              <div className="mt-4 mb-1 px-3">
+                <span className="text-[10px] uppercase tracking-widest text-slate-600 font-semibold">
+                  Admin
+                </span>
+              </div>
+              {adminNav.map((item) => (
+                <SidebarLink key={item.to} item={item} />
+              ))}
+            </>
+          )}
+        </nav>
+      </aside>
+
+      {/* Main area */}
+      <div className="flex-1 flex flex-col min-w-0">
+        {/* Top bar */}
+        <header className="flex items-center justify-between px-6 py-3 border-b border-slate-800 bg-[#0b0f17]">
+          <ProjectSelector />
+          <button
+            onClick={handleLogout}
+            className="flex items-center gap-2 text-sm text-slate-400 hover:text-slate-200 transition"
+          >
+            <LogOut size={16} />
+            Logout
+          </button>
+        </header>
+
+        {/* Page content */}
+        <main className="flex-1 p-6 overflow-auto">
+          <Outlet />
+        </main>
+      </div>
+
+      {/* Global toasts */}
+      <Toast />
+    </div>
+  )
+}

+ 26 - 0
webui/src/components/ProjectSelector.tsx

@@ -0,0 +1,26 @@
+import { useAuthStore } from '@/stores/authStore'
+
+export function ProjectSelector() {
+  const projects = useAuthStore((s) => s.projects)
+  const currentProject = useAuthStore((s) => s.currentProject)
+  const setCurrentProject = useAuthStore((s) => s.setCurrentProject)
+
+  const options = projects.length > 0 ? projects : ['default']
+
+  return (
+    <select
+      value={currentProject ?? 'default'}
+      onChange={(e) => setCurrentProject(e.target.value)}
+      className="bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded px-2 py-1
+                 focus:outline-none focus:ring-2 focus:ring-indigo-500 cursor-pointer
+                 hover:border-slate-500 transition"
+      aria-label="Select project"
+    >
+      {options.map((p) => (
+        <option key={p} value={p}>
+          {p}
+        </option>
+      ))}
+    </select>
+  )
+}

+ 51 - 0
webui/src/components/Toast.tsx

@@ -0,0 +1,51 @@
+import { useEffect } from 'react'
+import { X } from 'lucide-react'
+import { useToastStore } from '@/stores/toastStore'
+import type { Toast as ToastItem } from '@/stores/toastStore'
+
+const AUTO_DISMISS_MS = 4000
+
+function ToastEntry({ toast }: { toast: ToastItem }) {
+  const dismiss = useToastStore((s) => s.dismiss)
+
+  useEffect(() => {
+    const timer = setTimeout(() => dismiss(toast.id), AUTO_DISMISS_MS)
+    return () => clearTimeout(timer)
+  }, [toast.id, dismiss])
+
+  const colorClass =
+    toast.kind === 'success'
+      ? 'bg-emerald-900/90 border-emerald-700 text-emerald-100'
+      : toast.kind === 'error'
+        ? 'bg-red-900/90 border-red-700 text-red-100'
+        : 'bg-slate-800/90 border-slate-600 text-slate-100'
+
+  return (
+    <div
+      className={`flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[260px] max-w-sm ${colorClass}`}
+    >
+      <span className="flex-1 text-sm leading-snug">{toast.msg}</span>
+      <button
+        onClick={() => dismiss(toast.id)}
+        className="shrink-0 mt-0.5 opacity-70 hover:opacity-100 transition"
+        aria-label="Dismiss"
+      >
+        <X size={14} />
+      </button>
+    </div>
+  )
+}
+
+export function Toast() {
+  const toasts = useToastStore((s) => s.toasts)
+
+  if (toasts.length === 0) return null
+
+  return (
+    <div className="fixed bottom-6 right-6 z-50 flex flex-col gap-2 items-end">
+      {toasts.map((t) => (
+        <ToastEntry key={t.id} toast={t} />
+      ))}
+    </div>
+  )
+}

+ 8 - 0
webui/src/pages/Collections.tsx

@@ -0,0 +1,8 @@
+export default function Collections() {
+  return (
+    <div>
+      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Collections</h1>
+      <p className="text-slate-400">Collection management — coming in W6.</p>
+    </div>
+  )
+}

+ 8 - 0
webui/src/pages/Dashboard.tsx

@@ -0,0 +1,8 @@
+export default function Dashboard() {
+  return (
+    <div>
+      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Dashboard</h1>
+      <p className="text-slate-400">Statistics and overview — coming in W5.</p>
+    </div>
+  )
+}

+ 8 - 0
webui/src/pages/Documents.tsx

@@ -0,0 +1,8 @@
+export default function Documents() {
+  return (
+    <div>
+      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Documents</h1>
+      <p className="text-slate-400">Document browser and editor — coming in W7.</p>
+    </div>
+  )
+}

+ 8 - 0
webui/src/pages/KeysAdmin.tsx

@@ -0,0 +1,8 @@
+export default function KeysAdmin() {
+  return (
+    <div>
+      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Keys</h1>
+      <p className="text-slate-400">API key management — coming in W9.</p>
+    </div>
+  )
+}

+ 8 - 0
webui/src/pages/ProjectsAdmin.tsx

@@ -0,0 +1,8 @@
+export default function ProjectsAdmin() {
+  return (
+    <div>
+      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Projects</h1>
+      <p className="text-slate-400">Project administration — coming in W9.</p>
+    </div>
+  )
+}

+ 8 - 0
webui/src/pages/RagTester.tsx

@@ -0,0 +1,8 @@
+export default function RagTester() {
+  return (
+    <div>
+      <h1 className="text-2xl font-semibold text-slate-100 mb-2">RAG Tester</h1>
+      <p className="text-slate-400">Vector search tester — coming in W8.</p>
+    </div>
+  )
+}

+ 8 - 0
webui/src/pages/Settings.tsx

@@ -0,0 +1,8 @@
+export default function Settings() {
+  return (
+    <div>
+      <h1 className="text-2xl font-semibold text-slate-100 mb-2">Settings</h1>
+      <p className="text-slate-400">Global settings management — coming in W9.</p>
+    </div>
+  )
+}