Shadcn dashbaord layout (#32)

* added sidebar

* add side-bar layout

* fixed small bugs, adding mobile sidebar

* fixed mobile sidebar

* added mobile breadcrumb

* improved project switcher order

* fixed eslint
This commit is contained in:
Zai Shi 2024-05-11 19:24:10 +02:00 committed by GitHub
parent ff6e4540b4
commit 6f19e662df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 719 additions and 112 deletions

View File

@ -84,6 +84,7 @@
"date-fns": "^3.6.0",
"dotenv-cli": "^7.3.0",
"geist": "^1",
"input-otp": "^1.2.4",
"jose": "^5.2.2",
"lucide-react": "^0.378.0",
"next": "^14.1",
@ -96,6 +97,7 @@
"react-email": "2.1.0",
"react-hook-form": "^7.51.4",
"react-icons": "^5.0.1",
"react-resizable-panels": "^2.0.19",
"rehype-katex": "^7",
"remark-gfm": "^4",
"remark-heading-id": "^1.0.1",

View File

@ -7,7 +7,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form } from "@/components/ui/form";
import { InputField, ListSwitchField } from "@/components/form-fields";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AsyncButton } from "@/components/ui/button";
@ -55,6 +55,7 @@ export default function PageClient () {
...projectUpdate,
});
await router.push('/projects/' + newProject.id);
await wait(2000);
} finally {
setLoading(false);
}
@ -70,7 +71,7 @@ export default function PageClient () {
</div>
<form onSubmit={e => runAsynchronously(form.handleSubmit(onSubmit)(e))} className="space-y-6">
<InputField control={form.control} name="displayName" label="Project Name" placeholder="My Project" />
<InputField required control={form.control} name="displayName" label="Project Name" placeholder="My Project" />
<ListSwitchField
control={form.control}
@ -98,7 +99,8 @@ export default function PageClient () {
<div className="w-1/2 self-stretch p-4 bg-zinc-300 dark:bg-zinc-800 hidden md:flex">
{mockProject ?
(
<div className='w-full sm:max-w-sm m-auto scale-90'>
// The inert attribute is not available in typescript, so this is a hack to make type works
<div className='w-full sm:max-w-sm m-auto scale-90' {...{ inert: '' }}>
{/* a transparent cover that prevents the card being clicked */}
<div className="absolute inset-0 bg-transparent z-10"></div>
<Card className="p-6">

View File

@ -2,9 +2,10 @@
import { ProjectCard } from "@/components/project-card";
import { SearchBar } from "@/components/search-bar";
import { Button } from "@/components/ui/button";
import { AsyncButton } from "@/components/ui/button";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useUser } from "@stackframe/stack";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@ -35,7 +36,11 @@ export default function PageClient() {
return (
<>
<div className="flex justify-between gap-4 mb-4 flex-col sm:flex-row">
<SearchBar placeholder="Search project name" value={search} onChange={(e) => setSearch(e.target.value)} />
<SearchBar
placeholder="Search project name"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex gap-4">
<Select value={sort} onValueChange={(n) => setSort(n === 'recency' ? 'recency' : 'name')}>
@ -50,7 +55,13 @@ export default function PageClient() {
</SelectContent>
</Select>
<Button onClick={() => router.push('/new-project')}>Create Project</Button>
<AsyncButton
onClick={async () => {
await router.push('/new-project');
return await wait(2000);
}}
>Create Project
</AsyncButton>
</div>
</div>

View File

@ -6,7 +6,7 @@ export default function Layout(props: { children: React.ReactNode, params: { pro
return (
<AdminAppProvider projectId={props.params.projectId}>
<OnboardingDialog />
<SidebarLayout params={props.params}>
<SidebarLayout projectId={props.params.projectId}>
{props.children}
</SidebarLayout>
</AdminAppProvider>

View File

@ -0,0 +1,62 @@
"use client";;
import { Drawer, Stack, useTheme } from "@mui/joy";
import { useState } from "react";
import { Sidebar } from "./sidebar";
import { Header } from "./header";
import { navigationItems } from "./navigation-data";
export default function SidebarLayout(props: { children: React.ReactNode, params: { projectId: string } }) {
const theme = useTheme();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const isCompactMediaQuery = theme.breakpoints.down("md");
const headerHeight = 50;
return (
<>
<Stack
flexGrow={1}
direction="row"
alignItems="flex-start"
>
<Sidebar
isCompactMediaQuery={isCompactMediaQuery}
headerHeight={headerHeight}
navigationItems={navigationItems}
mode='full'
/>
<Stack flexGrow={1} direction="column" sx={{ overflow: 'hidden', height: '100vh' }}>
<Header
headerHeight={headerHeight}
navigationItems={navigationItems}
isCompactMediaQuery={isCompactMediaQuery}
onShowSidebar={() => setIsSidebarOpen(true)}
/>
<Stack
paddingX={{ md: 4, xs: 2 }}
flexGrow={1}
paddingY={2}
minWidth={0}
overflow='auto'
>
<Stack spacing={2} component="main">
{props.children}
</Stack>
</Stack>
</Stack>
</Stack>
<Drawer
open={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
>
<Sidebar
isCompactMediaQuery={isCompactMediaQuery}
headerHeight={headerHeight}
navigationItems={navigationItems}
mode='compact'
/>
</Drawer>
</>
);
}

View File

@ -1,64 +1,328 @@
"use client";
'use client';;
import Link from "next/link";
import {
Book,
ChevronDownIcon,
Globe,
KeyRound,
LockKeyhole,
LucideIcon,
Menu,
Settings2,
ShieldEllipsis,
User,
Users,
} from "lucide-react";
import { Link as LinkIcon } from "lucide-react";
import { Drawer, Stack, useTheme } from "@mui/joy";
import { useState } from "react";
import { Sidebar } from "./sidebar";
import { Header } from "./header";
import { Icon } from '@/components/icon';
import { navigationItems } from "./navigation-data";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Project, UserButton, useUser } from "@stackframe/stack";
import { useColorScheme } from "@mui/joy";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { ProjectSwitcher } from "@/components/project-switcher";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { DropdownMenu } from "@radix-ui/react-dropdown-menu";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
export default function SidebarLayout(props: { children: React.ReactNode, params: { projectId: string } }) {
const theme = useTheme();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
type Label = {
name: string,
type: 'label',
};
const isCompactMediaQuery = theme.breakpoints.down("md");
type Item = {
name: string,
href: string,
icon: LucideIcon,
regex: RegExp,
type: 'item',
};
const headerHeight = 50;
type Hidden = {
name: string | string[],
regex: RegExp,
type: 'hidden',
};
const navigationItems: (Label | Item | Hidden)[] = [
{
name: "Users",
type: 'label'
},
{
name: "Users",
href: "/users",
regex: /^\/projects\/[^\/]+\/users$/,
icon: User,
type: 'item'
},
{
name: "Auth Methods",
href: "/providers",
regex: /^\/projects\/[^\/]+\/providers$/,
icon: ShieldEllipsis,
type: 'item'
},
{
name: "Teams",
type: 'label'
},
{
name: "Teams",
href: "/teams",
regex: /^\/projects\/[^\/]+\/teams$/,
icon: Users,
type: 'item'
},
{
name: ["Team", "Members"],
regex: /^\/projects\/[^\/]+\/teams\/[^\/]+$/,
type: "hidden",
},
{
name: "Permissions",
href: "/team-permissions",
regex: /^\/projects\/[^\/]+\/team-permissions$/,
icon: LockKeyhole,
type: 'item'
},
{
name: "Settings",
type: 'label'
},
{
name: "Domains & Handlers",
href: "/urls-and-callbacks",
regex: /^\/projects\/[^\/]+\/urls-and-callbacks$/,
icon: LinkIcon,
type: 'item'
},
{
name: "Team Settings",
href: "/team-settings",
regex: /^\/projects\/[^\/]+\/team-settings$/,
icon: Settings2,
type: 'item'
},
{
name: "Environment",
href: "/environment",
regex: /^\/projects\/[^\/]+\/environment$/,
icon: Globe,
type: 'item'
},
{
name: "API Keys",
href: "/api-keys",
regex: /^\/projects\/[^\/]+\/api-keys$/,
icon: KeyRound,
type: 'item'
},
];
export function NavItem({ item, projectId, onClick }: { item: Item, projectId: string, onClick?: () => void}) {
const pathname = usePathname();
const selected = useMemo(() => {
return item.regex.test(pathname);
}, [item.regex, pathname]);
return (
<>
<Stack
flexGrow={1}
direction="row"
alignItems="flex-start"
>
<Sidebar
isCompactMediaQuery={isCompactMediaQuery}
headerHeight={headerHeight}
navigationItems={navigationItems}
mode='full'
/>
<Stack flexGrow={1} direction="column" sx={{ overflow: 'hidden', height: '100vh' }}>
<Header
headerHeight={headerHeight}
navigationItems={navigationItems}
isCompactMediaQuery={isCompactMediaQuery}
onShowSidebar={() => setIsSidebarOpen(true)}
/>
<Stack
paddingX={{ md: 4, xs: 2 }}
flexGrow={1}
paddingY={2}
minWidth={0}
overflow='auto'
>
<Stack spacing={2} component="main">
{props.children}
</Stack>
</Stack>
</Stack>
</Stack>
<Drawer
open={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
>
<Sidebar
isCompactMediaQuery={isCompactMediaQuery}
headerHeight={headerHeight}
navigationItems={navigationItems}
mode='compact'
/>
</Drawer>
</>
<Link
href={`/projects/${projectId}${item.href}`}
className={cn(
buttonVariants({ variant: 'ghost', size: "default" }),
selected && "bg-muted",
"flex-grow justify-start text-md text-zinc-800 dark:text-zinc-300"
)}
onClick={onClick}
>
<item.icon className="mr-2 h-4 w-4" />
{item.name}
</Link>
);
}
export function SidebarContent({ projectId, onNavigate }: { projectId: string, onNavigate?: () => void }) {
const { mode, setMode } = useColorScheme();
return (
<div className="flex flex-col h-full items-stretch">
<div className="h-14 border-b flex items-center px-2">
<ProjectSwitcher currentProjectId={projectId} />
</div>
<div className="flex flex-col gap-1 pt-2">
{navigationItems.map((item, index) => {
if (item.type === 'label') {
return <div key={index} className="pl-2 text-sm text-gray-500">
{item.name}
</div>;
} else if (item.type === 'item') {
return <div key={index} className="flex px-1">
<NavItem item={item} projectId={projectId} onClick={onNavigate} />
</div>;
}
})}
</div>
<div className="flex-grow"/>
<div className="py-2 px-1 flex">
<NavItem
onClick={onNavigate}
item={{
name: "Documentation",
type: "item",
href: "/search",
icon: Book,
regex: /^$/,
}}
projectId={projectId}
/>
</div>
<Separator />
{/* <div className="m-2 p-2 border rounded-md self-stretch"> */}
<div className="px-2 flex items-center">
<UserButton showUserInfo colorModeToggle={() => setMode(mode === 'light' ? 'dark' : 'light')} />
</div>
</div>
);
}
export function HeaderBreadcrumb({
mobile,
projectId
}: {
projectId: string,
mobile?: boolean,
}) {
const pathname = usePathname();
const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" });
const projects = user.useOwnedProjects();
const selectedItemNames: string[] = useMemo(() => {
const name = navigationItems.find((item) => {
if (item.type === 'label') {
return false;
} else {
return item.regex.test(pathname);
}
})?.name;
if (!name) {
return [];
} else if (name instanceof Array) {
return name;
} else {
return [name];
}
}, [pathname]);
const selectedProject: Project | undefined = useMemo(() => {
return projects.find((project) => project.id === projectId);
}, [projectId, projects]);
if (mobile) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="/projects">Home</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1">
<BreadcrumbEllipsis />
<ChevronDownIcon className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>
<Link href={`/projects/${projectId}`}>{selectedProject?.displayName}</Link>
</DropdownMenuItem>
<DropdownMenuItem>
{selectedItemNames.map((name, index) => (
<BreadcrumbItem key={index}>
<BreadcrumbPage>{name}</BreadcrumbPage>
</BreadcrumbItem>
))}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
} else {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="/projects">Home</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link href={`/projects/${projectId}`}>{selectedProject?.displayName}</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
{selectedItemNames.map((name, index) => (
<BreadcrumbItem key={index}>
<BreadcrumbPage>{name}</BreadcrumbPage>
</BreadcrumbItem>
))}
</BreadcrumbList>
</Breadcrumb>
);
}
}
export default function SidebarLayout(props: { projectId: string, children?: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="w-full flex">
<div className="flex-col border-r w-[240px] h-screen sticky top-0 hidden md:flex">
<SidebarContent projectId={props.projectId} />
</div>
<div className="flex-grow w-0">
<div className="h-14 border-b flex items-center justify-between px-4 sticky top-0 bg-white dark:bg-black z-10">
<div className="hidden md:flex">
<HeaderBreadcrumb projectId={props.projectId} />
</div>
<div className="flex md:hidden items-center">
<Sheet onOpenChange={(open) => setSidebarOpen(open)} open={sidebarOpen}>
<SheetTrigger>
<Button variant="outline" className="p-2 md:hidden">
<Menu />
</Button>
</SheetTrigger>
<SheetContent side='left' className="w-[240px] p-0" hasCloseButton={false}>
<SidebarContent projectId={props.projectId} onNavigate={() => setSidebarOpen(false)} />
</SheetContent>
</Sheet>
<div className="ml-4 flex md:hidden">
<HeaderBreadcrumb projectId={props.projectId} mobile />
</div>
</div>
<Button variant="outline" onClick={() => window.open("mailto:team@stack-auth.com")}>
Feedback
</Button>
</div>
<div className="px-4">
{props.children}
</div>
</div>
</div>
);
}

View File

@ -1,17 +1,15 @@
import '../polyfills';
import './globals.css';
import React from 'react';
import type { Metadata } from 'next';
import {GeistSans} from 'geist/font/sans';
import {GeistMono} from "geist/font/mono";
import { SnackbarProvider } from '@/hooks/use-snackbar';
import { Analytics } from "@vercel/analytics/react";
import { Inter as FontSans } from "next/font/google";
import './globals.css';
import ThemeProvider from '@/theme';
import { StyleLink } from '@/components/style-link';
import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env';
import React from 'react';
import { stackServerApp } from '@/stack';
import { StackProvider, StackTheme } from '@stackframe/stack';
import { cn } from '@/lib/utils';
@ -68,7 +66,7 @@ const theme = {
backgroundColor: 'white',
neutralColor: '#e4e4e7',
},
}
},
};
export default function RootLayout({

View File

@ -4,11 +4,18 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/comp
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
export function Label({ required, children }: { children?: string, required?: boolean }) {
return <FormLabel>{children} {required ? <span className="text-sm text-zinc-500">{' *'}</span> : null}</FormLabel>;
}
export function InputField<F extends FieldValues>(props: {
control: Control<F>,
name: Path<F>,
label: string,
placeholder?: string,
required?: boolean,
}) {
return (
<FormField
@ -16,7 +23,7 @@ export function InputField<F extends FieldValues>(props: {
name={props.name}
render={({ field }) => (
<FormItem>
<FormLabel>{props.label}</FormLabel>
<Label required={props.required}>{props.label}</Label>
<FormControl>
<Input {...field} placeholder={props.placeholder} />
</FormControl>
@ -30,7 +37,8 @@ export function InputField<F extends FieldValues>(props: {
export function SwitchField<F extends FieldValues>(props: {
control: Control<F>,
name: Path<F>,
label: string,
label: string,
required?: boolean,
}) {
return (
<FormField
@ -40,7 +48,7 @@ export function SwitchField<F extends FieldValues>(props: {
<FormItem>
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>{props.label}</FormLabel>
<Label required={props.required}>{props.label}</Label>
</div>
<FormControl>
<Switch
@ -60,6 +68,7 @@ export function ListSwitchField<F extends FieldValues>(props: {
name: Path<F>,
label: string,
options: { value: string, label: string }[],
required?: boolean,
}) {
return (
<FormField
@ -72,7 +81,7 @@ export function ListSwitchField<F extends FieldValues>(props: {
{props.options.map(provider => (
<div className="flex flex-row items-center justify-between" key={provider.value}>
<div className="space-y-0.5">
<FormLabel>{provider.label}</FormLabel>
<Label required={props.required}>{provider.label}</Label>
</div>
<FormControl>
<Switch

View File

@ -1,8 +1,4 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
"use client";;
import {
Select,
SelectContent,
@ -10,47 +6,49 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUser } from "@stackframe/stack";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
interface ProjectSwitcherProps {
isCollapsed: boolean,
accounts: {
label: string,
email: string,
icon: React.ReactNode,
}[],
export function ProjectAvatar(props: { displayName: string }) {
return (
<div className="w-7 h-7 rounded-sm bg-zinc-200 dark:bg-zinc-700 mr-1 flex items-center justify-center">
<p>
{props.displayName?.slice(0,1)?.toUpperCase()}
</p>
</div>
);
}
export function ProjectSwitcher({
isCollapsed,
accounts,
}: ProjectSwitcherProps) {
const [selectedAccount, setSelectedAccount] = React.useState<string>(
accounts[0].email
);
export function ProjectSwitcher(props: { currentProjectId: string }) {
const router = useRouter();
const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" });
const rawProjects = user.useOwnedProjects();
const { currentProject, projects } = useMemo(() => {
const currentProject = rawProjects.find((project) => project.id === props.currentProjectId);
const projects = rawProjects.sort((a, b) => b.id === props.currentProjectId ? 1 : -1);
return { currentProject, projects };
}, [props.currentProjectId, rawProjects]);
return (
<Select defaultValue={selectedAccount} onValueChange={setSelectedAccount}>
<Select defaultValue={props.currentProjectId} onValueChange={(value) => { router.push(`/projects/${value}`); }}>
<SelectTrigger
className={cn(
"flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0",
isCollapsed &&
"flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden"
)}
className="h-10 flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0"
aria-label="Select account"
>
<SelectValue placeholder="Select an account">
{accounts.find((account) => account.email === selectedAccount)?.icon}
<span className={cn("ml-2", isCollapsed && "hidden")}>
{ accounts.find((account) => account.email === selectedAccount)?.label }
<ProjectAvatar displayName={currentProject?.displayName || ""} />
<span>
{ currentProject?.displayName }
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.email} value={account.email}>
<div className="flex items-center gap-3 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
{account.icon}
{account.email}
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
<div className="flex items-center gap-2 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-foreground">
<ProjectAvatar displayName={p.displayName} />
{p.displayName}
</div>
</SelectItem>
))}

View File

@ -0,0 +1,115 @@
import * as React from "react";
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode,
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean,
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@ -0,0 +1,71 @@
"use client";
import * as React from "react";
import { DashIcon } from "@radix-ui/react-icons";
import { OTPInput, OTPInputContext } from "input-otp";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<DashIcon />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -0,0 +1,45 @@
"use client";
import { DragHandleDots2Icon } from "@radix-ui/react-icons";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean,
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<DragHandleDots2Icon className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -51,12 +51,12 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> { hasCloseButton?: boolean }
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
>(({ side = "right", hasCloseButton = true, className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
@ -65,10 +65,10 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
{hasCloseButton ? <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Close> : null}
</SheetPrimitive.Content>
</SheetPortal>
));

View File

@ -15,6 +15,9 @@ const StyledTrigger = styled(DropdownMenuPrimitive.Trigger)`
outline: none;
box-shadow: 0;
}
&:hover {
cursor: pointer;
}
`;
const DropdownMenuTrigger = React.forwardRef<

View File

@ -79,7 +79,8 @@ function UserButtonInnerInner(props: UserButtonProps & { user: CurrentUser | nul
const textStyles = {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden'
overflow: 'hidden',
margin: 0,
};
return (

View File

@ -568,12 +568,12 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
return getUrls(this._urlOptions);
}
protected _redirectTo(handlerName: keyof HandlerUrls) {
protected async _redirectTo(handlerName: keyof HandlerUrls) {
if (!this.urls[handlerName]) {
throw new Error(`No URL for handler name ${handlerName}`);
}
window.location.href = this.urls[handlerName];
return wait(2000);
return await wait(2000);
}
async redirectToSignIn() { return await this._redirectTo("signIn"); }

View File

@ -550,6 +550,9 @@ importers:
geist:
specifier: ^1
version: 1.2.2(next@14.1.0)
input-otp:
specifier: ^1.2.4
version: 1.2.4(react-dom@18.2.0)(react@18.2.0)
jose:
specifier: ^5.2.2
version: 5.2.2
@ -586,6 +589,9 @@ importers:
react-icons:
specifier: ^5.0.1
version: 5.0.1(react@18.2.0)
react-resizable-panels:
specifier: ^2.0.19
version: 2.0.19(react-dom@18.2.0)(react@18.2.0)
rehype-katex:
specifier: ^7
version: 7.0.0
@ -12248,6 +12254,16 @@ packages:
/inline-style-parser@0.2.2:
resolution: {integrity: sha512-EcKzdTHVe8wFVOGEYXiW9WmJXPjqi1T+234YpJr98RiFYKHV3cdy1+3mkTE+KHTHxFFLH51SfaGOoUdW+v7ViQ==}
/input-otp@1.2.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/inquirer@9.2.19:
resolution: {integrity: sha512-WpxOT71HGsFya6/mj5PUue0sWwbpbiPfAR+332zLj/siB0QA1PZM8v3GepegFV1Op189UxHUCF6y8AySdtOMVA==}
engines: {node: '>=18'}
@ -15868,6 +15884,16 @@ packages:
use-sidecar: 1.1.2(@types/react@18.2.66)(react@18.2.0)
dev: false
/react-resizable-panels@2.0.19(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-v3E41kfKSuCPIvJVb4nL4mIZjjKIn/gh6YqZF/gDfQDolv/8XnhJBek4EiV2gOr3hhc5A3kOGOayk3DhanpaQw==}
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/react-router-config@5.1.1(react-router@5.3.4)(react@18.2.0):
resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==}
peerDependencies: