mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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:
parent
ff6e4540b4
commit
6f19e662df
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
115
packages/stack-server/src/components/ui/breadcrumb.tsx
Normal file
115
packages/stack-server/src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
};
|
||||
71
packages/stack-server/src/components/ui/input-otp.tsx
Normal file
71
packages/stack-server/src/components/ui/input-otp.tsx
Normal 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 };
|
||||
45
packages/stack-server/src/components/ui/resizable.tsx
Normal file
45
packages/stack-server/src/components/ui/resizable.tsx
Normal 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 };
|
||||
@ -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>
|
||||
));
|
||||
|
||||
@ -15,6 +15,9 @@ const StyledTrigger = styled(DropdownMenuPrimitive.Trigger)`
|
||||
outline: none;
|
||||
box-shadow: 0;
|
||||
}
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
|
||||
@ -79,7 +79,8 @@ function UserButtonInnerInner(props: UserButtonProps & { user: CurrentUser | nul
|
||||
const textStyles = {
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -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"); }
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user