Unify workspace dropdowns; Update layouts (#458)
Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant
This commit is contained in:
committed by
GitHub
parent
ca585e09be
commit
4bc8448a1d
@@ -2,7 +2,13 @@ import Link from 'next/link';
|
||||
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
function LogoImage({
|
||||
/**
|
||||
* App Logo Image - modify this with your own logo
|
||||
* @param className - The class name to apply to the logo
|
||||
* @param width - The width of the logo
|
||||
* @returns
|
||||
*/
|
||||
export function LogoImage({
|
||||
className,
|
||||
width = 105,
|
||||
}: {
|
||||
@@ -12,7 +18,7 @@ function LogoImage({
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
className={cn(`w-[80px] lg:w-[95px]`, className)}
|
||||
className={cn(`w-20 lg:w-[95px]`, className)}
|
||||
viewBox="0 0 733 140"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -40,7 +46,12 @@ export function AppLogo({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link aria-label={label ?? 'Home Page'} href={href ?? '/'} prefetch={true}>
|
||||
<Link
|
||||
aria-label={label ?? 'Home Page'}
|
||||
href={href ?? '/'}
|
||||
prefetch={true}
|
||||
className="mx-auto md:mx-0"
|
||||
>
|
||||
<LogoImage className={className} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -13,8 +13,8 @@ export function ErrorPageContent({
|
||||
subtitle,
|
||||
reset,
|
||||
backLink = '/',
|
||||
backLabel = 'common:backToHomePage',
|
||||
contactLabel = 'common:contactUs',
|
||||
backLabel = 'common.backToHomePage',
|
||||
contactLabel = 'common.contactUs',
|
||||
}: {
|
||||
statusCode: string;
|
||||
heading: string;
|
||||
@@ -67,20 +67,27 @@ export function ErrorPageContent({
|
||||
<Trans i18nKey={backLabel} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={backLink}>
|
||||
<ArrowLeft className={'mr-1 h-4 w-4'} />
|
||||
<Trans i18nKey={backLabel} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link href={backLink}>
|
||||
<ArrowLeft className={'mr-1 h-4 w-4'} />
|
||||
<Trans i18nKey={backLabel} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button asChild variant={'ghost'}>
|
||||
<Link href={'/contact'}>
|
||||
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
|
||||
<Trans i18nKey={contactLabel} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link href={'/contact'}>
|
||||
<MessageCircleQuestion className={'mr-1 h-4 w-4'} />
|
||||
<Trans i18nKey={contactLabel} />
|
||||
</Link>
|
||||
}
|
||||
variant={'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,6 @@ import { JWTUserData } from '@kit/supabase/types';
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const paths = {
|
||||
home: pathsConfig.app.home,
|
||||
};
|
||||
|
||||
const features = {
|
||||
enableThemeToggle: featuresFlagConfig.enableThemeToggle,
|
||||
};
|
||||
@@ -19,6 +15,7 @@ const features = {
|
||||
export function ProfileAccountDropdownContainer(props: {
|
||||
user?: JWTUserData | null;
|
||||
showProfileName?: boolean;
|
||||
accountSlug?: string;
|
||||
|
||||
account?: {
|
||||
id: string | null;
|
||||
@@ -34,10 +31,22 @@ export function ProfileAccountDropdownContainer(props: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const homePath =
|
||||
featuresFlagConfig.enableTeamsOnly && props.accountSlug
|
||||
? pathsConfig.app.accountHome.replace('[account]', props.accountSlug)
|
||||
: pathsConfig.app.home;
|
||||
|
||||
const profileSettingsPath = props.accountSlug
|
||||
? pathsConfig.app.accountProfileSettings.replace(
|
||||
'[account]',
|
||||
props.accountSlug,
|
||||
)
|
||||
: pathsConfig.app.personalAccountSettings;
|
||||
|
||||
return (
|
||||
<PersonalAccountDropdown
|
||||
className={'w-full'}
|
||||
paths={paths}
|
||||
paths={{ home: homePath, profileSettings: profileSettingsPath }}
|
||||
features={features}
|
||||
user={userData}
|
||||
account={props.account}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { AbstractIntlMessages } from 'next-intl';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
|
||||
import { I18nProvider } from '@kit/i18n/provider';
|
||||
import { I18nClientProvider } from '@kit/i18n/provider';
|
||||
import { MonitoringProvider } from '@kit/monitoring/components';
|
||||
import { AppEventsProvider } from '@kit/shared/events';
|
||||
import { If } from '@kit/ui/if';
|
||||
@@ -14,34 +13,32 @@ import { AnalyticsProvider } from '~/components/analytics-provider';
|
||||
import { AuthProvider } from '~/components/auth-provider';
|
||||
import appConfig from '~/config/app.config';
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
import { i18nResolver } from '~/lib/i18n/i18n.resolver';
|
||||
import { getI18nSettings } from '~/lib/i18n/i18n.settings';
|
||||
|
||||
import { ReactQueryProvider } from './react-query-provider';
|
||||
|
||||
type RootProvidersProps = React.PropsWithChildren<{
|
||||
// The language to use for the app (optional)
|
||||
lang?: string;
|
||||
locale?: string;
|
||||
// The theme (light or dark or system) (optional)
|
||||
theme?: string;
|
||||
// The CSP nonce to pass to scripts (optional)
|
||||
nonce?: string;
|
||||
messages: AbstractIntlMessages;
|
||||
}>;
|
||||
|
||||
export function RootProviders({
|
||||
lang,
|
||||
locale = 'en',
|
||||
messages,
|
||||
theme = appConfig.theme,
|
||||
nonce,
|
||||
children,
|
||||
}: RootProvidersProps) {
|
||||
const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]);
|
||||
|
||||
return (
|
||||
<MonitoringProvider>
|
||||
<AppEventsProvider>
|
||||
<AnalyticsProvider>
|
||||
<ReactQueryProvider>
|
||||
<I18nProvider settings={i18nSettings} resolver={i18nResolver}>
|
||||
<I18nClientProvider locale={locale!} messages={messages}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
@@ -58,7 +55,7 @@ export function RootProviders({
|
||||
<If condition={featuresFlagConfig.enableVersionUpdater}>
|
||||
<VersionUpdater />
|
||||
</If>
|
||||
</I18nProvider>
|
||||
</I18nClientProvider>
|
||||
</ReactQueryProvider>
|
||||
</AnalyticsProvider>
|
||||
</AppEventsProvider>
|
||||
|
||||
382
apps/web/components/workspace-dropdown.tsx
Normal file
382
apps/web/components/workspace-dropdown.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
LogOut,
|
||||
MessageCircleQuestion,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
import { CreateTeamAccountDialog } from '@kit/team-accounts/components';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { SubMenuModeToggle } from '@kit/ui/mode-toggle';
|
||||
import { ProfileAvatar } from '@kit/ui/profile-avatar';
|
||||
import { useSidebar } from '@kit/ui/sidebar';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import featuresFlagConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export type AccountModel = {
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
interface WorkspaceDropdownProps {
|
||||
user: JWTUserData;
|
||||
accounts: AccountModel[];
|
||||
selectedAccount?: string;
|
||||
workspace?: {
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
picture_url: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkspaceDropdown({
|
||||
user,
|
||||
accounts,
|
||||
selectedAccount,
|
||||
workspace,
|
||||
}: WorkspaceDropdownProps) {
|
||||
const router = useRouter();
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const signOutMutation = useSignOut();
|
||||
|
||||
const [isCreatingTeam, setIsCreatingTeam] = useState(false);
|
||||
|
||||
const collapsed = !isSidebarOpen;
|
||||
const isTeamContext = !!selectedAccount;
|
||||
|
||||
const { data: personalAccountData } = usePersonalAccountData(
|
||||
user.id,
|
||||
workspace,
|
||||
);
|
||||
|
||||
const displayName = personalAccountData?.name ?? user.email ?? '';
|
||||
const userEmail = user.email ?? '';
|
||||
|
||||
const isSuperAdmin =
|
||||
user.app_metadata.role === 'super-admin' && user.aal === 'aal2';
|
||||
|
||||
const currentTeam = accounts.find((a) => a.value === selectedAccount);
|
||||
|
||||
const currentLabel = isTeamContext
|
||||
? (currentTeam?.label ?? selectedAccount)
|
||||
: displayName;
|
||||
|
||||
const currentAvatar = isTeamContext
|
||||
? (currentTeam?.image ?? null)
|
||||
: (personalAccountData?.picture_url ?? null);
|
||||
|
||||
const settingsPath = selectedAccount
|
||||
? pathsConfig.app.accountSettings.replace('[account]', selectedAccount)
|
||||
: pathsConfig.app.personalAccountSettings;
|
||||
|
||||
const switchToPersonal = () => {
|
||||
if (!featuresFlagConfig.enableTeamsOnly) {
|
||||
router.replace(pathsConfig.app.home);
|
||||
}
|
||||
};
|
||||
|
||||
const switchToTeam = (slug: string) => {
|
||||
router.replace(pathsConfig.app.accountHome.replace('[account]', slug));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<DropdownMenu>
|
||||
{collapsed ? (
|
||||
<div className="flex min-h-12 flex-col items-center justify-center">
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
data-test="workspace-dropdown-trigger"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="border-border hover:shadow"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage
|
||||
className="rounded-md!"
|
||||
src={currentAvatar ?? undefined}
|
||||
alt={currentLabel ?? ''}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{isTeamContext ? (
|
||||
(currentLabel ?? '').charAt(0).toUpperCase()
|
||||
) : (
|
||||
<User className="text-secondary-foreground size-4" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
data-test="workspace-dropdown-trigger"
|
||||
variant="ghost"
|
||||
className="hover:bg-accent/40 active:bg-accent border-border/50! hover:border-border h-12! w-full justify-start gap-x-2 rounded-md border px-2 transition-colors hover:shadow-xs"
|
||||
>
|
||||
<span className="flex aspect-square size-8 items-center justify-center">
|
||||
<Avatar className="size-8 rounded-md">
|
||||
<AvatarImage
|
||||
className="rounded-md!"
|
||||
src={currentAvatar ?? undefined}
|
||||
alt={currentLabel ?? ''}
|
||||
/>
|
||||
<AvatarFallback className="rounded-md!">
|
||||
{isTeamContext ? (
|
||||
(currentLabel ?? '').charAt(0).toUpperCase()
|
||||
) : (
|
||||
<User className="size-4" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</span>
|
||||
|
||||
<span className="ml-2 grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="max-w-md truncate">{currentLabel}</span>
|
||||
</span>
|
||||
|
||||
<ChevronsUpDown className="ml-auto size-4 transition-opacity duration-300" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuContent
|
||||
className="min-w-60!"
|
||||
align="center"
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2 px-2 py-1.5">
|
||||
<div className="w-2/12">
|
||||
<ProfileAvatar
|
||||
className="size-8 rounded-md"
|
||||
fallbackClassName="rounded-md"
|
||||
displayName={displayName}
|
||||
pictureUrl={personalAccountData?.picture_url}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-10/12 flex-col text-left text-sm">
|
||||
<span
|
||||
className="max-w-max truncate font-medium"
|
||||
data-test="account-dropdown-display-name"
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
|
||||
<span className="text-muted-foreground max-w-max truncate text-xs">
|
||||
{userEmail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<If condition={featuresFlagConfig.enableTeamAccounts}>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger data-test="workspace-switch-submenu">
|
||||
<Users className="size-4" />
|
||||
<span>
|
||||
<Trans i18nKey={'teams.switchWorkspace'} />
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent
|
||||
className="max-h-[50vh] overflow-y-auto"
|
||||
data-test="workspace-switch-content"
|
||||
>
|
||||
<If condition={!featuresFlagConfig.enableTeamsOnly}>
|
||||
<DropdownMenuItem
|
||||
data-test="personal-workspace-item"
|
||||
className="flex gap-2"
|
||||
onClick={switchToPersonal}
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-sm border">
|
||||
<User className="size-4" />
|
||||
</div>
|
||||
|
||||
<span className="flex-1">
|
||||
<Trans i18nKey={'teams.personalAccount'} />
|
||||
</span>
|
||||
|
||||
{!isTeamContext && <Check className="ml-auto size-4" />}
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<If condition={!featuresFlagConfig.enableTeamsOnly}>
|
||||
<DropdownMenuSeparator />
|
||||
</If>
|
||||
|
||||
{accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.value}
|
||||
data-test="workspace-team-item"
|
||||
data-name={account.label}
|
||||
data-slug={account.value}
|
||||
className="flex gap-2"
|
||||
onClick={() => {
|
||||
if (
|
||||
account.value &&
|
||||
account.value !== selectedAccount
|
||||
) {
|
||||
switchToTeam(account.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage
|
||||
className="rounded-md!"
|
||||
src={account.image ?? undefined}
|
||||
alt={account.label ?? ''}
|
||||
/>
|
||||
<AvatarFallback className="rounded-md! text-xs">
|
||||
{(account.label ?? '').charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{account.label}</div>
|
||||
</div>
|
||||
|
||||
{selectedAccount === account.value && (
|
||||
<Check className="ml-auto size-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<If condition={featuresFlagConfig.enableTeamCreation}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsCreatingTeam(true)}
|
||||
data-test="create-team-trigger"
|
||||
className="bg-background/50 sticky bottom-0 mt-1 flex h-10 w-full gap-2 border backdrop-blur-lg"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'teams.createTeam'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</If>
|
||||
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
className="flex items-center gap-x-2"
|
||||
href={settingsPath}
|
||||
data-test="workspace-settings-link"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common.routes.settings'} />
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<If condition={isSuperAdmin}>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
className="flex items-center gap-x-2 text-yellow-700 hover:text-yellow-600 dark:text-yellow-500"
|
||||
href="/admin"
|
||||
data-test="workspace-admin-link"
|
||||
>
|
||||
<Shield className="size-4" />
|
||||
|
||||
<span>Super Admin</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link className="flex items-center gap-x-2" href="/docs">
|
||||
<MessageCircleQuestion className="size-4" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common.documentation'} />
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<If condition={featuresFlagConfig.enableThemeToggle}>
|
||||
<SubMenuModeToggle />
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</If>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={signOutMutation.isPending}
|
||||
className="flex items-center gap-x-2"
|
||||
data-test="workspace-sign-out"
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'auth.signOut'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<If condition={featuresFlagConfig.enableTeamCreation}>
|
||||
<CreateTeamAccountDialog
|
||||
isOpen={isCreatingTeam}
|
||||
setIsOpen={setIsCreatingTeam}
|
||||
/>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user