feat: US-004 — App shell layout and sidebar navigation

- Add electron-store@8 for sidebar collapse state persistence via settings tRPC router
- Add @fontsource/geist for self-hosted Geist font (remove Google Fonts CDN)
- Add right-edge vertical 'keep scrolling for AI' label with chevron-down in all views
- Wire AppShell collapse toggle to settings.setSidebarCollapsed tRPC mutation
- Fix ESLint config with eslint-import-resolver-typescript to resolve @/* path aliases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-19 16:50:38 +01:00
parent e7f64b385a
commit c99799bb05
8 changed files with 863 additions and 26 deletions

View File

@@ -12,5 +12,16 @@
"plugin:import/electron", "plugin:import/electron",
"plugin:import/typescript" "plugin:import/typescript"
], ],
"parser": "@typescript-eslint/parser" "parser": "@typescript-eslint/parser",
"settings": {
"import/resolver": {
"typescript": {
"alwaysTryTypes": true,
"project": "./tsconfig.json"
},
"node": {
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
}
} }

View File

@@ -4,9 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Adiuva</title> <title>Adiuva</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

800
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,7 @@
"drizzle-kit": "^0.31.9", "drizzle-kit": "^0.31.9",
"electron": "40.6.0", "electron": "40.6.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
@@ -44,6 +45,7 @@
"vite": "^5.4.21" "vite": "^5.4.21"
}, },
"dependencies": { "dependencies": {
"@fontsource/geist": "^5.2.8",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.161.1", "@tanstack/react-router": "^1.161.1",
"@trpc/client": "^11.10.0", "@trpc/client": "^11.10.0",
@@ -54,6 +56,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0",
"electron-trpc": "^0.7.1", "electron-trpc": "^0.7.1",
"framer-motion": "^12.34.2", "framer-motion": "^12.34.2",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",

View File

@@ -1,5 +1,6 @@
import { initTRPC } from '@trpc/server'; import { initTRPC } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { getStore } from '../store';
const t = initTRPC.create(); const t = initTRPC.create();
@@ -134,6 +135,16 @@ const notesRouter = router({
.mutation(() => null), .mutation(() => null),
}); });
const settingsRouter = router({
getSidebarCollapsed: publicProcedure.query(() => getStore().get('sidebarCollapsed')),
setSidebarCollapsed: publicProcedure
.input(z.object({ collapsed: z.boolean() }))
.mutation(({ input }) => {
getStore().set('sidebarCollapsed', input.collapsed);
return null;
}),
});
const aiRouter = router({ const aiRouter = router({
chat: publicProcedure chat: publicProcedure
.input(z.object({ .input(z.object({
@@ -152,6 +163,7 @@ const aiRouter = router({
export const appRouter = router({ export const appRouter = router({
health: healthRouter, health: healthRouter,
settings: settingsRouter,
clients: clientsRouter, clients: clientsRouter,
projects: projectsRouter, projects: projectsRouter,
tasks: tasksRouter, tasks: tasksRouter,

18
src/main/store.ts Normal file
View File

@@ -0,0 +1,18 @@
import Store from 'electron-store';
interface AppSettings {
sidebarCollapsed: boolean;
}
let _store: Store<AppSettings> | null = null;
export function getStore(): Store<AppSettings> {
if (!_store) {
_store = new Store<AppSettings>({
defaults: {
sidebarCollapsed: false,
},
});
}
return _store;
}

View File

@@ -6,8 +6,10 @@ import {
ClipboardCheck, ClipboardCheck,
FolderKanban, FolderKanban,
PanelLeft, PanelLeft,
ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' }, { to: '/', icon: House, label: 'Home' },
@@ -21,10 +23,25 @@ interface AppShellProps {
} }
export function AppShell({ children }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
const [collapsed, setCollapsed] = useState(false); const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
staleTime: Infinity,
});
const setSidebarCollapsedMutation = trpc.settings.setSidebarCollapsed.useMutation();
// localCollapsed tracks user toggles after load; null means "use server value"
const [localCollapsed, setLocalCollapsed] = useState<boolean | null>(null);
const routerState = useRouterState(); const routerState = useRouterState();
const currentPath = routerState.location.pathname; const currentPath = routerState.location.pathname;
const collapsed = localCollapsed !== null ? localCollapsed : (collapsedQuery.data ?? false);
const handleToggle = () => {
const next = !collapsed;
setLocalCollapsed(next);
setSidebarCollapsedMutation.mutate({ collapsed: next });
};
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-background"> <div className="flex h-screen w-screen overflow-hidden bg-background">
{/* Sidebar */} {/* Sidebar */}
@@ -90,7 +107,7 @@ export function AppShell({ children }: AppShellProps) {
{/* Collapse toggle */} {/* Collapse toggle */}
<div className="px-2 pb-3 shrink-0"> <div className="px-2 pb-3 shrink-0">
<button <button
onClick={() => setCollapsed((c) => !c)} onClick={handleToggle}
className={cn( className={cn(
'flex items-center gap-2 h-8 px-3 rounded-md text-sm text-sidebar-foreground w-full', 'flex items-center gap-2 h-8 px-3 rounded-md text-sm text-sidebar-foreground w-full',
'hover:bg-sidebar-accent transition-colors', 'hover:bg-sidebar-accent transition-colors',
@@ -107,6 +124,19 @@ export function AppShell({ children }: AppShellProps) {
{/* Main content */} {/* Main content */}
<main className="flex-1 min-w-0 overflow-hidden relative"> <main className="flex-1 min-w-0 overflow-hidden relative">
{children} {children}
{/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */}
<div className="absolute right-0 top-0 bottom-0 flex items-end justify-center pb-8 pointer-events-none select-none">
<div className="flex flex-col items-center gap-1.5 pr-2">
<span
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
keep scrolling for AI
</span>
<ChevronDown size={10} className="text-muted-foreground/30" />
</div>
</div>
</main> </main>
</div> </div>
); );

View File

@@ -1,3 +1,7 @@
@import '@fontsource/geist/400.css';
@import '@fontsource/geist/500.css';
@import '@fontsource/geist/600.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -44,5 +48,3 @@
overflow: hidden; overflow: hidden;
} }
} }
/* Geist font — loaded via CDN in index.html */