feat(ui): add workspace customization and simplify navigation

This commit is contained in:
poyrazavsever
2026-07-17 12:47:54 +03:00
parent b04031a3e3
commit dafd9eb344
24 changed files with 855 additions and 224 deletions
+78 -71
View File
@@ -1,6 +1,8 @@
"use client";
import { signOut } from "@/app/login/actions";
import { ColorModeSync } from "@/components/theme/color-mode-sync";
import type { ColorMode } from "@/lib/color-mode";
import {
Card,
CardContent,
@@ -10,6 +12,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
@@ -18,7 +21,6 @@ import {
TooltipTrigger,
} from "poyraz-ui/molecules";
import {
SidebarBranding,
SidebarContent,
SidebarFooter,
SidebarGroup,
@@ -28,7 +30,6 @@ import {
SidebarMenuItem,
SidebarPanel,
SidebarProvider,
SidebarRail,
SidebarTrigger,
SidebarUserProfile,
useSidebar,
@@ -72,6 +73,7 @@ type AppShellProps = {
settingsHref: string;
user: ShellUser;
progress?: number;
colorMode?: ColorMode;
};
export function AppShell({
@@ -82,12 +84,14 @@ export function AppShell({
settingsHref,
user,
progress,
colorMode,
}: AppShellProps) {
const pathname = usePathname();
const sidebarProps = { branding, homeHref, navGroups, pathname, progress, settingsHref, user };
return (
<TooltipProvider>
{colorMode ? <ColorModeSync colorMode={colorMode} /> : null}
<div className="min-h-screen bg-background text-foreground">
<a
href="#main-content"
@@ -123,10 +127,9 @@ type SidebarCompositionProps = {
function DesktopSidebar(props: SidebarCompositionProps) {
return (
<SidebarProvider variant="collapsible">
<SidebarProvider variant="default">
<SidebarPanel className="sticky top-0 hidden h-screen shrink-0 self-stretch lg:flex">
<SidebarComposition {...props} />
<SidebarRail aria-label="Kenar çubuğunu daralt veya genişlet" />
</SidebarPanel>
</SidebarProvider>
);
@@ -136,8 +139,12 @@ function MobileSidebar(props: SidebarCompositionProps) {
return (
<SidebarProvider variant="floating">
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-surface/95 px-4 backdrop-blur lg:hidden">
<Link href={props.homeHref} className="min-w-0">
<MobileBrand branding={props.branding} />
<Link
href={props.homeHref}
className="flex min-w-0 max-w-40 items-center"
aria-label={`${props.branding.applicationName} ana sayfa`}
>
<WorkspaceLogo branding={props.branding} compact />
</Link>
<Tooltip>
<TooltipTrigger asChild>
@@ -165,23 +172,14 @@ function SidebarComposition({
}: SidebarCompositionProps) {
return (
<>
<SidebarHeader>
<Link href={homeHref} className="min-w-0 flex-1" aria-label={`${branding.applicationName} ana sayfa`}>
<SidebarBranding
logo={<BrandMark branding={branding} />}
title={branding.applicationName}
subtitle={branding.organizationName ?? "Freelancer portalı"}
/>
<SidebarHeader className="justify-center px-4 py-3">
<Link
href={homeHref}
className="flex min-h-12 w-full items-center justify-center"
aria-label={`${branding.applicationName} ana sayfa`}
>
<WorkspaceLogo branding={branding} />
</Link>
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger
className="hidden lg:inline-flex"
aria-label="Kenar çubuğunu daralt veya genişlet"
/>
</TooltipTrigger>
<TooltipContent>Kenar çubuğunu daralt</TooltipContent>
</Tooltip>
</SidebarHeader>
<SidebarContent scrollMode="fade">
@@ -239,54 +237,41 @@ function SidebarNavigation({
));
}
function BrandMark({ branding }: { branding: AppShellBranding }) {
const logoUrl = branding.lightLogoUrl ?? branding.darkLogoUrl;
if (!logoUrl) {
return (
<span className="flex h-full w-full items-center justify-center text-sm font-bold text-primary">
{branding.applicationName.slice(0, 1).toLocaleUpperCase("tr-TR")}
</span>
);
}
function WorkspaceLogo({
branding,
compact = false,
}: {
branding: AppShellBranding;
compact?: boolean;
}) {
const lightLogoUrl = branding.lightLogoUrl ?? branding.darkLogoUrl ?? "/logo/blackLogoLong.png";
const darkLogoUrl = branding.darkLogoUrl ?? branding.lightLogoUrl ?? "/logo/lightLogoLong.png";
const imageClassName = compact
? "max-h-8 w-auto max-w-full object-contain"
: "max-h-12 w-auto max-w-full object-contain";
return (
<span className="flex h-full w-full items-center justify-center">
<span className="flex h-full w-full items-center justify-center overflow-hidden">
<Image
src={logoUrl}
alt=""
width={32}
height={32}
src={lightLogoUrl}
alt={`${branding.applicationName} logosu`}
width={180}
height={56}
unoptimized
className={branding.darkLogoUrl ? "h-full w-full object-contain dark:hidden" : "h-full w-full object-contain"}
className={`${imageClassName} dark:hidden`}
/>
<Image
src={darkLogoUrl}
alt={`${branding.applicationName} logosu`}
width={180}
height={56}
unoptimized
className={`hidden ${imageClassName} dark:block`}
/>
{branding.darkLogoUrl ? (
<Image
src={branding.darkLogoUrl}
alt=""
width={32}
height={32}
unoptimized
className="hidden h-full w-full object-contain dark:block"
/>
) : null}
</span>
);
}
function MobileBrand({ branding }: { branding: AppShellBranding }) {
return (
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-border bg-muted">
<BrandMark branding={branding} />
</span>
<Typography component="span" variant="small" className="truncate font-semibold">
{branding.applicationName}
</Typography>
</div>
);
}
function ProgressSummary({ progress }: { progress: number }) {
const normalizedProgress = Math.max(0, Math.min(100, progress));
@@ -325,8 +310,8 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Hesap menüsünü aç"
className="flex w-full items-center gap-2 rounded-sm p-1 text-left outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-focus-ring"
aria-label={`${user.displayName} için hesap menüsünü aç`}
className="group flex w-full items-center gap-2 rounded-md border border-transparent p-1.5 text-left outline-none transition-[color,background-color,border-color] hover:border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-focus-ring data-[state=open]:border-border data-[state=open]:bg-accent"
>
<SidebarUserProfile
className="min-w-0 flex-1"
@@ -335,22 +320,44 @@ function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: st
avatarUrl={user.avatarUrl ?? undefined}
initials={user.shortName}
/>
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<ChevronUp
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="min-w-52">
<DropdownMenuItem asChild media={<Settings className="h-4 w-4" aria-hidden="true" />}>
<Link href={settingsHref}>Ayarlar</Link>
<DropdownMenuContent
align="start"
side="top"
sideOffset={8}
collisionPadding={12}
surface="solid"
radius="md"
itemRadius="sm"
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-56"
>
<DropdownMenuLabel className="space-y-0.5 px-2.5 py-2">
<span className="block truncate text-sm font-semibold text-foreground">
{user.displayName}
</span>
<span className="block truncate font-normal text-muted-foreground">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={settingsHref} className="gap-2">
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span>Ayarlar</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<form action={signOut}>
<DropdownMenuItem
asChild
media={<LogOut className="h-4 w-4" aria-hidden="true" />}
className="text-destructive"
className="text-destructive focus:text-destructive data-[highlighted]:text-destructive"
>
<button type="submit" className="w-full">
Çıkış yap
<button type="submit" className="w-full gap-2 text-left">
<LogOut className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>Çıkış yap</span>
</button>
</DropdownMenuItem>
</form>
+11 -2
View File
@@ -2,10 +2,12 @@
import { AppShell, type AppShellBranding } from "@/components/layout/app-shell";
import { sidebarData } from "@/config/sidebar";
import type { ColorMode } from "@/lib/color-mode";
type DashboardShellProps = {
branding: AppShellBranding;
children: React.ReactNode;
colorMode: ColorMode;
user: {
email: string;
displayName: string;
@@ -14,9 +16,16 @@ type DashboardShellProps = {
};
};
export function DashboardShell({ branding, children, user }: DashboardShellProps) {
export function DashboardShell({ branding, children, colorMode, user }: DashboardShellProps) {
return (
<AppShell branding={branding} homeHref="/" navGroups={sidebarData} settingsHref="/settings" user={user}>
<AppShell
branding={branding}
colorMode={colorMode}
homeHref="/"
navGroups={sidebarData}
settingsHref="/settings"
user={user}
>
{children}
</AppShell>
);
+4 -1
View File
@@ -2,10 +2,12 @@
import { AppShell, type AppShellBranding } from "@/components/layout/app-shell";
import { portalSidebarData } from "@/config/portal-sidebar";
import type { ColorMode } from "@/lib/color-mode";
type PortalShellProps = {
branding: AppShellBranding;
children: React.ReactNode;
colorMode: ColorMode;
user: {
email: string;
displayName: string;
@@ -15,10 +17,11 @@ type PortalShellProps = {
progress?: number;
};
export function PortalShell({ branding, children, user, progress }: PortalShellProps) {
export function PortalShell({ branding, children, colorMode, user, progress }: PortalShellProps) {
return (
<AppShell
branding={branding}
colorMode={colorMode}
homeHref="/portal"
navGroups={portalSidebarData}
settingsHref="/portal/settings"
-7
View File
@@ -4,7 +4,6 @@ import type { ReactNode } from "react";
type PageHeaderProps = {
title: string;
description?: string;
eyebrow?: string;
primaryAction?: ReactNode;
secondaryActions?: ReactNode;
};
@@ -12,18 +11,12 @@ type PageHeaderProps = {
export function PageHeader({
title,
description,
eyebrow,
primaryAction,
secondaryActions,
}: PageHeaderProps) {
return (
<header className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0 space-y-1.5">
{eyebrow ? (
<Typography component="p" variant="caption" className="font-semibold uppercase tracking-wider text-primary">
{eyebrow}
</Typography>
) : null}
<Typography component="h1" variant="h1" balance>
{title}
</Typography>
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import {
COLOR_MODE_COOKIE,
COLOR_MODE_COOKIE_MAX_AGE,
type ColorMode,
} from "@/lib/color-mode";
export function ColorModeSync({ colorMode }: { colorMode: ColorMode }) {
useEffect(() => {
applyColorMode(colorMode);
}, [colorMode]);
return null;
}
export function applyColorMode(colorMode: ColorMode): void {
const root = document.documentElement;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.dataset.colorMode = colorMode;
root.classList.toggle("dark", colorMode === "dark" || (colorMode === "system" && prefersDark));
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${COLOR_MODE_COOKIE}=${colorMode}; Path=/; Max-Age=${COLOR_MODE_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
}