toasts
This commit is contained in:
parent
7fcbc4d98e
commit
9ee1f0bcbc
|
@ -9,6 +9,7 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/themes": "^2.0.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
|
|
@ -5,6 +5,9 @@ settings:
|
|||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@radix-ui/react-toast':
|
||||
specifier: ^1.1.5
|
||||
version: 1.1.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/themes':
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
|
||||
|
@ -1240,6 +1243,38 @@ packages:
|
|||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-toast@1.1.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.8
|
||||
'@radix-ui/primitive': 1.0.1
|
||||
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.48)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.0.1(@types/react@18.2.48)(react@18.2.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.48)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.48)(react@18.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.48)(react@18.2.0)
|
||||
'@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@types/react': 18.2.48
|
||||
'@types/react-dom': 18.2.18
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==}
|
||||
peerDependencies:
|
||||
|
|
|
@ -1,3 +1,97 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* mfw "ui library" doesnt ship styles */
|
||||
.ToastViewport {
|
||||
--viewport-padding: 25px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--viewport-padding);
|
||||
gap: 10px;
|
||||
width: 390px;
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
z-index: 2147483647;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ToastRoot {
|
||||
background-color: var(--color-panel-solid);
|
||||
border: 1px solid var(--gray-4);
|
||||
border-radius: 6px;
|
||||
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
|
||||
padding: 15px;
|
||||
display: grid;
|
||||
grid-template-areas: 'title action' 'description action';
|
||||
grid-template-columns: auto max-content;
|
||||
column-gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
.ToastRoot[data-state='open'] {
|
||||
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.ToastRoot[data-state='closed'] {
|
||||
animation: hide 200ms ease-in;
|
||||
}
|
||||
.ToastRoot[data-swipe='move'] {
|
||||
transform: translateX(var(--radix-toast-swipe-move-x));
|
||||
}
|
||||
.ToastRoot[data-swipe='cancel'] {
|
||||
transform: translateX(0);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
.ToastRoot[data-swipe='end'] {
|
||||
animation: swipeOut 100ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes hide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + var(--viewport-padding)));
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swipeOut {
|
||||
from {
|
||||
transform: translateX(var(--radix-toast-swipe-end-x));
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + var(--viewport-padding)));
|
||||
}
|
||||
}
|
||||
|
||||
.ToastTitle {
|
||||
grid-area: title;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: var(--slate-12);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ToastDescription {
|
||||
grid-area: description;
|
||||
margin: 0;
|
||||
color: var(--slate-11);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.ToastAction {
|
||||
grid-area: action;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import AuthWrapper from '@/lib/components/wrapper/AuthWrapper';
|
|||
import { getServerSession } from 'next-auth';
|
||||
import NavigationWrapper from '@/lib/components/wrapper/NavigationWrapper';
|
||||
import BackgroundImage from '@/lib/components/ui/BackgroundImage';
|
||||
import ToastProvider from '@/lib/providers/ToastProvider';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Maddy Admin',
|
||||
|
@ -21,11 +22,13 @@ export default async function RootLayout({
|
|||
<body className={`p-4 h-full m-0`}>
|
||||
<ThemeWrapper>
|
||||
<BackgroundImage />
|
||||
<ToastProvider>
|
||||
<AuthWrapper session={await getServerSession()}>
|
||||
<NavigationWrapper>
|
||||
{children}
|
||||
</NavigationWrapper>
|
||||
</AuthWrapper>
|
||||
</ToastProvider>
|
||||
</ThemeWrapper>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -4,44 +4,18 @@ import ConnectionDetailsCard from "@/lib/components/ui/user/ConnectionDetailsCar
|
|||
import OwnAliasesCard from "@/lib/components/ui/user/OwnAliasesCard";
|
||||
import OwnCredentialsCard from "@/lib/components/ui/user/OwnCredentialsCard";
|
||||
import useWindowDimensions from "@/lib/hooks/useWindowDimensions";
|
||||
import { Callout, Grid, Heading } from "@radix-ui/themes";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Grid, Heading } from "@radix-ui/themes";
|
||||
|
||||
export default function SelfService() {
|
||||
const [passwordChanged, setPasswordChanged] = useState(false);
|
||||
const dimensions = useWindowDimensions();
|
||||
const mobileUi = dimensions.width < 1200;
|
||||
|
||||
function PasswordChangedAlert() {
|
||||
const [hide, setHide] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout = setTimeout(() => {
|
||||
setHide(true);
|
||||
setTimeout(() => setPasswordChanged(false), 500);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
return (
|
||||
<Callout.Root className={`transition-all ${hide ? "opacity-0 h-0 m-0 !p-0" : " mb-4"}`}>
|
||||
<Callout.Icon><CheckIcon /></Callout.Icon>
|
||||
<Callout.Text>Password updated!</Callout.Text>
|
||||
</Callout.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{passwordChanged && (
|
||||
<PasswordChangedAlert />
|
||||
)}
|
||||
|
||||
<Heading className="pb-4">Account settings</Heading>
|
||||
|
||||
<Grid display="inline-grid" columns={mobileUi ? "1" : "3"} gap="4" width={mobileUi ? "100%" : "auto"}>
|
||||
<OwnCredentialsCard onPasswordChange={() => setPasswordChanged(true)} />
|
||||
<OwnCredentialsCard />
|
||||
<ConnectionDetailsCard />
|
||||
<OwnAliasesCard />
|
||||
</Grid>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { createAlias } from "@/lib/actions";
|
||||
import { AliasEntry } from "@/lib/db";
|
||||
import { ToastContext } from "@/lib/providers/ToastProvider";
|
||||
import { Button, Callout, Dialog, Flex, TextField } from "@radix-ui/themes";
|
||||
import { AtSignIcon, MailWarningIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
export default function CreateAliasButton({ user, onCreate }: { user: string, onCreate?: (alias: AliasEntry) => any }) {
|
||||
const [value, setValue] = useState("");
|
||||
const toast = useContext(ToastContext);
|
||||
|
||||
return (
|
||||
<Dialog.Root onOpenChange={(open) => !open && setValue("")}>
|
||||
|
@ -45,9 +47,18 @@ export default function CreateAliasButton({ user, onCreate }: { user: string, on
|
|||
try {
|
||||
const alias = await createAlias(user, value);
|
||||
onCreate?.(alias);
|
||||
toast({
|
||||
title: "Alias created",
|
||||
description: value,
|
||||
variant: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(e);
|
||||
toast({
|
||||
title: "Failed to create alias",
|
||||
description: `${e}`,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>Create</Button>
|
||||
|
|
|
@ -4,16 +4,18 @@ import { GRAVATAR_DEFAULT } from "@/lib/constants";
|
|||
import { AliasEntry } from "@/lib/db";
|
||||
import { isAdmin, sha256sum } from "@/lib/util";
|
||||
import { Avatar, Badge, Button, Card, Code, Dialog, Flex, Grid, Heading, Table, Text } from "@radix-ui/themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import GhostMessage from "../GhostMessage";
|
||||
import { BananaIcon } from "lucide-react";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import { approveAlias, deleteAlias, fetchUserAliases } from "@/lib/actions";
|
||||
import GenericConfirmationDialog from "../GenericConfirmationDialog";
|
||||
import CreateAliasButton from "./CreateAliasButton";
|
||||
import { ToastContext } from "@/lib/providers/ToastProvider";
|
||||
|
||||
export default function ManageUserButton({ email }: { email: string }) {
|
||||
const [aliases, setAliases] = useState<AliasEntry[] | null>(null);
|
||||
const toast = useContext(ToastContext);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserAliases(email).then(setAliases);
|
||||
|
@ -77,8 +79,21 @@ export default function ManageUserButton({ email }: { email: string }) {
|
|||
description={<>Do you want to approve <Code>{alias.address}</Code>'s alias request for <Code>{alias.alias}</Code>?</>}
|
||||
labelConfirm="Approve"
|
||||
action={async () => {
|
||||
try {
|
||||
await approveAlias(alias.alias);
|
||||
setAliases(aliases.map((a) => a.id == alias.id ? { ...alias, pending: false } : a));
|
||||
toast({
|
||||
title: "Alias approved",
|
||||
description: alias.alias,
|
||||
variant: "success",
|
||||
});
|
||||
} catch(e) {
|
||||
toast({
|
||||
title: "Failed to approve alias",
|
||||
description: `${e}`,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button size="1" variant="surface">Approve</Button>
|
||||
|
@ -89,8 +104,21 @@ export default function ManageUserButton({ email }: { email: string }) {
|
|||
description={<>Are you sure you want to delete <Code>{alias.alias}</Code>?</>}
|
||||
labelConfirm="Delete"
|
||||
action={async () => {
|
||||
try {
|
||||
await deleteAlias(alias.alias);
|
||||
setAliases(aliases.filter((a) => a.id != alias.id));
|
||||
toast({
|
||||
title: "Alias deleted",
|
||||
description: alias.alias,
|
||||
variant: "success",
|
||||
});
|
||||
} catch(e) {
|
||||
toast({
|
||||
title: "Failed to delete alias",
|
||||
description: `${e}`,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button size="1" variant="soft">Delete</Button>
|
||||
|
|
|
@ -7,9 +7,10 @@ import { aliasesNeedApproval } from "@/lib/util";
|
|||
import { Badge, Box, Button, Callout, Card, Code, Dialog, DropdownMenu, Flex, Heading, ScrollArea, Table, Text, TextField } from "@radix-ui/themes";
|
||||
import { ChevronDownIcon, InfoIcon, UsersRoundIcon } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import GhostMessage from "../GhostMessage";
|
||||
import { ToastContext } from "@/lib/providers/ToastProvider";
|
||||
|
||||
export default function OwnAliasesCard() {
|
||||
const session = useSession().data;
|
||||
|
@ -17,6 +18,7 @@ export default function OwnAliasesCard() {
|
|||
const [newAliasUsername, setNewAliasUsername] = useState("");
|
||||
const [newAliasDomain, setNewAliasDomain] = useState(ALIAS_DOMAINS[0]);
|
||||
const [allowAlias, setAllowAlias] = useState(false);
|
||||
const toast = useContext(ToastContext);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOwnAliases().then(setAliases);
|
||||
|
@ -98,9 +100,18 @@ export default function OwnAliasesCard() {
|
|||
try {
|
||||
const alias = await createAliasSelf(`${newAliasUsername}@${newAliasDomain}`);
|
||||
setAliases([...(aliases ?? []), alias]);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Alias created",
|
||||
description: alias.pending ? "Administrator approval pending" : undefined,
|
||||
variant: "success",
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
alert(e);
|
||||
toast({
|
||||
title: "Failed to create alias",
|
||||
description: `${e}`,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}}>Create</Button>
|
||||
</Dialog.Close>
|
||||
|
@ -153,9 +164,18 @@ export default function OwnAliasesCard() {
|
|||
try {
|
||||
await deleteAlias(alias.alias);
|
||||
setAliases(aliases.filter((a) => a.id != alias.id));
|
||||
toast({
|
||||
title: "Alias deleted",
|
||||
description: alias.alias,
|
||||
variant: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(e);
|
||||
toast({
|
||||
title: "Failed to delete alias",
|
||||
description: `${e}`,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { changeOwnPassword } from "@/lib/actions";
|
||||
import { ToastContext } from "@/lib/providers/ToastProvider";
|
||||
import { Box, Button, Callout, Card, Dialog, Flex, Heading, IconButton, Link, Popover, Text, TextField, Tooltip } from "@radix-ui/themes";
|
||||
import { AlertCircleIcon, InfoIcon, LockIcon, UserIcon } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
export default function OwnCredentialsCard({ onPasswordChange }: { onPasswordChange?: () => any }) {
|
||||
export default function OwnCredentialsCard() {
|
||||
const session = useSession().data;
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const validPassword = newPassword.length >= 8;
|
||||
const toast = useContext(ToastContext);
|
||||
|
||||
return (
|
||||
<Card className="h-fit">
|
||||
|
@ -91,9 +93,18 @@ export default function OwnCredentialsCard({ onPasswordChange }: { onPasswordCha
|
|||
|
||||
try {
|
||||
await changeOwnPassword(newPassword);
|
||||
onPasswordChange?.();
|
||||
toast({
|
||||
title: "Password changed",
|
||||
description: "balls",
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
console.error(e);
|
||||
toast({
|
||||
title: "Password change failed",
|
||||
description: `${e}`,
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>Update</Button>
|
||||
|
|
80
src/lib/providers/ToastProvider.tsx
Normal file
80
src/lib/providers/ToastProvider.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { Box, Flex, Text } from "@radix-ui/themes";
|
||||
import { CheckIcon, CircleSlashIcon, XCircleIcon, XIcon } from "lucide-react";
|
||||
import { createContext, useState } from "react";
|
||||
|
||||
export type ToastData = {
|
||||
title: string,
|
||||
description?: string,
|
||||
variant?: 'normal' | 'error' | 'success',
|
||||
};
|
||||
|
||||
function genId() {
|
||||
return `${Math.random()}${Date.now()}`; // good enough i guess
|
||||
}
|
||||
|
||||
type ToastDataInternal = ToastData & { id: string };
|
||||
|
||||
export const ToastContext = createContext((toast: ToastData) => { });
|
||||
|
||||
export default function ToastProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [toasts, setToasts] = useState<ToastDataInternal[]>([]);
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Provider>
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onClosed={() => setToasts(toasts.filter((t) => t.id != toast.id))}
|
||||
/>
|
||||
))}
|
||||
|
||||
<ToastContext.Provider value={(toast: ToastData) => {
|
||||
// Updating the "local" `toasts` variable before calling `setToasts()` allows us to
|
||||
// spawn multiple toasts without re-rendering. Otherwise, calling `toast()` twice
|
||||
// will only display the last toast.
|
||||
toasts.push({ ...toast, id: genId() });
|
||||
setToasts([...toasts]);
|
||||
}}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
<ToastPrimitives.Viewport className="ToastViewport" />
|
||||
</ToastPrimitives.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Toast({ toast, onClosed }: { toast: ToastData, onClosed: () => void }) {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
className={`ToastRoot`}
|
||||
duration={3000}
|
||||
onOpenChange={(state) => !state && onClosed()}
|
||||
>
|
||||
<Flex justify="between" align="center" gap="4">
|
||||
<Text>
|
||||
{
|
||||
toast.variant == "error"
|
||||
? <CircleSlashIcon />
|
||||
: toast.variant == "success"
|
||||
? <CheckIcon />
|
||||
: <></>
|
||||
}
|
||||
</Text>
|
||||
<Box grow="1">
|
||||
<ToastPrimitives.Title className="ToastTitle">{toast.title}</ToastPrimitives.Title>
|
||||
{toast.description && <ToastPrimitives.Description className="ToastDescription">{toast.description}</ToastPrimitives.Description>}
|
||||
</Box>
|
||||
</Flex>
|
||||
<ToastPrimitives.Close className="bg-transparent border-none p-0">
|
||||
<Text color="gray"><XIcon size="16" /></Text>
|
||||
</ToastPrimitives.Close>
|
||||
</ToastPrimitives.Root>
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue