Put settings in the sidebar and let the sidebar frost composite

The footer was a user card with the theme picker and log out buried in
its dropdown. It is now two plain entries, Settings and Log Out, and the
theme moved into the settings page as its own tab.

The dark sidebar never matched the canvas because _canvas.tsx painted
bg-card across the whole viewport underneath it, so the floating
panel's bg-card/80 resolved to card over card and its gutter was card
too. Nothing there should paint a surface; the panel now reads the same
as the other frosted chrome.

The superuser guard sliced the first three tabs, which silently changed
meaning when a fourth arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
This commit is contained in:
Melvin Strobl
2026-08-15 21:19:41 +02:00
co-authored by Claude Opus 5
parent ca7a16c8bc
commit 6b73ca3645
7 changed files with 94 additions and 88 deletions
+1 -58
View File
@@ -1,6 +1,6 @@
import { Monitor, Moon, Sun } from "lucide-react" import { Monitor, Moon, Sun } from "lucide-react"
import { type Theme, useTheme } from "@/components/theme-provider" import { useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@@ -8,63 +8,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
type LucideIcon = React.FC<React.SVGProps<SVGSVGElement>>
const ICON_MAP: Record<Theme, LucideIcon> = {
system: Monitor,
light: Sun,
dark: Moon,
}
export const SidebarAppearance = () => {
const { isMobile } = useSidebar()
const { setTheme, theme } = useTheme()
const Icon = ICON_MAP[theme]
return (
<SidebarMenuItem>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip="Appearance" data-testid="theme-button">
<Icon className="size-4 text-muted-foreground" />
<span>Appearance</span>
<span className="sr-only">Toggle theme</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side={isMobile ? "top" : "right"}
align="end"
className="w-(--radix-dropdown-menu-trigger-width) min-w-56"
>
<DropdownMenuItem
data-testid="light-mode"
onClick={() => setTheme("light")}
>
<Sun className="mr-2 h-4 w-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem
data-testid="dark-mode"
onClick={() => setTheme("dark")}
>
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
export const Appearance = () => { export const Appearance = () => {
const { setTheme } = useTheme() const { setTheme } = useTheme()
+12 -9
View File
@@ -1,6 +1,5 @@
import { Home, Users, Workflow } from "lucide-react" import { Home, LogOut, Settings, Users, Workflow } from "lucide-react"
import { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo" import { Logo } from "@/components/Common/Logo"
import { import {
Sidebar, Sidebar,
@@ -11,7 +10,6 @@ import {
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
import { type Item, Main } from "./Main" import { type Item, Main } from "./Main"
import { User } from "./User"
const baseItems: Item[] = [ const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" }, { icon: Home, title: "Dashboard", path: "/" },
@@ -19,15 +17,20 @@ const baseItems: Item[] = [
] ]
export function AppSidebar() { export function AppSidebar() {
const { user: currentUser } = useAuth() const { user: currentUser, logout } = useAuth()
const items = currentUser?.is_superuser const items = currentUser?.is_superuser
? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }] ? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }]
: baseItems : baseItems
const footerItems: Item[] = [
{ icon: Settings, title: "Settings", path: "/settings" },
{ icon: LogOut, title: "Log Out", onClick: logout },
]
return ( return (
// Floating frosted chrome over the bg-card content region; see the root // Floating frosted chrome over whatever surface the shell paints; see the
// DESIGN-GUIDELINES.md → Shells and → Overlay surfaces & content chips. // root DESIGN-GUIDELINES.md → Shells and → Overlay surfaces & content chips.
<Sidebar <Sidebar
collapsible="icon" collapsible="icon"
variant="floating" variant="floating"
@@ -47,9 +50,9 @@ export function AppSidebar() {
<SidebarContent> <SidebarContent>
<Main items={items} /> <Main items={items} />
</SidebarContent> </SidebarContent>
<SidebarFooter> {/* Main already pads horizontally; the footer only adds the bottom gap. */}
<SidebarAppearance /> <SidebarFooter className="px-0">
<User user={currentUser} /> <Main items={footerItems} />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
) )
+20 -9
View File
@@ -10,10 +10,12 @@ import {
useSidebar, useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
/** A nav entry is either a route (`path`) or an action (`onClick`), never both. */
export type Item = { export type Item = {
icon: LucideIcon icon: LucideIcon
title: string title: string
path: string path?: string
onClick?: () => void
} }
interface MainProps { interface MainProps {
@@ -36,22 +38,31 @@ export function Main({ items }: MainProps) {
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{items.map((item) => { {items.map((item) => {
const isActive = const path = item.path
item.path === "/" const isActive = path
? path === "/"
? currentPath === "/" ? currentPath === "/"
: currentPath.startsWith(item.path) : currentPath.startsWith(path)
: false
const label = (
<>
<item.icon />
<span>{item.title}</span>
</>
)
return ( return (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton <SidebarMenuButton
tooltip={item.title} tooltip={item.title}
isActive={isActive} isActive={isActive}
asChild asChild={!!path}
onClick={() => {
handleMenuClick()
item.onClick?.()
}}
> >
<RouterLink to={item.path} onClick={handleMenuClick}> {path ? <RouterLink to={path}>{label}</RouterLink> : label}
<item.icon />
<span>{item.title}</span>
</RouterLink>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
) )
@@ -0,0 +1,50 @@
import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react"
import { type Theme, useTheme } from "@/components/theme-provider"
import { cn } from "@/lib/utils"
const options: { value: Theme; title: string; icon: LucideIcon }[] = [
{ value: "light", title: "Light", icon: Sun },
{ value: "dark", title: "Dark", icon: Moon },
{ value: "system", title: "System", icon: Monitor },
]
const Appearance = () => {
const { theme, setTheme } = useTheme()
return (
<div className="max-w-md">
<h3 className="text-lg font-semibold py-4">Appearance</h3>
<p className="text-sm text-muted-foreground pb-4">
Choose how Fluksio looks to you. System follows your device setting.
</p>
{/* The one segmented shape: a single border pill, transparent segments,
bg-accent on the selected one (root DESIGN-GUIDELINES.md). */}
<div
data-testid="theme-button"
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={theme === option.value}
data-testid={`${option.value}-mode`}
onClick={() => setTheme(option.value)}
className={cn(
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors",
theme === option.value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
<option.icon className="size-4" />
{option.title}
</button>
))}
</div>
</div>
)
}
export default Appearance
+7 -2
View File
@@ -8,6 +8,11 @@ import { isLoggedIn } from "@/hooks/useAuth"
* The full-bleed shell. Same frosted sidebar as `_layout`, but the content * The full-bleed shell. Same frosted sidebar as `_layout`, but the content
* region is the whole viewport: the flow editor floats its own chrome over the * region is the whole viewport: the flow editor floats its own chrome over the
* canvas instead of sitting inside a padded column. * canvas instead of sitting inside a padded column.
*
* Nothing here paints a surface. The canvas is `--background`, so an opaque
* `bg-card` on the shell would sit *under* the floating sidebar and turn its
* `bg-card/80 backdrop-blur-md` into a plain `bg-card` slab with a card-coloured
* moat around it — the frost has to composite against the canvas to read right.
*/ */
export const Route = createFileRoute("/_canvas")({ export const Route = createFileRoute("/_canvas")({
component: CanvasLayout, component: CanvasLayout,
@@ -20,9 +25,9 @@ export const Route = createFileRoute("/_canvas")({
function CanvasLayout() { function CanvasLayout() {
return ( return (
<SidebarProvider className="bg-card"> <SidebarProvider>
<AppSidebar /> <AppSidebar />
<SidebarInset className="bg-card"> <SidebarInset>
<main className="relative h-svh overflow-hidden"> <main className="relative h-svh overflow-hidden">
<Outlet /> <Outlet />
</main> </main>
+4 -1
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import Appearance from "@/components/UserSettings/Appearance"
import ChangePassword from "@/components/UserSettings/ChangePassword" import ChangePassword from "@/components/UserSettings/ChangePassword"
import DeleteAccount from "@/components/UserSettings/DeleteAccount" import DeleteAccount from "@/components/UserSettings/DeleteAccount"
import UserInformation from "@/components/UserSettings/UserInformation" import UserInformation from "@/components/UserSettings/UserInformation"
@@ -9,6 +10,7 @@ import useAuth from "@/hooks/useAuth"
const tabsConfig = [ const tabsConfig = [
{ value: "my-profile", title: "My profile", component: UserInformation }, { value: "my-profile", title: "My profile", component: UserInformation },
{ value: "password", title: "Password", component: ChangePassword }, { value: "password", title: "Password", component: ChangePassword },
{ value: "appearance", title: "Appearance", component: Appearance },
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount }, { value: "danger-zone", title: "Danger zone", component: DeleteAccount },
] ]
@@ -25,8 +27,9 @@ export const Route = createFileRoute("/_layout/settings")({
function UserSettings() { function UserSettings() {
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
// A superuser deleting its own account would lock everyone out.
const finalTabs = currentUser?.is_superuser const finalTabs = currentUser?.is_superuser
? tabsConfig.slice(0, 3) ? tabsConfig.filter((tab) => tab.value !== "danger-zone")
: tabsConfig : tabsConfig
if (!currentUser) { if (!currentUser) {
-9
View File
@@ -20,12 +20,3 @@ export const handleError = function (
const errorMessage = extractErrorMessage(err) const errorMessage = extractErrorMessage(err)
this(errorMessage) this(errorMessage)
} }
export const getInitials = (name: string): string => {
return name
.split(" ")
.slice(0, 2)
.map((word) => word[0])
.join("")
.toUpperCase()
}