New page layout (#33)

* added sidebar

* add side-bar layout

* fixed small bugs, adding mobile sidebar

* fixed mobile sidebar

* added mobile breadcrumb

* improved project switcher order

* fixed eslint

* added typography

* added page layout, removed unused data

* new page layout

* removed unused files
This commit is contained in:
Zai Shi 2024-05-12 09:26:37 +02:00 committed by GitHub
parent 6f19e662df
commit fddce41aa8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 131 additions and 606 deletions

View File

@ -11,6 +11,7 @@ import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"
import EnvKeys from "@/components/env-keys";
import { SmartLink } from "@/components/smart-link";
import { ApiKeySetFirstView } from "@stackframe/stack";
import { PageLayout } from "../page-layout";
export default function ApiKeysDashboardClient() {
@ -20,22 +21,15 @@ export default function ApiKeysDashboardClient() {
const [isNewApiKeyDialogOpen, setIsNewApiKeyDialogOpen] = useState(false);
return (
<>
<Paragraph h1>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
API Keys
</Box>
<AsyncButton onClick={() => setIsNewApiKeyDialogOpen(true)}>
Create new
</AsyncButton>
</Stack>
</Paragraph>
<Paragraph body>
Please note that API keys cannot be viewed anymore after they have been created. If you lose them, you will have to create new ones.
</Paragraph>
<PageLayout
title="API Keys"
description="Manage your project's API keys"
actions={
<AsyncButton onClick={() => setIsNewApiKeyDialogOpen(true)}>
Create new
</AsyncButton>
}
>
<ApiKeysTable rows={apiKeySets} />
<CreateNewDialog
@ -43,7 +37,7 @@ export default function ApiKeysDashboardClient() {
open={isNewApiKeyDialogOpen}
onClose={() => setIsNewApiKeyDialogOpen(false)}
/>
</>
</PageLayout>
);
}

View File

@ -1,3 +0,0 @@
import { redirectHandler } from "@/route-handlers/redirect-handler";
export const GET = redirectHandler("users");

View File

@ -10,6 +10,7 @@ import { SmartLink } from "@/components/smart-link";
import { SmartSwitch } from "@/components/smart-switch";
import { SimpleCard } from "@/components/simple-card";
import { useAdminApp } from "../use-admin-app";
import { PageLayout } from "../page-layout";
export default function EnvironmentClient() {
const stackAdminApp = useAdminApp();
@ -21,12 +22,7 @@ export default function EnvironmentClient() {
return (
<>
<Paragraph h1>
Environment
</Paragraph>
<PageLayout title="Environment" description="Development and production settings">
<SimpleCard title="Production mode">
<SmartSwitch
checked={project.isProductionMode}
@ -78,6 +74,6 @@ export default function EnvironmentClient() {
</IconAlert>
)}
</SimpleCard>
</>
</PageLayout>
);
}

View File

@ -1,157 +0,0 @@
"use client";
import { Sheet, SheetProps, Select, Option, SelectOption, Stack, Typography } from "@mui/joy";
import * as React from 'react';
import Box from '@mui/joy/Box';
import IconButton from '@mui/joy/IconButton';
import { useAdminApp } from './use-admin-app';
import { redirect, usePathname } from 'next/navigation';
import { useUser } from '@stackframe/stack';
import { Icon } from '@/components/icon';
import Breadcrumbs from '@mui/joy/Breadcrumbs';
import { NavigationItem } from "./navigation-data";
function ProjectSwitchItem({ label }: { label: string }) {
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
}}>
<Box sx={{
width: 28,
height: 28,
borderRadius: 'sm',
bgcolor: 'background.level1',
marginRight: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<Typography>
{label?.slice(0,1)?.toUpperCase()}
</Typography>
</Box>
<Typography level="title-md">{label}</Typography>
</Box>
);
}
function ProjectSwitch() {
const stackAdminApp = useAdminApp();
const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" });
const projects = user.useOwnedProjects();
const project = projects.find((project) => project.id === stackAdminApp.projectId);
const renderValue = (option: SelectOption<string> | null) => {
if (!option || typeof option.label !== 'string') {
return null;
}
return (
<Box>
<ProjectSwitchItem label={option.label} />
</Box>
);
};
return (
<Select
// indicator={null}
variant="plain"
defaultValue={project?.id}
size="sm"
slotProps={{
listbox: {
sx: {
zIndex: 10001
},
},
}}
renderValue={renderValue}
onChange={(event, newProjectId) => {
redirect(`/projects/${newProjectId}/users`);
}}
>
{projects.map((projectInfo) => (
<Option value={projectInfo.id} label={projectInfo.displayName} key={projectInfo.id}>
<ProjectSwitchItem label={projectInfo.displayName} />
</Option>
))}
</Select>
);
}
export function Header(props: SheetProps & {
headerHeight: number,
isCompactMediaQuery: string,
onShowSidebar: () => void,
navigationItems: NavigationItem[],
}) {
const { isCompactMediaQuery, onShowSidebar, navigationItems, headerHeight, ...sheetProps } = props;
const pathname = usePathname();
const selectedItem = React.useMemo(() => {
return navigationItems.find((item) => {
if (item.type === 'label') {
return false;
}
return item.regex.test(pathname);
});
}, [pathname, navigationItems]);
return (
<Sheet
variant="outlined"
component="header"
{...sheetProps}
sx={{
paddingX: 1,
position: 'sticky',
top: 0,
zIndex: 30,
borderTop: 'none',
borderLeft: 'none',
borderRight: 'none',
display: 'flex',
alignItems: 'stretch',
height: `${headerHeight}px`,
flexShrink: 0,
...sheetProps.sx ?? {},
}}
>
<Stack
direction="row"
alignItems="center"
flexGrow={1}
>
<IconButton
size="sm"
variant="outlined"
onClick={onShowSidebar}
sx={{
display: 'none',
[isCompactMediaQuery]: {
display: 'flex',
},
}}
>
<Icon icon="menu" />
</IconButton>
<Stack flexDirection="row" alignItems="center">
<Breadcrumbs aria-label="breadcrumb">
<ProjectSwitch />
{selectedItem ? (
typeof selectedItem.name === 'string' ? (
<Typography level="title-md">{selectedItem.name}</Typography>
) : selectedItem.name.map((name, index) => (
<Typography key={name} level="title-md">{name}</Typography>
))
) : null}
</Breadcrumbs>
</Stack>
</Stack>
</Sheet>
);
}

View File

@ -1,98 +0,0 @@
import { Icon } from "@/components/icon";
type Label = {
name: string,
type: 'label',
};
type Item = {
name: string,
href: string,
icon: JSX.Element,
regex: RegExp,
type: 'item',
};
type Hidden = {
name: string | string[],
regex: RegExp,
type: 'hidden',
};
export type NavigationItem = Label | Item | Hidden;
export const navigationItems: (Label | Item | Hidden)[] = [
{
name: "Users",
type: 'label'
},
{
name: "Users",
href: "/users",
regex: /^\/projects\/[^\/]+\/user\/[^\/]+$/,
icon: <Icon icon="person" />,
type: 'item'
},
{
name: "Auth Methods",
href: "/providers",
regex: /^\/projects\/[^\/]+\/providers$/,
icon: <Icon icon="security" />,
type: 'item'
},
{
name: "Teams",
type: 'label'
},
{
name: "Teams",
href: "/teams",
regex: /^\/projects\/[^\/]+\/teams$/,
icon: <Icon icon="group" />,
type: 'item'
},
{
name: ["Team", "Members"],
regex: /^\/projects\/[^\/]+\/teams\/[^\/]+$/,
type: "hidden",
},
{
name: "Permissions",
href: "/team-permissions",
regex: /^\/projects\/[^\/]+\/team-permissions$/,
icon: <Icon icon="lock" />,
type: 'item'
},
{
name: "Settings",
type: 'label'
},
{
name: "Domains & Handlers",
href: "/urls-and-callbacks",
regex: /^\/projects\/[^\/]+\/urls-and-callbacks$/,
icon: <Icon icon="link" />,
type: 'item'
},
{
name: "Team Settings",
href: "/team-settings",
regex: /^\/projects\/[^\/]+\/team-settings$/,
icon: <Icon icon="settings" />,
type: 'item'
},
{
name: "Environment",
href: "/environment",
regex: /^\/projects\/[^\/]+\/environment$/,
icon: <Icon icon="list_alt" />,
type: 'item'
},
{
name: "API Keys",
href: "/api-keys",
regex: /^\/projects\/[^\/]+\/api-keys$/,
icon: <Icon icon="key" />,
type: 'item'
},
];

View File

@ -0,0 +1,29 @@
import Typography from "@/components/ui/typography";
export function PageLayout(props: {
children: React.ReactNode,
title: string,
description?: string,
actions?: React.ReactNode,
}) {
return (
<div className="px-4 py-2">
<div className="flex justify-between items-end">
<div>
<Typography type="h2">
{props.title}
</Typography>
{props.description && (
<Typography type="p" variant="secondary">
{props.description}
</Typography>
)}
</div>
{props.actions}
</div>
<div className="mt-4">
{props.children}
</div>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { SimpleCard } from "@/components/simple-card";
import { useAdminApp } from "../use-admin-app";
import { ProviderAccordion, availableProviders } from "./provider-accordion";
import { OAuthProviderConfigJson } from "@stackframe/stack-shared";
import { PageLayout } from "../page-layout";
export default function ProvidersClient() {
const stackAdminApp = useAdminApp();
@ -14,10 +15,7 @@ export default function ProvidersClient() {
const oauthProviders = project.evaluatedConfig.oauthProviders;
return (
<>
<Paragraph h1>
Auth Methods
</Paragraph>
<PageLayout title="Auth Methods" description="Configure how users can sign in to your app">
<Paragraph body>
<SimpleCard title="Email authentication">
@ -88,6 +86,6 @@ export default function ProvidersClient() {
In order to add a new provider, you can choose to use shared credentials created by us, or create your own OAuth client on the provider&apos;s website.
</Paragraph>
</SimpleCard>
</>
</PageLayout>
);
}

View File

@ -1,62 +0,0 @@
"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

@ -34,6 +34,7 @@ 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";
import Typography from "@/components/ui/typography";
type Label = {
name: string,
@ -163,9 +164,9 @@ export function SidebarContent({ projectId, onNavigate }: { projectId: string, o
<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">
return <Typography key={index} className="pl-2" type="label" variant="secondary">
{item.name}
</div>;
</Typography>;
} else if (item.type === 'item') {
return <div key={index} className="flex px-1">
<NavItem item={item} projectId={projectId} onClick={onNavigate} />
@ -190,8 +191,7 @@ export function SidebarContent({ projectId, onNavigate }: { projectId: string, o
/>
</div>
<Separator />
{/* <div className="m-2 p-2 border rounded-md self-stretch"> */}
<div className="px-2 flex items-center">
<div className="px-4 py-2 flex items-center">
<UserButton showUserInfo colorModeToggle={() => setMode(mode === 'light' ? 'dark' : 'light')} />
</div>
</div>
@ -319,7 +319,7 @@ export default function SidebarLayout(props: { projectId: string, children?: Rea
Feedback
</Button>
</div>
<div className="px-4">
<div>
{props.children}
</div>
</div>

View File

@ -1,157 +0,0 @@
'use client';;
import * as React from 'react';
import NextLink from 'next/link';
import Box from '@mui/joy/Box';
import Divider from '@mui/joy/Divider';
import List from '@mui/joy/List';
import ListItem from '@mui/joy/ListItem';
import ListItemButton from '@mui/joy/ListItemButton';
import ListItemContent from '@mui/joy/ListItemContent';
import Typography from '@mui/joy/Typography';
import { useAdminApp } from './use-admin-app';
import { usePathname } from 'next/navigation';
import { UserButton } from '@stackframe/stack';
import { useColorScheme, Stack, Sheet } from '@mui/joy';
import { Icon } from '@/components/icon';
import { Logo } from '@/components/logo';
import { NavigationItem } from './navigation-data';
function SidebarItem({
title,
icon,
href,
target,
}: {
title: string,
icon: React.ReactNode,
href: string,
target?: string,
}) {
const pathname = usePathname();
const selected = React.useMemo(() => {
return pathname === new URL(href, "https://example.com").pathname;
}, [href, pathname]);
return (
<ListItem
sx={{
'--ListItem-radius': (theme) => theme.vars.radius.sm,
}}
>
<ListItemButton
selected={selected}
href={href}
target={target}
component={NextLink}
sx={{
"&.Mui-selected": {
backgroundColor: "background.level1",
},
}}
>
{icon}
<ListItemContent>
<Typography level="title-md">{title}</Typography>
</ListItemContent>
{target === "_blank" ? <Icon icon="open_in_new" /> : null}
</ListItemButton>
</ListItem>
);
}
export function Sidebar(props: {
isCompactMediaQuery: string,
headerHeight: number,
navigationItems: NavigationItem[],
mode: 'compact' | 'full',
}) {
const { mode, setMode } = useColorScheme();
const stackAdminApp = useAdminApp();
const basePath = `/projects/${stackAdminApp.projectId}`;
const { headerHeight, navigationItems, ...sheetProps} = props;
return (
<Sheet
variant="outlined"
sx={props.mode === 'full' ? {
position: 'sticky',
top: 0,
height: `100vh`,
[props.isCompactMediaQuery]: {
top: `${headerHeight}px`,
height: `calc(100vh - ${headerHeight}px)`,
},
overflowY: 'auto',
borderLeft: 'none',
borderTop: 'none',
borderBottom: 'none',
width: '250px',
flexShrink: 0,
display: 'block',
[props.isCompactMediaQuery]: { display: 'none' },
} : {}}>
<Stack
sx={{
height: '100dvh',
top: 0,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Stack sx={{ marginLeft: 2, justifyContent: 'center', height: headerHeight - 1 }}>
<Logo full height={24} href="/projects" />
</Stack>
<Divider sx={{ mb: 1 }} />
<Stack sx={{ px: 1, display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<Box
sx={{
minHeight: 0,
overflow: 'hidden auto',
display: 'flex',
flexGrow: 1,
flexDirection: 'column',
}}
>
<List size="sm" sx={{ gap: 0.25 }}>
{navigationItems.map((item) => (
item.type === 'item' ?
<SidebarItem
key={item.name + '-item'}
title={item.name}
icon={item.icon}
href={basePath + item.href}
/> :
item.type === 'label' ?
<Typography
key={item.name + '-label'}
level="title-sm"
color="neutral"
sx={{ my: 1 }}
>
{item.name}
</Typography> : null
))}
<Box style={{ flexGrow: 1 }}/>
<SidebarItem
title='Documentation'
icon={<Icon icon="help_outline" />}
href={process.env.NEXT_PUBLIC_DOC_URL || ''}
target="_blank"
/>
</List>
</Box>
<Divider sx={{ mt: 1 }} />
<Box sx={{ py: 1 }}>
<UserButton showUserInfo colorModeToggle={() => setMode(mode === 'light' ? 'dark' : 'light')} />
</Box>
</Stack>
</Stack>
</Sheet>
);
}

View File

@ -1,45 +0,0 @@
import { Button, Input, InputProps } from "@mui/joy";
import { useCallback, useState } from "react";
import { Icon } from "@/components/icon";
export function SiteSearch(props: InputProps) {
const [searchText, setSearchText] = useState('');
const { ...inputProps } = props;
const openSearch = useCallback(() => {
const baseUrl = new URL(process.env.__NEXT_ROUTER_BASEPATH || "", window.location.origin);
// Let's strip away all information but the necessary (Google search doesn't support eg. port number)
const baseSite = `${baseUrl.hostname}${baseUrl.pathname}`;
window.open(`https://www.google.com/search?q=${encodeURIComponent(`${searchText} site:${baseSite}`)}`, '_blank');
}, [searchText]);
return (
<Input
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder="Search"
startDecorator={<Icon icon="search" sx={{
color: theme => theme.palette.primary[500],
}} />}
endDecorator={
<Button size="sm" color="primary" variant="solid" onClick={() => openSearch()}>
Go
</Button>
}
{...inputProps}
sx={{
'&:not(:focus-within) button:not(:active)': {
display: 'none',
},
...inputProps.sx ?? {},
}}
onKeyDown={e => {
if (e.key === 'Enter') {
openSearch();
}
}}
/>
);
}

View File

@ -21,6 +21,7 @@ import {
import { Icon } from "@/components/icon";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { PermissionGraph, PermissionList } from "./permission-list";
import { PageLayout } from "../page-layout";
export default function ClientPage() {
@ -29,16 +30,14 @@ export default function ClientPage() {
const [createPermissionModalOpen, setCreatePermissionModalOpen] = React.useState(false);
return (
<>
<Paragraph h1>
Team Permissions
</Paragraph>
<Stack alignItems={"flex-start"}>
<PageLayout
title="Team Permissions"
description="Manage team permissions"
actions={
<AsyncButton onClick={() => setCreatePermissionModalOpen(true)}>
Create Permission
</AsyncButton>
</Stack>
}>
<PermissionsTable rows={permissions} />
@ -46,7 +45,7 @@ export default function ClientPage() {
open={createPermissionModalOpen}
onClose={() => setCreatePermissionModalOpen(false)}
/>
</>
</PageLayout>
);
}

View File

@ -4,18 +4,14 @@ import { Paragraph } from "@/components/paragraph";
import { SmartSwitch } from "@/components/smart-switch";
import { SimpleCard } from "@/components/simple-card";
import { useAdminApp } from "../use-admin-app";
import { PageLayout } from "../page-layout";
export default function TeamSettingsClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProjectAdmin();
return (
<>
<Paragraph h1>
Environment
</Paragraph>
<PageLayout title="Team Settings" description="Configure how teams are created and managed">
<SimpleCard title="Automatic Team Creation">
<SmartSwitch
checked={project.evaluatedConfig.createTeamOnSignUp}
@ -33,6 +29,6 @@ export default function TeamSettingsClient() {
</Typography>
</SmartSwitch>
</SimpleCard>
</>
</PageLayout>
);
}

View File

@ -6,6 +6,7 @@ import { MemberTable } from './member-table';
import { useAdminApp } from '../../use-admin-app';
import { notFound } from 'next/navigation';
import { SmartSwitch } from '@/components/smart-switch';
import { PageLayout } from '../../page-layout';
export default function ClientPage(props: { teamId: string }) {
@ -18,14 +19,11 @@ export default function ClientPage(props: { teamId: string }) {
}
return (
<>
<Paragraph h1>
{team.displayName}
</Paragraph>
<PageLayout title="Team Members" description={`Manage team members of "${team.displayName}"`}>
<Paragraph body>
<MemberTable rows={users || []} team={team} />
</Paragraph>
</>
</PageLayout>
);
}

View File

@ -2,6 +2,7 @@
import { Paragraph } from "@/components/paragraph";
import { TeamTable } from "./team-table";
import { useAdminApp } from "../use-admin-app";
import { PageLayout } from "../page-layout";
export default function ClientPage() {
@ -9,12 +10,8 @@ export default function ClientPage() {
const teams = stackAdminApp.useTeams();
return (
<>
<Paragraph h1>
Teams
</Paragraph>
<PageLayout title="Teams" description="Manage your project's teams">
<TeamTable rows={teams} />
</>
</PageLayout>
);
}

View File

@ -11,6 +11,7 @@ import { useAdminApp } from "../use-admin-app";
import { SmartSwitch } from "@/components/smart-switch";
import { Project } from "@stackframe/stack";
import { DomainConfigJson } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { PageLayout } from "../page-layout";
function isValidUrl(urlString: string) {
try {
@ -155,10 +156,7 @@ export default function UrlsAndCallbacksClient() {
return (
<>
<Paragraph h1>
Domains & Handlers
</Paragraph>
<PageLayout title="Domains and Handler" description="Specify trusted domains and handler URLs">
<SimpleCard title="Domains and Handler">
<Box sx={{ my: 2 }}>
@ -273,6 +271,6 @@ export default function UrlsAndCallbacksClient() {
Your project will no longer be able to receive callbacks from this domain.
</Paragraph>
</Dialog>
</>
</PageLayout>
);
}

View File

@ -3,8 +3,9 @@
import { Paragraph } from "@/components/paragraph";
import { UsersTable } from "./users-table";
import { useAdminApp } from "../use-admin-app";
import { Alert } from "@mui/joy";
import { SmartLink } from "@/components/smart-link";
import { PageLayout } from "../page-layout";
import { Alert } from "@/components/ui/alert";
export default function UsersDashboardClient() {
@ -12,15 +13,10 @@ export default function UsersDashboardClient() {
const allUsers = stackAdminApp.useServerUsers();
return (
<>
<Paragraph h1>
Users
</Paragraph>
<PageLayout title="Users" description="Manage your project's users">
{allUsers.length > 0 ? null : (
<Paragraph body>
<Alert color="success">
<Alert className="mb-4">
Congratulations on starting your project! Check the <SmartLink href="https://docs.stack-auth.com">documentation</SmartLink> to add your first users.
</Alert>
</Paragraph>
@ -29,6 +25,6 @@ export default function UsersDashboardClient() {
<Paragraph body>
<UsersTable rows={allUsers} />
</Paragraph>
</>
</PageLayout>
);
}

View File

@ -1,10 +1,11 @@
'use client';
import { Text, UserButton } from "@stackframe/stack";
import { UserButton } from "@stackframe/stack";
import { Logo } from "./logo";
import { Separator } from "./ui/separator";
import Link from "next/link";
import { useColorScheme } from "@mui/joy";
import Typography from "./ui/typography";
export function Navbar({ ...props }) {
const { mode, setMode } = useColorScheme();
@ -20,10 +21,10 @@ export function Navbar({ ...props }) {
<div className="flex items-center">
<div className="flex gap-4 mr-8 items-center">
<Link href="mailto:team@stack-auth.com">
<Text size='sm' variant='secondary'>Feedback</Text>
<Typography type='label'>Feedback</Typography>
</Link>
<Link href="https://docs.stack-auth.com/">
<Text size='sm' variant='secondary'>Docs</Text>
<Typography type='label'>Docs</Typography>
</Link>
</div>
<UserButton colorModeToggle={() => setMode(mode === 'light' ? 'dark' : 'light')}/>

View File

@ -1,9 +1,9 @@
'use client';;
import { CardDescription, CardFooter, CardHeader, CardTitle, ClickableCard } from './ui/card';
import { CardContent } from '@mui/joy';
import { Project } from '@stackframe/stack';
import { useFromNow } from '@/hooks/use-from-now';
import { useRouter } from 'next/navigation';
import Typography from './ui/typography';
export function ProjectCard({ project }: { project: Project }) {
const createdAt = useFromNow(project.createdAt);
@ -15,15 +15,13 @@ export function ProjectCard({ project }: { project: Project }) {
<CardTitle>{project.displayName}</CardTitle>
<CardDescription>{project.description}</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
</CardContent>
<CardFooter className="flex justify-between">
<p className='text-sm text-gray-500'>
<Typography type='label' variant='secondary'>
{project.userCount} users
</p>
<p className='text-sm text-gray-500'>
</Typography>
<Typography type='label' variant='secondary'>
{createdAt}
</p>
</Typography>
</CardFooter>
</ClickableCard>
);

View File

@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import React from "react";
export const typographyVariants = cva("text-md", {
variants: {
type: {
h1: "text-4xl font-bold",
h2: "text-3xl font-bold",
h3: "text-2xl font-semibold",
h4: "text-xl",
p: "text-md",
label: "text-sm",
footnote: "text-xs",
},
variant: {
primary: "text-black dark:text-white",
secondary: "text-zinc-500",
destructive: "text-red-500",
success: "text-green-500",
},
},
defaultVariants: {
type: "p",
variant: "primary",
},
});
export interface TypographyProps
extends React.HTMLAttributes<HTMLHeadingElement>,
VariantProps<typeof typographyVariants> {}
const Typography = React.forwardRef<HTMLHeadingElement, TypographyProps>(
({ className, type, variant, ...props }, ref) => {
const Comp = (type === 'footnote' || type === 'label' ? 'p' : type) || 'p';
return (
<Comp
className={cn(typographyVariants({ type, variant, className }))}
ref={ref}
{...props}
/>
);
},
);
Typography.displayName = "Typography";
export default Typography;