Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/web/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { AppShell } from '@/components/app-shell/app-shell';
import { DashboardShell } from '@/components/dashboard/dashboard-shell';

export default function DashboardPage() {
return (
<main className="shell">
<DashboardShell />
</main>
<AppShell><main className="shell"><DashboardShell /></main></AppShell>
);
Comment on lines 5 to 7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Move the shared AppShell wrapper to a Next.js layout to prevent state loss on navigation.

Placing the shared AppShell component inside individual page.tsx files causes it to completely unmount and remount on every route transition between these pages. Because AppSidenav initializes its collapsed state to false and updates it via a client-side useEffect, this unmounting causes the sidebar to "forget" its state during navigation. As a result, the sidebar will revert to its expanded state and visibly animate closed on every single page transition.

To preserve UI state and avoid unnecessary re-renders, shared shells must be implemented in a Next.js layout.tsx file rather than wrapping each page component individually. The idiomatic approach is to create a Route Group (e.g., app/(main)/layout.tsx returning the AppShell) and move these specific routes inside it.

  • packages/web/app/dashboard/page.tsx#L5-L7: Remove the <AppShell> wrapper and restore the previous return structure.
  • packages/web/app/explore/page.tsx#L6-L8: Remove the <AppShell> wrapper and restore the previous return structure.
  • packages/web/app/start/page.tsx#L5-L7: Remove the <AppShell> wrapper and restore the previous return structure.
📍 Affects 3 files
  • packages/web/app/dashboard/page.tsx#L5-L7 (this comment)
  • packages/web/app/explore/page.tsx#L6-L8
  • packages/web/app/start/page.tsx#L5-L7
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/app/dashboard/page.tsx` around lines 5 - 7, Move the shared
AppShell wrapper into a persistent Next.js route-group layout, such as
app/(main)/layout.tsx, and place the dashboard, explore, and start routes under
that group. In packages/web/app/dashboard/page.tsx lines 5-7,
packages/web/app/explore/page.tsx lines 6-8, and packages/web/app/start/page.tsx
lines 5-7, remove the AppShell wrappers and restore each page’s prior return
structure so the layout owns the shell across navigation.

}
5 changes: 2 additions & 3 deletions packages/web/app/explore/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { AppShell } from '@/components/app-shell/app-shell';
import { ExploreView } from '@/components/explore/explore-view';

/** Public discovery page: browse communities or all published content. */
export default function ExplorePage() {
return (
<main className="shell">
<ExploreView />
</main>
<AppShell><main className="shell"><ExploreView /></main></AppShell>
);
}
5 changes: 2 additions & 3 deletions packages/web/app/start/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { AppShell } from '@/components/app-shell/app-shell';
import { StartWizard } from '@/components/onboarding/start-wizard';

export default function StartPage() {
return (
<main className="shell">
<StartWizard />
</main>
<AppShell><main className="shell"><StartWizard /></main></AppShell>
);
}
12 changes: 12 additions & 0 deletions packages/web/components/app-shell/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react';

import { AppSidenav } from '@/components/app-shell/app-sidenav';

export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen">
<AppSidenav />
<div className="flex-1">{children}</div>
</div>
);
}
156 changes: 156 additions & 0 deletions packages/web/components/app-shell/app-sidenav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
'use client';

import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

const SIDEBAR_STORAGE_KEY = 'komunify-app-sidebar';

const navItems = [
{
label: 'Dashboard',
href: '/dashboard',
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
),
},
{
label: 'Explore',
href: '/explore',
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="m15.5 8.5-2.1 4.9-4.9 2.1 2.1-4.9 4.9-2.1Z" />
</svg>
),
},
{
label: 'Start',
href: '/start',
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v18M3 12h18" />
<circle cx="12" cy="12" r="9" />
</svg>
),
},
] as const;

export function AppSidenav() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);

useEffect(() => {
const storedValue = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);

if (storedValue !== null) {
setCollapsed(storedValue === 'true');
}
}, []);

function toggleSidebar() {
setCollapsed((currentValue) => {
const nextValue = !currentValue;
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(nextValue));
return nextValue;
});
}

return (
<aside
className={`sticky top-0 hidden h-screen shrink-0 flex-col gap-8 overflow-y-auto border-r border-[var(--color-border-medium)] bg-[var(--color-bg-elevated)] px-4 py-5 transition-[width] duration-200 ease-out min-[901px]:flex ${
collapsed ? 'w-16' : 'w-[232px]'
}`}
>
<div
className={`flex items-center justify-between gap-3 ${
collapsed ? 'flex-col' : 'flex-row'
}`}
>
<Link
href="/"
aria-label="Komunify home"
className={`inline-flex items-center ${collapsed ? 'gap-0' : 'gap-3'}`}
>
<img
src={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/logo-mark.png`}
alt=""
className="h-8 w-auto shrink-0"
/>
<span
className={`whitespace-nowrap font-sans text-lg font-bold tracking-[0.15em] ${
collapsed ? 'hidden' : 'inline'
}`}
>
<span className="text-[var(--color-content-accent)]">K</span>
<span className="text-[var(--color-content-primary)]">OMUNIFY</span>
</span>
</Link>

<button
type="button"
onClick={toggleSidebar}
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border-medium)] bg-[var(--color-bg-input)] text-[var(--color-content-secondary)] transition-colors duration-150 hover:border-[var(--color-border-accent)] hover:bg-[var(--color-bg-accent-tint)] hover:text-[var(--color-content-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-content-accent)]"
>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="h-4 w-4 stroke-current"
>
<path
d={collapsed ? 'm9 18 6-6-6-6' : 'm15 18-6-6 6-6'}
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>

<nav aria-label="Member navigation" className="flex flex-col gap-0.5">
<p
className={`mb-1.5 text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-content-secondary)] ${
collapsed ? 'hidden' : 'block'
}`}
>
Member
</p>

{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);

return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? 'page' : undefined}
aria-label={item.label}
title={collapsed ? item.label : undefined}
className={`flex items-center rounded-[var(--radius-md)] py-2 text-[15px] transition-colors duration-150 ${
collapsed ? 'justify-center gap-0 px-0' : 'gap-2 px-2.5'
} ${
isActive
? 'bg-[var(--color-bg-accent-tint)] font-semibold text-[var(--color-content-accent)]'
: 'text-[var(--color-content-secondary)] hover:bg-[color-mix(in_srgb,var(--color-content-accent)_6%,transparent)] hover:text-[var(--color-content-primary)]'
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-content-accent)]`}
>
<span className="inline-flex shrink-0 items-center [&>svg]:h-[18px] [&>svg]:w-[18px] [&>svg]:stroke-current [&>svg]:stroke-[1.75]">
{item.icon}
</span>
<span className={collapsed ? 'hidden' : 'inline'}>{item.label}</span>
</Link>
);
})}
</nav>
</aside>
);
}
Loading