feat: begin redesign and component unification
This commit is contained in:
parent
ed0cdbdf4d
commit
ec36876b9f
@ -1,38 +1,40 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import React, { useRef } from 'react';
|
||||||
import { Button as AriaButton } from 'react-aria-components';
|
import { Button as AriaButton } from 'react-aria-components';
|
||||||
|
import { useButton } from 'react-aria';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
type Props = Parameters<typeof AriaButton>[0] & {
|
export interface ButtonProps extends React.HTMLProps<HTMLButtonElement> {
|
||||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
variant?: 'heavy'
|
||||||
readonly variant?: 'heavy' | 'light';
|
isDisabled?: boolean
|
||||||
};
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({ variant = 'light', ...props }: Props) {
|
||||||
|
const ref = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const { buttonProps } = useButton(props, ref);
|
||||||
|
|
||||||
export default function Button(props: Props) {
|
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<button
|
||||||
{...props}
|
ref={ref}
|
||||||
|
{...buttonProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
'w-fit text-sm rounded-xl px-3 py-2',
|
||||||
props.variant === 'heavy'
|
'focus:outline-none focus:ring',
|
||||||
? 'bg-main-700 dark:bg-main-800'
|
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||||
: 'bg-main-200 dark:bg-main-700/30',
|
...(variant === 'heavy'
|
||||||
props.variant === 'heavy'
|
? [
|
||||||
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
||||||
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
||||||
props.variant === 'heavy'
|
'text-headplane-200 dark:text-headplane-800'
|
||||||
? 'text-white'
|
] : [
|
||||||
: 'text-ui-700 dark:text-ui-300',
|
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||||
|
]),
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
// If control is passed, set the state value
|
>
|
||||||
onPress={
|
{props.children}
|
||||||
props.control
|
</button>
|
||||||
? () => {
|
)
|
||||||
props.control?.[1](true);
|
|
||||||
}
|
|
||||||
: props.onPress
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,37 +1,31 @@
|
|||||||
import type { HTMLProps } from 'react';
|
import React from 'react';
|
||||||
import { Heading as AriaHeading } from 'react-aria-components';
|
import Title from '~/components/Title';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
function Title(props: Parameters<typeof AriaHeading>[0]) {
|
|
||||||
return (
|
|
||||||
<AriaHeading
|
|
||||||
{...props}
|
|
||||||
slot="title"
|
|
||||||
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
|
function Text(props: React.HTMLProps<HTMLParagraphElement>) {
|
||||||
return (
|
return (
|
||||||
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
|
<p {...props} className={cn('text-base leading-6 my-0', props.className)} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = HTMLProps<HTMLDivElement> & {
|
type Props = React.HTMLProps<HTMLDivElement> & {
|
||||||
variant?: 'raised' | 'flat';
|
variant?: 'raised' | 'flat';
|
||||||
};
|
};
|
||||||
|
|
||||||
function Card(props: Props) {
|
interface Props extends React.HTMLProps<HTMLDivElement> {
|
||||||
|
variant?: 'raised' | 'flat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ variant = 'raised', ...props }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
'w-full max-w-md overflow-hidden rounded-3xl p-5',
|
||||||
props.variant === 'flat'
|
variant === 'flat'
|
||||||
? 'bg-transparent shadow-none'
|
? 'bg-transparent shadow-none'
|
||||||
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
|
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
|
||||||
'border border-ui-200 dark:border-ui-700',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
import React, { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||||
|
import Button, { ButtonProps } from '~/components/Button';
|
||||||
|
import Title from '~/components/Title';
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
Dialog as AriaDialog,
|
Dialog as AriaDialog,
|
||||||
@ -9,64 +11,16 @@ import {
|
|||||||
} from 'react-aria-components';
|
} from 'react-aria-components';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
type ButtonProps = Parameters<typeof AriaButton>[0] & {
|
interface ActionProps extends ButtonProps {
|
||||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
variant: 'cancel' | 'confirm';
|
||||||
};
|
|
||||||
|
|
||||||
function Button(props: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<AriaButton
|
|
||||||
{...props}
|
|
||||||
aria-label="Dialog"
|
|
||||||
className={cn(
|
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
|
||||||
'bg-main-700 dark:bg-main-800 text-white',
|
|
||||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
|
||||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
|
||||||
props.className,
|
|
||||||
)}
|
|
||||||
// If control is passed, set the state value
|
|
||||||
onPress={
|
|
||||||
props.control
|
|
||||||
? () => {
|
|
||||||
props.control?.[1](true);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionProps = Parameters<typeof AriaButton>[0] & {
|
|
||||||
readonly variant: 'cancel' | 'confirm';
|
|
||||||
};
|
|
||||||
|
|
||||||
function Action(props: ActionProps) {
|
function Action(props: ActionProps) {
|
||||||
return (
|
return (
|
||||||
<AriaButton
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
type={props.variant === 'confirm' ? 'submit' : 'button'}
|
type={props.variant === 'confirm' ? 'submit' : 'button'}
|
||||||
className={cn(
|
variant={props.variant === 'cancel' ? 'light' : 'heavy'}
|
||||||
'px-4 py-2 rounded-lg',
|
|
||||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
|
||||||
props.variant === 'cancel'
|
|
||||||
? 'text-ui-700 dark:text-ui-300'
|
|
||||||
: 'text-ui-300 dark:text-ui-300',
|
|
||||||
props.variant === 'confirm'
|
|
||||||
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
|
|
||||||
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
|
|
||||||
props.className,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Title(props: Parameters<typeof AriaHeading>[0]) {
|
|
||||||
return (
|
|
||||||
<AriaHeading
|
|
||||||
{...props}
|
|
||||||
slot="title"
|
|
||||||
className={cn('text-lg font-semibold leading-6 mb-5', props.className)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -100,7 +54,7 @@ function Panel({ children, control, className }: PanelProps) {
|
|||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
'w-full max-w-md overflow-hidden rounded-2xl p-4',
|
||||||
'bg-ui-50 dark:bg-ui-900 shadow-lg',
|
'bg-ui-50 dark:bg-ui-900 shadow-lg',
|
||||||
'entering:animate-in exiting:animate-out',
|
'entering:animate-in exiting:animate-out',
|
||||||
'dark:border dark:border-ui-700',
|
'dark:border dark:border-ui-700',
|
||||||
|
|||||||
@ -86,14 +86,9 @@ export default function Header(data: Props) {
|
|||||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||||
{data.user ? (
|
{data.user ? (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Button
|
<Menu.IconButton className="p-0">
|
||||||
className={cn(
|
<CircleUser />
|
||||||
'rounded-full h-8 w-8',
|
</Menu.IconButton>
|
||||||
'hover:bg-headplane-200 dark:hover:bg-headplane-800',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CircleUser className="w-full h-full" />
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items>
|
<Menu.Items>
|
||||||
<Menu.Item className="text-right">
|
<Menu.Item className="text-right">
|
||||||
<p className="font-bold">{data.user.name}</p>
|
<p className="font-bold">{data.user.name}</p>
|
||||||
|
|||||||
42
app/components/IconButton.tsx
Normal file
42
app/components/IconButton.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Button as AriaButton } from 'react-aria-components';
|
||||||
|
import { useButton } from 'react-aria';
|
||||||
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
|
interface Props extends React.HTMLProps<HTMLButtonElement> {
|
||||||
|
variant?: 'heavy'
|
||||||
|
isDisabled?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IconButton({ variant = 'light', ...props }: Props) {
|
||||||
|
const ref = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const { buttonProps } = useButton(props, ref);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
{...buttonProps}
|
||||||
|
aria-label={props.label}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full flex items-center justify-center p-1',
|
||||||
|
'focus:outline-none focus:ring',
|
||||||
|
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||||
|
...(variant === 'heavy'
|
||||||
|
? [
|
||||||
|
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
|
||||||
|
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
|
||||||
|
'text-headplane-200 dark:text-headplane-800'
|
||||||
|
] : [
|
||||||
|
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||||
|
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||||
|
]),
|
||||||
|
props.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||||
|
import Button from '~/components/Button';
|
||||||
|
import IconButton from '~/components/IconButton';
|
||||||
import {
|
import {
|
||||||
Button as AriaButton,
|
Button as AriaButton,
|
||||||
Menu as AriaMenu,
|
Menu as AriaMenu,
|
||||||
@ -8,16 +10,6 @@ import {
|
|||||||
} from 'react-aria-components';
|
} from 'react-aria-components';
|
||||||
import { cn } from '~/utils/cn';
|
import { cn } from '~/utils/cn';
|
||||||
|
|
||||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
|
||||||
return (
|
|
||||||
<AriaButton
|
|
||||||
{...props}
|
|
||||||
className={cn('outline-none', props.className)}
|
|
||||||
aria-label="Menu"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Items(props: Parameters<typeof AriaMenu>[0]) {
|
function Items(props: Parameters<typeof AriaMenu>[0]) {
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@ -88,4 +80,10 @@ function Menu({ children }: { children: ReactNode }) {
|
|||||||
return <MenuTrigger>{children}</MenuTrigger>;
|
return <MenuTrigger>{children}</MenuTrigger>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Menu, { Button, Item, ItemButton, Items });
|
export default Object.assign(Menu, {
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
Item,
|
||||||
|
ItemButton,
|
||||||
|
Items
|
||||||
|
});
|
||||||
|
|||||||
13
app/components/Title.tsx
Normal file
13
app/components/Title.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface TitleProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Title({ children }: TitleProps) {
|
||||||
|
return (
|
||||||
|
<h3 className="text-2xl font-bold mb-6">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router';
|
import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router';
|
||||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router';
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router';
|
||||||
import { loadContext } from '~/utils/config/headplane';
|
import { loadContext } from '~/utils/config/headplane';
|
||||||
|
import '@fontsource-variable/inter'
|
||||||
|
|
||||||
import { ProgressBar } from 'react-aria-components';
|
import { ProgressBar } from 'react-aria-components';
|
||||||
import { ErrorPopup } from '~/components/Error';
|
import { ErrorPopup } from '~/components/Error';
|
||||||
@ -30,7 +31,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
|||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<body className="overscroll-none dark:bg-ui-950 dark:text-ui-50">
|
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export default function Page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
|
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
|
||||||
<Card.Title>Welcome to Headplane</Card.Title>
|
<Card.Title>Welcome to Headplane</Card.Title>
|
||||||
{data.apiKey ? (
|
{data.apiKey ? (
|
||||||
<Form method="post">
|
<Form method="post">
|
||||||
@ -117,7 +117,7 @@ export default function Page() {
|
|||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
<Button className="w-full mt-2.5" variant="heavy" type="submit">
|
<Button className="w-full mt-2.5" variant="heavy" type="submit">
|
||||||
Login
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@ -131,8 +131,8 @@ export default function Page() {
|
|||||||
{data.oidc ? (
|
{data.oidc ? (
|
||||||
<Form method="POST">
|
<Form method="POST">
|
||||||
<input type="hidden" name="oidc-start" value="true" />
|
<input type="hidden" name="oidc-start" value="true" />
|
||||||
<Button className="w-full" variant="heavy" type="submit">
|
<Button className="w-full" type="submit">
|
||||||
Login with SSO
|
Single Sign-On
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@ -103,13 +103,7 @@ export default function New(data: NewProps) {
|
|||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Button
|
<Menu.Button variant="heavy">
|
||||||
className={cn(
|
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
|
||||||
'bg-main-700 dark:bg-main-800 text-white',
|
|
||||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Add Device
|
Add Device
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
<Menu.Items>
|
<Menu.Items>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user