Fabio Antonini
feat: add in-app Guide page with 8 sections (IT/EN)
401e56e
import { NavLink, useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { FolderOpen, MessageSquare, Users, Microscope, LogOut, ClipboardCheck, Bot, Plus, ChevronDown, ChevronRight, Settings, HelpCircle, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { useAuthStore } from "@/store/auth"
import { authApi, ragApi, usersApi, agentProjectsApi, type AgentProject } from "@/lib/api"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
import SettingsDialog from "@/components/settings/SettingsDialog"
import i18n from "@/i18n"
import { useState, useEffect } from "react"
const links = [
{ to: "/projects", label: "nav.projects", icon: FolderOpen },
{ to: "/agent", label: "nav.agent", icon: Bot },
{ to: "/rag", label: "nav.rag", icon: MessageSquare },
{ to: "/compliance", label: "nav.compliance", icon: ClipboardCheck },
]
const adminLinks = [
{ to: "/admin/users", label: "nav.admin", icon: Users },
]
function getInitials(name: string | undefined): string {
if (!name) return "?"
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map(w => w[0].toUpperCase())
.join("")
}
export default function Sidebar() {
const { t } = useTranslation()
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const [agentProjects, setAgentProjects] = useState<AgentProject[]>([])
const [agentExpanded, setAgentExpanded] = useState(true)
const [newProjectTitle, setNewProjectTitle] = useState("")
const [creatingProject, setCreatingProject] = useState(false)
const [showNewProjectInput, setShowNewProjectInput] = useState(false)
const [models, setModels] = useState<string[]>([])
const [currentModel, setCurrentModel] = useState<string>("")
const [currentOcrModel, setCurrentOcrModel] = useState<string>("easyocr")
const [currentVlmModel, setCurrentVlmModel] = useState<string>("qwen3-vl:8b")
const [ollamaUp, setOllamaUp] = useState<boolean | null>(null)
const [refreshing, setRefreshing] = useState(false)
// OpenAI state
const [openaiModels, setOpenaiModels] = useState<{ llm: string[]; vlm: string[]; embed: string[] }>({ llm: [], vlm: [], embed: [] })
const [currentEmbedModel, setCurrentEmbedModel] = useState<string>("nomic-embed-text")
const [openaiKeyConfigured, setOpenaiKeyConfigured] = useState(false)
const [showKeyInput, setShowKeyInput] = useState(false)
const [keyDraft, setKeyDraft] = useState("")
const [savingKey, setSavingKey] = useState(false)
const [keyError, setKeyError] = useState("")
// Settings dialog
const [settingsOpen, setSettingsOpen] = useState(false)
async function loadModels() {
setRefreshing(true)
try {
const r = await ragApi.status()
setOllamaUp(r.data.ollama_reachable)
setModels(r.data.models)
setOpenaiModels({
llm: r.data.openai_llm_models ?? [],
vlm: r.data.openai_vlm_models ?? [],
embed: r.data.openai_embed_models ?? [],
})
} catch {
setOllamaUp(false)
} finally {
setRefreshing(false)
}
}
useEffect(() => {
loadModels()
agentProjectsApi.listProjects().then(r => setAgentProjects(r.data)).catch(() => {})
usersApi.getSettings().then(async r => {
const s = r.data
setOpenaiKeyConfigured(s.openai_key_configured)
if (s.rag_model) {
setCurrentModel(s.rag_model)
await ragApi.setModel(s.rag_model).catch(() => {})
} else {
ragApi.getModel().then(r2 => setCurrentModel(r2.data.model)).catch(() => {})
}
if (s.vlm_model) {
setCurrentVlmModel(s.vlm_model)
await ragApi.setVlmModel(s.vlm_model).catch(() => {})
} else {
ragApi.getVlmModel().then(r2 => setCurrentVlmModel(r2.data.vlm_model)).catch(() => {})
}
if (s.ocr_model) {
setCurrentOcrModel(s.ocr_model)
await ragApi.setOcrModel(s.ocr_model).catch(() => {})
} else {
ragApi.getOcrModel().then(r2 => setCurrentOcrModel(r2.data.ocr_model)).catch(() => {})
}
if (s.embed_model) {
setCurrentEmbedModel(s.embed_model)
await ragApi.setEmbedModel(s.embed_model).catch(() => {})
} else {
ragApi.getEmbedModel().then(r2 => setCurrentEmbedModel(r2.data.embed_model)).catch(() => {})
}
}).catch(() => {
ragApi.getModel().then(r => setCurrentModel(r.data.model)).catch(() => {})
ragApi.getOcrModel().then(r => setCurrentOcrModel(r.data.ocr_model)).catch(() => {})
ragApi.getVlmModel().then(r => setCurrentVlmModel(r.data.vlm_model)).catch(() => {})
ragApi.getEmbedModel().then(r => setCurrentEmbedModel(r.data.embed_model)).catch(() => {})
})
}, [])
async function handleEmbedModelChange(e: React.ChangeEvent<HTMLSelectElement>) {
const model = e.target.value
setCurrentEmbedModel(model)
try { await ragApi.setEmbedModel(model) } catch { /* no-op */ }
usersApi.saveModelPreferences({ embed_model: model }).catch(() => {})
}
async function handleSaveKey(e: React.FormEvent) {
e.preventDefault()
setKeyError("")
setSavingKey(true)
try {
await usersApi.saveOpenAIKey(keyDraft)
setOpenaiKeyConfigured(true)
setShowKeyInput(false)
setKeyDraft("")
await loadModels()
} catch {
setKeyError(t("config.openai_key_error"))
} finally {
setSavingKey(false)
}
}
async function handleDeleteKey() {
try {
await usersApi.deleteOpenAIKey()
setOpenaiKeyConfigured(false)
await loadModels()
} catch {
// no-op
}
}
async function handleCreateProject() {
if (!newProjectTitle.trim()) return
setCreatingProject(true)
try {
const { data } = await agentProjectsApi.createProject(newProjectTitle.trim())
setAgentProjects(prev => [data, ...prev])
setNewProjectTitle("")
setShowNewProjectInput(false)
navigate(`/agent/projects/${data.id}`)
} catch {
// no-op
} finally {
setCreatingProject(false)
}
}
async function handleModelChange(e: React.ChangeEvent<HTMLSelectElement>) {
const model = e.target.value
setCurrentModel(model)
try { await ragApi.setModel(model) } catch { /* no-op */ }
usersApi.saveModelPreferences({ rag_model: model }).catch(() => {})
}
async function handleOcrModelChange(e: React.ChangeEvent<HTMLSelectElement>) {
const model = e.target.value
setCurrentOcrModel(model)
try { await ragApi.setOcrModel(model) } catch { /* no-op */ }
usersApi.saveModelPreferences({ ocr_model: model }).catch(() => {})
}
async function handleVlmModelChange(e: React.ChangeEvent<HTMLSelectElement>) {
const model = e.target.value
setCurrentVlmModel(model)
try { await ragApi.setVlmModel(model) } catch { /* no-op */ }
usersApi.saveModelPreferences({ vlm_model: model }).catch(() => {})
}
async function handleLogout() {
try { await authApi.logout() } catch { /* no-op */ }
logout()
}
function toggleLang() {
i18n.changeLanguage(i18n.language === "it" ? "en" : "it")
}
const initials = getInitials(user?.full_name)
return (
<aside className="flex flex-col w-56 min-h-screen bg-card border-r px-3 py-4 gap-1">
{/* Logo */}
<div className="flex items-center gap-2 px-2 mb-6">
<Microscope className="h-5 w-5 text-primary" />
<span className="font-semibold text-base">{t("app.name")}</span>
</div>
{/* Main nav */}
<nav className="flex-1 space-y-0.5 overflow-y-auto">
{links.filter(l => l.to !== "/agent").slice(0, links.findIndex(l => l.to === "/agent")).map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)
}
>
<Icon className="h-4 w-4" />
{t(label)}
</NavLink>
))}
{/* Agente Documentale link */}
<NavLink
to="/agent"
end
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)
}
>
<Bot className="h-4 w-4" />
{t("nav.agent")}
</NavLink>
{/* Agent projects sub-section */}
<div className="pt-1">
<button
onClick={() => setAgentExpanded(v => !v)}
className="flex items-center gap-1.5 w-full px-3 py-1 text-xs font-semibold text-muted-foreground tracking-wide hover:text-foreground transition-colors"
>
{agentExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{t("nav.agent_projects")}
<button
onClick={e => { e.stopPropagation(); setShowNewProjectInput(v => !v) }}
className="ml-auto p-0.5 rounded hover:bg-muted"
title={t("agent.new_project")}
>
<Plus className="h-3 w-3" />
</button>
</button>
{showNewProjectInput && (
<div className="px-2 py-1 flex gap-1">
<input
autoFocus
value={newProjectTitle}
onChange={e => setNewProjectTitle(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") handleCreateProject(); if (e.key === "Escape") setShowNewProjectInput(false) }}
placeholder={t("agent.project_name_placeholder")}
className="flex-1 rounded border bg-background px-2 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
/>
<Button size="icon" className="h-6 w-6 shrink-0" disabled={creatingProject} onClick={handleCreateProject}>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{agentExpanded && agentProjects.map(p => (
<NavLink
key={p.id}
to={`/agent/projects/${p.id}`}
className={({ isActive }) =>
cn(
"flex items-center gap-2 pl-6 pr-3 py-1.5 text-xs rounded-md transition-colors",
isActive ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground hover:bg-muted hover:text-foreground"
)
}
>
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{p.title}</span>
</NavLink>
))}
{agentExpanded && agentProjects.length === 0 && (
<p className="pl-6 pr-3 py-1 text-xs text-muted-foreground">{t("agent.no_projects")}</p>
)}
</div>
{/* Remaining main nav links (RAG, Compliance, …) */}
{links.filter(l => l.to !== "/agent").slice(links.findIndex(l => l.to === "/agent")).map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)
}
>
<Icon className="h-4 w-4" />
{t(label)}
</NavLink>
))}
{user?.role === "admin" && (
<>
<div className="my-2 border-t" />
{adminLinks.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)
}
>
<Icon className="h-4 w-4" />
{t(label)}
</NavLink>
))}
</>
)}
</nav>
{/* Footer — ChatGPT-style user menu */}
<div className="border-t pt-2 px-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2.5 w-full rounded-md px-2 py-2 hover:bg-muted transition-colors">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
{initials}
</span>
<span className="flex-1 text-left text-sm font-medium truncate">{user?.full_name}</span>
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-52">
<DropdownMenuItem onClick={() => setSettingsOpen(true)}>
<Settings className="mr-2 h-4 w-4" />
{t("nav.settings")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/guida")}>
<HelpCircle className="mr-2 h-4 w-4" />
{t("nav.help")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut className="mr-2 h-4 w-4" />
{t("auth.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SettingsDialog
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
models={models}
ollamaUp={ollamaUp}
refreshing={refreshing}
onRefresh={loadModels}
openaiModels={openaiModels}
currentModel={currentModel}
onModelChange={handleModelChange}
currentOcrModel={currentOcrModel}
onOcrModelChange={handleOcrModelChange}
currentVlmModel={currentVlmModel}
onVlmModelChange={handleVlmModelChange}
openaiKeyConfigured={openaiKeyConfigured}
currentEmbedModel={currentEmbedModel}
onEmbedModelChange={handleEmbedModelChange}
showKeyInput={showKeyInput}
setShowKeyInput={setShowKeyInput}
keyDraft={keyDraft}
setKeyDraft={setKeyDraft}
savingKey={savingKey}
keyError={keyError}
onSaveKey={handleSaveKey}
onDeleteKey={handleDeleteKey}
currentLang={i18n.language}
onToggleLang={toggleLang}
/>
</div>
</aside>
)
}