feat(layout): implement core layout structure and navigation sidebars

This commit is contained in:
Chneemann 2026-08-28 15:08:27 +02:00
parent fb862b6e1b
commit 9d175303f5
No known key found for this signature in database
13 changed files with 436 additions and 36 deletions

View file

@ -1,16 +1,57 @@
/**
* @file app/(app)/layout.tsx
* @description Layout component providing a full-screen flex container with overflow clipping for application sub-routes.
* @description Main application layout component that manages responsive sidebars and drawer overlays.
*/
"use client";
import { useSidebarStore } from "@/lib/store/useSidebarStore";
import { ServerSidebar } from "@/components/sidebar/ServerSidebar";
import { ChannelSidebar } from "@/components/sidebar/ChannelSidebar";
import { MemberSidebar } from "@/components/sidebar/MemberSidebar";
import { UserProfile } from "@/components/sidebar/UserProfile";
import { MobileDrawer } from "@/components/ui/MobileDrawer";
/**
* App group layout wrapper component.
*
* @param {Object} props - The component props.
* @param {React.ReactNode} props.children - The child elements to be rendered within the layout container.
* @returns {JSX.Element} The rendered layout container wrapping the child components.
*/
* Client component serving as the primary application layout, handling the server, channel, and member sidebars with mobile drawer support.
*
* @param {Object} props - The component props.
* @param {React.ReactNode} props.children - The child layout or page content to render in the central main view.
* @returns {JSX.Element} The application layout structure with responsive sidebars.
*/
export default function AppLayout({ children }: { children: React.ReactNode }) {
return <div className="flex h-screen w-full overflow-hidden">{children}</div>;
const { isNavOpen, isMembersOpen, closeAll } = useSidebarStore();
return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
{/* Left Navigation: Server + Channels + User Profile */}
<MobileDrawer
isOpen={isNavOpen}
onClose={closeAll}
side="left"
breakpoint="md"
>
<div className="flex flex-col h-full shrink-0">
<div className="flex flex-1 min-h-0">
<ServerSidebar />
<ChannelSidebar />
</div>
<UserProfile />
</div>
</MobileDrawer>
{/* Center: Main Area */}
<div className="flex-1 flex min-w-0">{children}</div>
{/* Right: List of Members */}
<MobileDrawer
isOpen={isMembersOpen}
onClose={closeAll}
side="right"
breakpoint="xl"
>
<MemberSidebar />
</MobileDrawer>
</div>
);
}

View file

@ -1,33 +1,28 @@
/**
* @file app/(app)/page.tsx
* @description Welcome page component displayed as the initial landing screen of the Waveform application.
* @description Main dashboard page displaying the primary chat view with header, message area, and input control.
*/
import Image from "next/image";
import { ChatHeader } from "@/components/chat/ChatHeader";
import { ChatInput } from "@/components/chat/ChatInput";
/**
* Renders the welcome page featuring the Waveform logo, main heading, and confirmation text.
* Renders the main application dashboard containing the general chat interface.
*
* @returns {JSX.Element} The rendered welcome page component.
* @returns {Promise<JSX.Element>} The rendered application dashboard page.
*/
export default function WelcomePage() {
export default async function ApplicationDashboardPage() {
return (
<main className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<div className="mb-6 relative w-24 h-24">
<Image
src="/logo.png"
alt="Waveform Logo"
fill
className="object-contain"
priority
/>
<main className="flex-1 flex flex-col h-full min-w-0 bg-background">
<ChatHeader />
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="flex items-center justify-center h-full text-neutral-500 text-sm">
This is the beginning of the #general channel.
</div>
</div>
<h1 className="text-4xl font-extrabold tracking-tight text-accent mb-2">
Waveform
</h1>
<p className="max-w-md">
The basic framework has been successfully set up!
</p>
<ChatInput />
</main>
);
}

View file

@ -1,10 +1,14 @@
@import "tailwindcss";
@theme {
--color-background: hsl(200 6% 10%);
--color-foreground: hsl(210 5% 86%);
--color-background: hsl(220 13% 13%);
--color-surface: hsl(223 13% 10%);
--color-accent: hsl(235 86% 65%);
--color-accent-hover: hsl(235 86% 58%);
--color-foreground: hsl(210 11% 93%);
--color-muted: hsl(215 8% 55%);
--font-sans:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,

View file

@ -0,0 +1,45 @@
/**
* @file components/chat/ChatHeader.tsx
* @description Client component rendering the top header of the chat interface with sidebar toggles and current channel details.
*/
"use client";
import { Menu, Hash, Users } from "lucide-react";
import { useSidebarStore } from "@/lib/store/useSidebarStore";
/**
* Renders the top navigation header for the chat view, providing toggle triggers for mobile navigation and member list sidebars.
*
* @returns {JSX.Element} The rendered chat header component.
*/
export function ChatHeader() {
const { toggleNav, toggleMembers } = useSidebarStore();
return (
<header className="h-12 border-b border-neutral-800 flex items-center justify-between px-4 bg-background shrink-0">
<div className="flex items-center gap-3 font-semibold">
<button
onClick={toggleNav}
className="md:hidden text-muted hover:text-foreground focus:outline-none cursor-pointer"
aria-label="Toggle Navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-1.5">
<Hash className="w-5 h-5 text-muted" />
<span className="text-foreground">general</span>
</div>
</div>
<button
onClick={toggleMembers}
className="xl:hidden text-muted hover:text-foreground focus:outline-none cursor-pointer"
aria-label="Toggle Members"
>
<Users className="w-5 h-5" />
</button>
</header>
);
}

View file

@ -0,0 +1,23 @@
/**
* @file components/chat/ChatInput.tsx
* @description Client component providing an input field for composing and sending chat messages.
*/
/**
* Renders the input container component for sending messages in a chat channel.
*
* @returns {JSX.Element} The rendered chat input UI element.
*/
export function ChatInput() {
return (
<div className="p-3 md:p-4 bg-background shrink-0">
<div className="bg-surface border border-surface rounded-lg p-2.5 flex items-center focus-within:ring-1 focus-within:ring-accent transition-all">
<input
type="text"
placeholder="Nachricht an #allgemein"
className="w-full bg-transparent outline-none text-foreground placeholder-muted text-sm"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
/**
* @file components/sidebar/ChannelSidebar.tsx
* @description Sidebar component for server navigation, rendering channel lists.
*/
import { Hash } from "lucide-react";
/**
* Navigation sidebar displaying server details and text channels.
*
* @returns {JSX.Element} The rendered channel sidebar element.
*/
export function ChannelSidebar() {
return (
<aside className="w-60 bg-surface flex flex-col border-r border-background shrink-0 h-full">
<header className="h-12 border-b border-background flex items-center px-4 font-semibold text-foreground shrink-0">
Waveform Community
</header>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider mb-2 px-2">
Text Channels
</h2>
<nav className="space-y-0.5">
<button className="w-full text-left px-2 py-1.5 rounded text-muted hover:bg-background hover:text-foreground font-medium text-sm flex items-center gap-2 transition-colors cursor-pointer">
<Hash className="w-4 h-4 text-muted shrink-0" />
<span className="truncate">general</span>
</button>
<button className="w-full text-left px-2 py-1.5 rounded text-muted hover:bg-background hover:text-foreground font-medium text-sm flex items-center gap-2 transition-colors cursor-pointer">
<Hash className="w-4 h-4 text-muted shrink-0" />
<span className="truncate">dev-talk</span>
</button>
</nav>
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,25 @@
/**
* @file components/sidebar/MemberSidebar.tsx
* @description Sidebar component displaying the list of active server/chat members and their online status.
*/
/**
* Renders the member sidebar showing online users and their profile avatars.
*
* @returns {JSX.Element} The rendered member sidebar navigation container.
*/
export function MemberSidebar() {
return (
<aside className="w-60 bg-[hsl(200_6%_8%)] border-l border-neutral-800 p-4 shrink-0 h-full overflow-y-auto">
<h2 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
Online 1
</h2>
<div className="flex items-center gap-3 py-1.5">
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center font-bold text-xs text-white">
U
</div>
<span className="text-sm font-medium text-foreground">User</span>
</div>
</aside>
);
}

View file

@ -0,0 +1,25 @@
/**
* @file components/sidebar/ServerSidebar.tsx
* @description Sidebar navigation component for switching between servers and direct messages.
*/
/**
* Navigation bar for switching between servers and direct messages.
*
* @returns {JSX.Element} The rendered server sidebar component.
*/
export function ServerSidebar() {
return (
<aside className="w-18 bg-surface flex flex-col items-center py-3 gap-3 border-r border-background shrink-0 h-full justify-between">
<div className="flex flex-col items-center gap-3 w-full">
<div className="w-12 h-12 rounded-2xl bg-accent flex items-center justify-center font-bold text-white cursor-pointer hover:rounded-xl transition-all shadow-md">
W
</div>
<div className="w-8 h-0.5 bg-background/80 rounded-full" />
<div className="w-12 h-12 rounded-3xl bg-background flex items-center justify-center text-muted hover:bg-accent hover:text-white hover:rounded-xl transition-all cursor-pointer">
+
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,45 @@
/**
* @file components/sidebar/UserProfile.tsx
* @description User profile component rendered at the bottom of the sidebar, displaying user avatar, status indicator, username, and settings action trigger.
*/
import { Settings } from "lucide-react";
/**
* Renders the user profile card with user information, online status, and quick access settings.
*
* @returns {JSX.Element} The rendered user profile footer element.
*/
export function UserProfile() {
return (
<div className="p-2 w-full bg-surface shrink-0">
<footer className="h-14 bg-background/80 hover:bg-background/90 border border-background/50 rounded-xl flex items-center justify-between px-3 gap-2 shadow-lg backdrop-blur-md transition-all duration-200">
<div className="flex items-center gap-2.5 min-w-0 flex-1 cursor-pointer group">
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-white font-bold text-xs shadow-sm transition-transform group-hover:scale-105">
U
</div>
{/* Online Status Indicator */}
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-500 ring-2 ring-background" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate leading-tight group-hover:text-accent transition-colors">
Username
</p>
<p className="text-xs text-muted truncate leading-tight font-medium">
Online
</p>
</div>
</div>
<button
className="p-1.5 text-muted hover:text-foreground focus:outline-none cursor-pointer"
aria-label="User Settings"
>
<Settings className="w-4 h-4" />
</button>
</footer>
</div>
);
}

View file

@ -0,0 +1,66 @@
/**
* @file components/ui/MobileDrawer.tsx
* @description Slide-out drawer component for mobile views with configurable side placement, responsive breakpoint behavior, and backdrop overlay.
*/
"use client";
import { clsx } from "clsx";
interface MobileDrawerProps {
isOpen: boolean;
onClose: () => void;
side: "left" | "right";
breakpoint: "md" | "xl";
children: React.ReactNode;
}
/**
* Renders a responsive drawer container that slides in from either side on mobile viewports and transitions to a static layout at specified breakpoints.
*
* @param {MobileDrawerProps} props - The component props.
* @param {boolean} props.isOpen - Controls the visibility state of the drawer overlay on mobile viewports.
* @param {() => void} props.onClose - Callback function triggered when clicking the backdrop overlay to close the drawer.
* @param {"left" | "right"} props.side - The screen edge from which the drawer slides out.
* @param {"md" | "xl"} props.breakpoint - Tailwind responsive breakpoint at which the drawer becomes static and hides the mobile backdrop.
* @param {React.ReactNode} props.children - Content rendered within the drawer body.
* @returns {JSX.Element} The drawer element alongside its conditional backdrop overlay.
*/
export function MobileDrawer({
isOpen,
onClose,
side,
breakpoint,
children,
}: MobileDrawerProps) {
const isLeft = side === "left";
const translateHidden = isLeft ? "-translate-x-full" : "translate-x-full";
const breakpointStatic =
breakpoint === "md"
? "md:static md:translate-x-0"
: "xl:static xl:translate-x-0";
const breakpointHidden = breakpoint === "md" ? "md:hidden" : "xl:hidden";
return (
<>
<div
className={clsx(
"fixed inset-y-0 z-40 flex transform transition-transform duration-200 ease-in-out h-full",
isLeft ? "left-0" : "right-0",
breakpointStatic,
isOpen ? "translate-x-0" : translateHidden,
)}
>
{children}
</div>
{isOpen && (
<div
className={clsx("fixed inset-0 bg-black/60 z-30", breakpointHidden)}
onClick={onClose}
/>
)}
</>
);
}

View file

@ -0,0 +1,39 @@
/**
* @file lib/store/useSidebarStore.ts
* @description Condition state management store for controlling the visibility and mutual exclusion of navigation and members sidebars.
*/
import { create } from "zustand";
/**
* Interface representing the state and actions of the sidebar store.
*
* @interface SidebarState
* @property {boolean} isNavOpen - Indicates whether the navigation sidebar is open.
* @property {boolean} isMembersOpen - Indicates whether the members list sidebar is open.
* @property {() => void} toggleNav - Toggles the navigation sidebar while automatically closing the members sidebar.
* @property {() => void} toggleMembers - Toggles the members sidebar while automatically closing the navigation sidebar.
* @property {() => void} closeAll - Closes both the navigation and members sidebars simultaneously.
*/
interface SidebarState {
isNavOpen: boolean;
isMembersOpen: boolean;
toggleNav: () => void;
toggleMembers: () => void;
closeAll: () => void;
}
/**
* Custom Condition hook for managing global sidebar visibility states.
*
* @returns {SidebarState} The current sidebar state and action handlers.
*/
export const useSidebarStore = create<SidebarState>((set) => ({
isNavOpen: false,
isMembersOpen: false,
toggleNav: () =>
set((state) => ({ isNavOpen: !state.isNavOpen, isMembersOpen: false })),
toggleMembers: () =>
set((state) => ({ isMembersOpen: !state.isMembersOpen, isNavOpen: false })),
closeAll: () => set({ isNavOpen: false, isMembersOpen: false }),
}));

56
package-lock.json generated
View file

@ -8,9 +8,12 @@
"name": "waveform",
"version": "0.1.0",
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.35.0",
"next": "16.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
@ -1722,7 +1725,7 @@
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -2853,6 +2856,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2906,7 +2918,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -5156,6 +5168,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.35.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.35.0.tgz",
"integrity": "sha512-yXCCWxGFYT6bLIPYC4SY6fPQPRs/d797rRIue+J9XP2Td6vQvD53gaQRBCnIVT1kTQRHtAtxlfOQNWAuIF8ELg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -6891,6 +6912,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.15",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
"integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View file

@ -9,9 +9,12 @@
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.35.0",
"next": "16.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",