Settings as panels, the way the portal does it

Four tabs over three cards was navigation for its own sake, and the two shells
disagreed about what a settings screen looks like. It is one grid of cards now,
matching the portal: the account card carries changing a password and deleting
the account in its footer, appearance is a card with one row, and remote access
— an operator's concern, not a personal preference — is a card of its own for a
superuser.

SettingRow, alert-dialog and UserAvatar come across from the portal, so an
account renders the same face in both shells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:04:42 +02:00
co-authored by Claude Opus 5
parent 6d09500a73
commit 5da5606f79
12 changed files with 717 additions and 367 deletions
+28 -39
View File
@@ -1,25 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { motion } from "motion/react"
import Appearance from "@/components/UserSettings/Appearance"
import ChangePassword from "@/components/UserSettings/ChangePassword"
import DeleteAccount from "@/components/UserSettings/DeleteAccount"
import RemoteAccess from "@/components/UserSettings/RemoteAccess"
import UserInformation from "@/components/UserSettings/UserInformation"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useAuth from "@/hooks/useAuth"
const tabsConfig = [
{ value: "my-profile", title: "My profile", component: UserInformation },
{ value: "password", title: "Password", component: ChangePassword },
{ value: "appearance", title: "Appearance", component: Appearance },
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount },
]
// Whether this whole installation can be reached from outside is not a
// personal preference, so the tab only exists for an operator.
const superuserTabs = [
{ value: "remote-access", title: "Remote access", component: RemoteAccess },
]
import { listStagger, slideUp } from "@/lib/motion"
export const Route = createFileRoute("/_layout/settings")({
component: UserSettings,
@@ -34,13 +20,6 @@ export const Route = createFileRoute("/_layout/settings")({
function UserSettings() {
const { user: currentUser } = useAuth()
// A superuser deleting its own account would lock everyone out.
const finalTabs = currentUser?.is_superuser
? [
...tabsConfig.filter((tab) => tab.value !== "danger-zone"),
...superuserTabs,
]
: tabsConfig
if (!currentUser) {
return null
@@ -49,26 +28,36 @@ function UserSettings() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">User Settings</h1>
<p className="text-muted-foreground">
<h1 className="font-display text-3xl font-semibold">Settings</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Manage your account settings and preferences
</p>
</div>
<Tabs defaultValue="my-profile">
<TabsList>
{finalTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.title}
</TabsTrigger>
))}
</TabsList>
{finalTabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
<tab.component />
</TabsContent>
))}
</Tabs>
{/* Panels rather than tabs, the way the portal does it: there are few
enough of these to read at once, and hiding four behind a tab bar was
navigation over three cards. Changing a password and deleting an
account belong to the account, so they live in that card's footer. */}
<motion.div
className="grid items-start gap-6 lg:grid-cols-2"
variants={listStagger}
initial="hidden"
animate="visible"
>
<motion.div variants={slideUp}>
<UserInformation />
</motion.div>
<motion.div variants={slideUp}>
<Appearance />
</motion.div>
{/* Whether this whole installation can be reached from outside is not
a personal preference, so it only exists for an operator. */}
{currentUser.is_superuser && (
<motion.div variants={slideUp}>
<RemoteAccess />
</motion.div>
)}
</motion.div>
</div>
)
}