feat: switch to new toast provider

This commit is contained in:
Aarnav Tale 2025-01-28 16:06:41 -05:00
parent a19eb6bcda
commit 2c8880c84d
No known key found for this signature in database
8 changed files with 151 additions and 49 deletions

View File

@ -1,5 +1,5 @@
import { CopyIcon } from '@primer/octicons-react'; import { CopyIcon } from '@primer/octicons-react';
import { toast } from './Toaster'; import toast from '~/utils/toast';
interface Props { interface Props {
name: string; name: string;

View File

@ -0,0 +1,87 @@
import {
AriaToastProps,
AriaToastRegionProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
}
function Toast({ state, ...props }: ToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref,
);
return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
>
<X className="p-1" />
</IconButton>
</div>
);
}
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
}
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
const state = useToastQueue(queue);
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
);
}

View File

@ -1,19 +1,11 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router'; import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router'; import { Outlet, useLoaderData } from 'react-router';
import { useEffect } from 'react'
import { ErrorPopup } from '~/components/Error';
import Header from '~/components/Header';
import { toast } from '~/components/Toaster';
import Footer from '~/components/Footer';
import Link from '~/components/Link';
import { useLiveData } from '~/utils/useLiveData'
import { cn } from '~/utils/cn'; import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane'; import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
import { HeadscaleError, pull, healthcheck } from '~/utils/headscale';
import { destroySession, getSession } from '~/utils/sessions.server';
import { XCircleFillIcon } from '@primer/octicons-react';
import log from '~/utils/log'; import log from '~/utils/log';
import { destroySession, getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
let healthy = false; let healthy = false;
@ -45,25 +37,29 @@ export async function loader({ request }: LoaderFunctionArgs) {
return { return {
healthy, healthy,
} };
} }
export default function Layout() { export default function Layout() {
useLiveData({ interval: 3000 }); useLiveData({ interval: 3000 });
const { healthy } = useLoaderData<typeof loader>() const { healthy } = useLoaderData<typeof loader>();
return ( return (
<> <>
{!healthy ? ( {!healthy ? (
<div className={cn( <div
className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14', 'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1', 'flex flex-col justify-center gap-1',
)}> )}
<div className={cn( >
<div
className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5', 'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500', 'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm', 'border-red-600 dark:border-red-400 shadow-sm',
)}> )}
>
<XCircleFillIcon className="w-4 h-4 text-white" /> <XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable Headscale is unreachable
</div> </div>

View File

@ -1,14 +1,20 @@
import type { LoaderFunctionArgs, LinksFunction, MetaFunction } from 'react-router'; import type { LinksFunction, MetaFunction } from 'react-router';
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from 'react-router'; import {
import { loadContext } from '~/utils/config/headplane'; Links,
import '@fontsource-variable/inter' Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from 'react-router';
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';
// TODO: Make this a default export import ToastProvider from '~/components/ToastProvider';
import { Toaster } from '~/components/Toaster';
import stylesheet from '~/tailwind.css?url'; import stylesheet from '~/tailwind.css?url';
import { cn } from '~/utils/cn'; import { cn } from '~/utils/cn';
import { useToastQueue } from '~/utils/toast';
export const meta: MetaFunction = () => [ export const meta: MetaFunction = () => [
{ title: 'Headplane' }, { title: 'Headplane' },
@ -23,6 +29,8 @@ export const links: LinksFunction = () => [
]; ];
export function Layout({ children }: { readonly children: React.ReactNode }) { export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
return ( return (
<html lang="en"> <html lang="en">
<head> <head>
@ -33,7 +41,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
</head> </head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50"> <body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children} {children}
<Toaster /> <ToastProvider queue={toastQueue} />
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
</body> </body>
@ -61,5 +69,5 @@ export default function App() {
</ProgressBar> </ProgressBar>
<Outlet /> <Outlet />
</> </>
) );
} }

View File

@ -1,33 +1,33 @@
import { setTimeout } from 'node:timers/promises';
import { import {
BeakerIcon, BeakerIcon,
EyeIcon, EyeIcon,
IssueDraftIcon, IssueDraftIcon,
PencilIcon, PencilIcon,
} from '@primer/octicons-react'; } from '@primer/octicons-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useRevalidator, useFetcher } from 'react-router';
//import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'; //import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { setTimeout } from 'node:timers/promises'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useFetcher, useLoaderData, useRevalidator } from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Notice from '~/components/Notice'; import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner'; import Spinner from '~/components/Spinner';
import { toast } from '~/components/Toaster';
import { cn } from '~/utils/cn'; import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane'; import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale'; import { loadConfig } from '~/utils/config/headscale';
import { HeadscaleError, pull, put } from '~/utils/headscale'; import { HeadscaleError, pull, put } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import log from '~/utils/log'; import log from '~/utils/log';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import { Editor, Differ } from './components/cm.client'; import toast from '~/utils/toast';
import { Unavailable } from './components/unavailable'; import { Differ, Editor } from './components/cm.client';
import { ErrorView } from './components/error'; import { ErrorView } from './components/error';
import { Unavailable } from './components/unavailable';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')); const session = await getSession(request.headers.get('Cookie'));
@ -143,8 +143,6 @@ export async function action({ request }: ActionFunctionArgs) {
}, },
); );
} }
return { success: true, error: null };
} }
export default function Page() { export default function Page() {

View File

@ -1,20 +1,20 @@
/* eslint-disable unicorn/no-keyword-prefix */ /* eslint-disable unicorn/no-keyword-prefix */
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core'; import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import { import {
restrictToParentElement, restrictToParentElement,
restrictToVerticalAxis, restrictToVerticalAxis,
} from '@dnd-kit/modifiers'; } from '@dnd-kit/modifiers';
import { import {
arrayMove,
SortableContext, SortableContext,
arrayMove,
useSortable, useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'; import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react';
import { type FetcherWithComponents, useFetcher } from 'react-router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Input } from 'react-aria-components'; import { Button, Input } from 'react-aria-components';
import { type FetcherWithComponents, useFetcher } from 'react-router';
import Spinner from '~/components/Spinner'; import Spinner from '~/components/Spinner';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
@ -91,8 +91,7 @@ export default function Domains({
> >
{localDomains.map((sd, index) => ( {localDomains.map((sd, index) => (
<Domain <Domain
// eslint-disable-next-line react/no-array-index-key key={sd}
key={index}
domain={sd} domain={sd}
id={index + 1} id={index + 1}
localDomains={localDomains} localDomains={localDomains}

View File

@ -9,7 +9,6 @@ import Attribute from '~/components/Attribute';
import Card from '~/components/Card'; import Card from '~/components/Card';
import { ErrorPopup } from '~/components/Error'; import { ErrorPopup } from '~/components/Error';
import StatusCircle from '~/components/StatusCircle'; import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster';
import type { Machine, User } from '~/types'; import type { Machine, User } from '~/types';
import { cn } from '~/utils/cn'; import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane'; import { loadContext } from '~/utils/config/headplane';
@ -19,6 +18,7 @@ import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server'; import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData'; import { useLiveData } from '~/utils/useLiveData';
import toast from '~/utils/toast';
import Auth from './components/auth'; import Auth from './components/auth';
import Oidc from './components/oidc'; import Oidc from './components/oidc';
import Remove from './dialogs/remove'; import Remove from './dialogs/remove';

14
app/utils/toast.ts Normal file
View File

@ -0,0 +1,14 @@
import { ToastQueue } from '@react-stately/toast';
import React from 'react';
const toastQueue = new ToastQueue<React.ReactNode>({
maxVisibleToasts: 7,
});
export function useToastQueue() {
return toastQueue;
}
export default function toast(content: React.ReactNode, duration = 3000) {
return toastQueue.add(content, { timeout: duration });
}