Implement shadcn/ui + Tailwind CSS UI layer

- Design token system via CSS custom properties (light/dark mode)
- Theme context hook + ThemeToggle component
- AppShell + collapsible Sidebar replace inline Nav
- LoginPage redesigned: two-column grid with hero panel
- shadcn/ui Button and Input components
- Tailwind config wired to CSS variable tokens
- All pages de-Nav'd; PrivateRoute/AdminRoute wrap with AppShell
- TypeScript passes clean (npm run typecheck)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-17 12:32:06 +02:00
parent 9e2e4ec338
commit c3f87706ee
26 changed files with 1263 additions and 89 deletions
+7 -2
View File
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "./hooks/useAuth";
import { getMe } from "./api/client";
import AppShell from "./components/AppShell";
import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage";
import ProfilePage from "./pages/ProfilePage";
@@ -13,7 +14,11 @@ import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { token } = useAuth();
return token ? <>{children}</> : <Navigate to="/login" replace />;
return token ? (
<AppShell>{children}</AppShell>
) : (
<Navigate to="/login" replace />
);
}
function AdminRoute({ children }: { children: React.ReactNode }) {
@@ -25,7 +30,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
if (isLoading) return null;
// Redirect to /login (not /) so the route appears not to exist
if (!user?.is_admin) return <Navigate to="/login" replace />;
return <>{children}</>;
return <AppShell>{children}</AppShell>;
}
export default function App() {
+14
View File
@@ -0,0 +1,14 @@
import Sidebar from "@/components/Sidebar";
interface AppShellProps {
children: React.ReactNode;
}
export default function AppShell({ children }: AppShellProps) {
return (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Home,
Grid2X2,
Settings,
ShieldCheck,
ChevronRight,
ChevronLeft,
LogOut,
UserCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import ThemeToggle from "@/components/ThemeToggle";
import { useAuth } from "@/hooks/useAuth";
import { getMe } from "@/api/client";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ to: "/", label: "Home", icon: Home, exact: true },
{ to: "/apps", label: "Apps", icon: Grid2X2, exact: false },
{ to: "/settings", label: "Settings", icon: Settings, exact: false },
];
export default function Sidebar() {
const [expanded, setExpanded] = useState(true);
const { logout } = useAuth();
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
return (
<aside
className={cn(
"flex flex-col h-screen bg-surface border-r border-border transition-all duration-200 shrink-0",
expanded ? "w-56" : "w-16"
)}
>
{/* Nav items */}
<nav className="flex-1 py-4 px-2 space-y-1 overflow-hidden">
{NAV_ITEMS.map(({ to, label, icon: Icon, exact }) => (
<NavLink
key={to}
to={to}
end={exact}
className={({ isActive }) =>
cn(
"flex items-center rounded-lg transition-colors",
expanded ? "px-3 py-2 gap-3" : "justify-center py-3",
isActive
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
)
}
>
<Icon className="h-5 w-5 shrink-0" />
{expanded && (
<span className="text-sm font-medium whitespace-nowrap">{label}</span>
)}
</NavLink>
))}
{user?.is_admin && (
<NavLink
to="/admin"
className={({ isActive }) =>
cn(
"flex items-center rounded-lg transition-colors",
expanded ? "px-3 py-2 gap-3" : "justify-center py-3",
isActive
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
)
}
>
<ShieldCheck className="h-5 w-5 shrink-0" />
{expanded && (
<span className="text-sm font-medium whitespace-nowrap">Admin</span>
)}
</NavLink>
)}
</nav>
{/* Bottom section */}
<div className="py-4 px-2 space-y-1 border-t border-border">
{/* User avatar row */}
<div
className={cn(
"flex items-center rounded-lg py-2 text-muted",
expanded ? "px-3 gap-3" : "justify-center"
)}
>
<UserCircle className="h-5 w-5 shrink-0" />
{expanded && user && (
<span className="text-sm font-medium truncate">{user.email}</span>
)}
</div>
{/* Theme toggle */}
<div className={cn("flex", expanded ? "px-1" : "justify-center")}>
<ThemeToggle />
</div>
{/* Logout */}
<Button
variant="ghost"
className={cn(
"w-full text-muted hover:text-foreground",
expanded ? "justify-start px-3 gap-3" : "justify-center px-0"
)}
onClick={logout}
>
<LogOut className="h-5 w-5 shrink-0" />
{expanded && <span className="text-sm font-medium">Logout</span>}
</Button>
{/* Collapse toggle */}
<Button
variant="ghost"
size="icon"
className={cn("w-full text-muted hover:text-foreground", !expanded && "px-0")}
onClick={() => setExpanded((e) => !e)}
aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
>
{expanded ? (
<ChevronLeft className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
)}
</Button>
</div>
</aside>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { Sun, Moon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/hooks/useTheme";
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
);
}
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-white hover:bg-primary-hover",
ghost:
"hover:bg-muted/20 hover:text-foreground",
outline:
"border border-border bg-transparent hover:bg-muted/20 text-foreground",
destructive:
"bg-red-600 text-white hover:bg-red-700",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3",
lg: "h-11 px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };
+32
View File
@@ -0,0 +1,32 @@
import { useState, useEffect, useCallback } from "react";
type Theme = "light" | "dark";
function applyTheme(theme: Theme) {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
});
useEffect(() => {
applyTheme(theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = useCallback(() => {
setTheme((t) => (t === "light" ? "dark" : "light"));
}, []);
return { theme, toggleTheme };
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "./styles/theme.css";
import App from "./App";
const queryClient = new QueryClient();
+1 -8
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Nav from "../components/Nav";
import { getAISettings, updateAISettings, testAIConnection } from "../api/client";
type Provider = "anthropic" | "ollama" | "lmstudio";
@@ -96,17 +95,11 @@ export default function AIAdminSettingsPage() {
};
if (isLoading) {
return (
<>
<Nav />
<div style={{ padding: 32 }}>Loading</div>
</>
);
return <div style={{ padding: 32 }}>Loading</div>;
}
return (
<>
<Nav />
<div style={{ padding: 32, maxWidth: 600, margin: "0 auto" }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>AI Service Settings</h1>
-2
View File
@@ -9,7 +9,6 @@ import {
type AdminUserCreate,
type UserData,
} from "../api/client";
import Nav from "../components/Nav";
export default function AdminPage() {
const queryClient = useQueryClient();
@@ -68,7 +67,6 @@ export default function AdminPage() {
return (
<>
<Nav />
<div style={{ padding: 32, maxWidth: 800 }}>
<h1>User Management</h1>
+1 -2
View File
@@ -1,6 +1,5 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Nav from "../components/Nav";
import { getMe } from "../api/client";
interface AppCard {
@@ -36,7 +35,6 @@ export default function AppsPage() {
return (
<>
<Nav />
<div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}>
<h1>Apps</h1>
<div style={{ display: "flex", gap: 24, flexWrap: "wrap", marginTop: 24 }}>
@@ -102,3 +100,4 @@ export default function AppsPage() {
</>
);
}
+4 -8
View File
@@ -1,17 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getMe } from "../api/client";
import Nav from "../components/Nav";
export default function DashboardPage() {
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
return (
<>
<Nav />
<div style={{ padding: 32 }}>
<h1>Dashboard</h1>
{user && <p>Welcome, {user.full_name ?? user.email}</p>}
</div>
</>
<div>
<h1 className="text-2xl font-semibold text-foreground mb-2">Dashboard</h1>
{user && <p className="text-muted">Welcome, {user.full_name ?? user.email}</p>}
</div>
);
}
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Nav from "../components/Nav";
import { getDocumentLimits, updateDocumentLimits } from "../api/client";
const inputStyle: React.CSSProperties = {
@@ -34,17 +33,11 @@ export default function DocumentAdminSettingsPage() {
});
if (isLoading) {
return (
<>
<Nav />
<div style={{ padding: 32 }}>Loading</div>
</>
);
return <div style={{ padding: 32 }}>Loading</div>;
}
return (
<>
<Nav />
<div style={{ padding: 32, maxWidth: 600, margin: "0 auto" }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>Documents Settings</h1>
-2
View File
@@ -1,6 +1,5 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Nav from "../components/Nav";
import {
listDocuments,
uploadDocument,
@@ -707,7 +706,6 @@ export default function DocumentsPage() {
return (
<>
<Nav />
<div style={{ padding: 32, maxWidth: 960, margin: "0 auto" }}>
<h1>Documents</h1>
+44 -45
View File
@@ -1,11 +1,13 @@
import axios from "axios";
import { useState } from "react";
import { Briefcase, LayoutDashboard } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ThemeToggle from "@/components/ThemeToggle";
import { useAuth } from "../hooks/useAuth";
// ── Customise these two constants for each deployment ────────────────────────
// ── Customise for each deployment ────────────────────────────────────────────
const BUSINESS_NAME = "Your Business Name";
// Replace the src with the actual logo URL or import, or swap the placeholder
// div below for an <img> tag once a logo is available.
// ─────────────────────────────────────────────────────────────────────────────
export default function LoginPage() {
@@ -30,62 +32,59 @@ export default function LoginPage() {
};
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<div style={{ width: 360, padding: 32 }}>
{/* Logo placeholder — swap for <img src="..." alt="Logo" /> */}
<div style={{
width: 96,
height: 96,
margin: "0 auto 24px",
border: "2px dashed #aaa",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#aaa",
fontSize: 12,
}}>
Logo
<div className="grid md:grid-cols-2 min-h-screen">
{/* ── Left column: login form ── */}
<div className="relative flex flex-col items-center justify-center p-8 bg-surface">
{/* Theme toggle — top-right of this panel */}
<div className="absolute top-4 right-4">
<ThemeToggle />
</div>
<h1 style={{ textAlign: "center", marginBottom: 24 }}>{BUSINESS_NAME}</h1>
<div className="w-full max-w-sm space-y-6">
{/* Logo placeholder */}
<div className="flex flex-col items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10">
<Briefcase className="h-8 w-8 text-primary" />
</div>
<h1 className="text-xl font-semibold text-primary">{BUSINESS_NAME}</h1>
<p className="text-sm text-muted">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", marginBottom: 4 }}>Email</label>
<input
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", marginBottom: 4 }}>Password</label>
<input
<Input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }}
/>
</div>
{error && <p style={{ color: "red", margin: "8px 0" }}>{error}</p>}
<button
type="submit"
style={{ width: "100%", padding: "8px 0", marginTop: 8, cursor: "pointer" }}
>
Sign in
</button>
</form>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full">
Sign In
</Button>
</form>
</div>
</div>
{/* ── Right column: hero panel ── */}
<div className="hidden md:flex flex-col items-center justify-center bg-primary p-8 gap-4">
<LayoutDashboard className="h-20 w-20 text-white/80" />
<h2 className="text-2xl font-bold text-white text-center">
Manage your team, your way
</h2>
<p className="text-sm text-white/70 text-center">
The all-in-one employer platform.
</p>
</div>
</div>
);
+1 -3
View File
@@ -1,7 +1,6 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getMe, getProfile, updateProfile, type ProfileUpdate } from "../api/client";
import Nav from "../components/Nav";
export default function ProfilePage() {
const queryClient = useQueryClient();
@@ -54,11 +53,10 @@ export default function ProfilePage() {
mutation.mutate(payload);
};
if (isLoading) return <><Nav /><div style={{ padding: 32 }}>Loading</div></>;
if (isLoading) return <div style={{ padding: 32 }}>Loading</div>;
return (
<>
<Nav />
<div style={{ padding: 32, maxWidth: 480 }}>
<h1>Profile</h1>
+60
View File
@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Design tokens ──────────────────────────────────────────────────────────
Colors are stored as RGB triplets so Tailwind's opacity modifier syntax
(e.g. bg-primary/10) works correctly.
────────────────────────────────────────────────────────────────────────── */
:root {
/* Brand / interactive */
--color-primary: 37 99 235; /* #2563EB blue-600 */
--color-primary-hover: 29 78 216; /* #1D4ED8 blue-700 */
--color-accent: 234 179 8; /* #EAB308 yellow-500 */
--color-accent-hover: 202 138 4; /* #CA8A04 yellow-600 */
/* Layout */
--color-background: 248 250 252; /* #F8FAFC slate-50 */
--color-surface: 255 255 255; /* #FFFFFF */
--color-border: 226 232 240; /* #E2E8F0 slate-200 */
/* Text */
--color-text-primary: 15 23 42; /* #0F172A slate-900 */
--color-text-muted: 100 116 139; /* #64748B slate-500 */
/* Shape */
--radius: 0.5rem;
}
.dark {
/* Brand / interactive */
--color-primary: 59 130 246; /* #3B82F6 blue-500 */
--color-primary-hover: 37 99 235; /* #2563EB blue-600 */
--color-accent: 250 204 21; /* #FACC15 yellow-400 */
--color-accent-hover: 234 179 8; /* #EAB308 yellow-500 */
/* Layout */
--color-background: 15 23 42; /* #0F172A slate-900 */
--color-surface: 30 41 59; /* #1E293B slate-800 */
--color-border: 51 65 85; /* #334155 slate-700 */
/* Text */
--color-text-primary: 248 250 252; /* #F8FAFC slate-50 */
--color-text-muted: 148 163 184; /* #94A3B8 slate-400 */
}
/* ── Base resets ─────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
@apply bg-background text-foreground;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
-webkit-font-smoothing: antialiased;
}