feat: switch to new toast provider
This commit is contained in:
parent
a19eb6bcda
commit
2c8880c84d
@ -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;
|
||||||
|
|||||||
87
app/components/ToastProvider.tsx
Normal file
87
app/components/ToastProvider.tsx
Normal 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} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
24
app/root.tsx
24
app/root.tsx
@ -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 />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
14
app/utils/toast.ts
Normal 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 });
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user