Adding functioning plot of Plume Events widget
This commit is contained in:
@@ -24,6 +24,6 @@ export default defineConfig([globalIgnores(['dist']), {
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'no-unused-vars': 'off',
|
||||
},
|
||||
}, ...storybook.configs["flat/recommended"]])
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import {Button, Dialog, Flex, Grid, GridItem, Spinner, Text} from "@chakra-ui/react";
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Button, Dialog, Flex, Portal, Spinner, Text} from "@chakra-ui/react";
|
||||
import {useEffect, useMemo, useState } from 'react';
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
|
||||
export default function IvansAlerts({ settings, widgetID }) {
|
||||
console.log("IvansAlerts");
|
||||
console.log("IvansAlerts widget ID: ", widgetID);
|
||||
const [activeSummaryItem, setActiveSummaryItem] = useState(false);
|
||||
const [activeFileItem, setActiveFileItem] = useState(false);
|
||||
const [activeImageItem, setActiveImageItem] = useState(false);
|
||||
const widgets = useDashboardStore(state => state.widgets);
|
||||
console.log("widgets: ", widgets);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const isCopy = settings?.copyToClipboard === true; // acts as a failsafe if no default setting is set
|
||||
const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
// build url once on mount
|
||||
const seven_day_url = useMemo(() => {
|
||||
const base_url = '/api/v2/comm_coor_ivans'
|
||||
const base_url = '/api/v2/comm_coor_ivans';
|
||||
let today = new Date();
|
||||
let sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
@@ -27,10 +23,11 @@ export default function IvansAlerts({ settings, widgetID }) {
|
||||
return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today;
|
||||
}, []);
|
||||
|
||||
// VdapApi hook only calls api if the url changes
|
||||
// VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
|
||||
const {data: apiD, isLoading, error} = VdapApi(seven_day_url);
|
||||
|
||||
const apiData = [
|
||||
const apiData = useMemo(() => {
|
||||
return ([
|
||||
{
|
||||
id: 277,
|
||||
volcano_number: 268010,
|
||||
@@ -90,11 +87,12 @@ export default function IvansAlerts({ settings, widgetID }) {
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
])
|
||||
}, []);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (apiData.length === 0) return [];
|
||||
let sorted = [];
|
||||
if (apiData?.length === 0) return [];
|
||||
let sorted;
|
||||
|
||||
if (sorts?.volcanoSort && sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeVolcanoSort);
|
||||
@@ -106,13 +104,13 @@ export default function IvansAlerts({ settings, widgetID }) {
|
||||
sorted = [...apiData].sort(descTimeSort);
|
||||
}
|
||||
|
||||
console.log("sorted data: ", sorted);
|
||||
return sorted;
|
||||
}, [apiData, sorts]);
|
||||
|
||||
useEffect(() => {
|
||||
if(isCopy) {
|
||||
navigator.clipboard.writeText(data)
|
||||
const dataText = JSON.stringify(data); // TODO: this is pretty ugly when copied and pasted, format it better
|
||||
navigator.clipboard.writeText(dataText)
|
||||
.then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
})
|
||||
@@ -121,7 +119,7 @@ export default function IvansAlerts({ settings, widgetID }) {
|
||||
}));
|
||||
updateSettings(widgetID, {copyToClipboard: false});
|
||||
}
|
||||
}, [isCopy]);
|
||||
}, [isCopy, data, widgetID, updateSettings]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
|
||||
@@ -142,187 +140,96 @@ export default function IvansAlerts({ settings, widgetID }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex w="100%" h="100%" direction="column" bg="gray.800" borderRadius="xl" align="center" justify="center" gap={2} p={2} overflow="auto">
|
||||
{/*<Grid w="100%" h="100%" templateColumns="repeat(6, 1fr)" alignItems="center">*/}
|
||||
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" pl={4}>Volcano</GridItem>*/}
|
||||
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold">Report Number</GridItem>*/}
|
||||
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" pl={3}>Report Send Time</GridItem>*/}
|
||||
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Preparer</GridItem>*/}
|
||||
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Summary</GridItem>*/}
|
||||
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Attachments</GridItem>*/}
|
||||
{/*</Grid>*/}
|
||||
<Flex pos="relative" w="100%" h="100%" direction="column" bg="gray.800" borderRadius="xl" align="center" justify="center" gap={2} p={2}>
|
||||
{data.map((item, index) => {
|
||||
const context_string = item.volcano_name + ' • ' + item.send_time;
|
||||
return (
|
||||
<Grid w="100%" h="100%" templateColumns="repeat(8, 1fr)" key={index} borderRadius="lg" bg="gray.700" alignItems="center" justifyContent="center">
|
||||
<GridItem colSpan={2} color="gray.100" pl={4}>{item.volcano_name}</GridItem>
|
||||
<GridItem colSpan={1} color="gray.100">{item.report_num}</GridItem>
|
||||
<GridItem colSpan={2} color="gray.100">{item.send_time}</GridItem>
|
||||
<GridItem colSpan={1} color="gray.100">{item.preparer}</GridItem>
|
||||
<GridItem colSpan={1}>
|
||||
<>
|
||||
<Button w="7rem" h={0} mb={1} onClick={() => setActiveSummaryItem(item)} color="purple.300" variant="plain" textDecor="underline">View Summary</Button>
|
||||
/* IVANS ENTRIES */
|
||||
<Flex onClick={() => setActiveSummaryItem(item)} w="100%" h="100%" key={index} borderRadius="lg" bg="gray.700" justify="space-between" align="center">
|
||||
<Flex direction="column" w="60%" pl={3} color="gray.100">
|
||||
<Text>{item.volcano_name}</Text>
|
||||
<Text fontSize="xs" color="gray.300/90">{item.send_time}</Text>
|
||||
</Flex>
|
||||
<Flex direction="row" w="40%" justify="right" pr={3}>
|
||||
<Text color="gray.100" bg="gray.800/50" borderRadius="lg" pl={2} pr={2}>{item.report_num}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
})}
|
||||
{/* SUMMARY/PDF VIEWER */}
|
||||
<SummaryDialog
|
||||
open={!!activeSummaryItem}
|
||||
onOpenChange={() => setActiveSummaryItem(null)}
|
||||
item={activeSummaryItem}
|
||||
context={context_string}
|
||||
/>
|
||||
</>
|
||||
</GridItem>
|
||||
<GridItem colSpan={1}>
|
||||
<Flex direction="column" justify="center" alignItems="center" gap={0}>
|
||||
{item.files.length === 0 ? (
|
||||
<Text color="gray.600" fontSize="sm">No Attachments</Text>
|
||||
):(
|
||||
item.files.map((file, index) => {
|
||||
if (file.type === "pdf") return (
|
||||
<div key={index}>
|
||||
<Button w="7rem" h={0} mb={1} onClick={() => setActiveFileItem(file)} color="purple.300" variant="plain" textDecor="underline">View PDF</Button>
|
||||
<FileDialog
|
||||
open={!!activeFileItem}
|
||||
onOpenChange={() => setActiveFileItem(null)}
|
||||
item={activeFileItem}
|
||||
context={context_string}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
else if (file.type === "image") return (
|
||||
<div key={index}>
|
||||
<Button w="7rem" onClick={() => setActiveImageItem(file)} color="purple.300" variant="plain" textDecor="underline">View Image</Button>
|
||||
<ImageDialog
|
||||
open={!!activeImageItem}
|
||||
onOpenChange={() => setActiveImageItem(null)}
|
||||
item={activeImageItem}
|
||||
context={context_string}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
else if (file.type === "download") return (
|
||||
<Button key={index} w="7rem" onClick={() => downloadReport(file.url, file.name) } color="purple.300" variant="plain" textDecor="underline">Download</Button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</Flex>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryDialog({ open, onOpenChange, item }) {
|
||||
return (
|
||||
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content w="100vw" h="40vh">
|
||||
<Dialog.Header>
|
||||
<Dialog.Root lazyMount
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
trapFocus={false}
|
||||
modal={false}
|
||||
closeOnInteractOutside={false}
|
||||
motionPreset="slide-in-bottom"
|
||||
position="absolute"
|
||||
>
|
||||
<Dialog.Positioner w="100%" h="100%">
|
||||
<Dialog.Content w="100%" h="100%" maxW="none" p={0} m={0} borderRadius="xl" boxShadow="none">
|
||||
<Dialog.Header p={3}>
|
||||
<Dialog.Title w="100%">
|
||||
<Flex w="100%" h="100%" direction="column" textAlign="left">
|
||||
<Flex direction="row" justify="space-between" align="center">
|
||||
<Text fontSize="md">{item?.volcano_name}</Text>
|
||||
<Text bg="gray.800" fontSize="xs" borderRadius="sm" color="gray.500" pl={2} pr={2}>Preparer: {item?.preparer}</Text>
|
||||
<Flex w="100%" direction="row" justify="space-between" align="center">
|
||||
<Flex w="100%" direction="column">
|
||||
<Text w="100%" fontSize="lg">
|
||||
{item?.volcano_name}
|
||||
</Text>
|
||||
<Text w="100%" fontSize="sm" color="blue.400">
|
||||
{item?.send_time}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" justify="right" align="center">
|
||||
<Text textAlign="center" bg="gray.600" fontSize="xs" borderRadius="xl" color="gray.400" pl={2} pr={2}>
|
||||
prepared by: {item?.preparer}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Text fontSize="xs" color="blue.400">{item?.send_time}</Text>
|
||||
</Flex>
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Body bg="gray.800" overflow="auto">
|
||||
<Text textAlign="left">{item?.summary}</Text>
|
||||
</Dialog.Body>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
|
||||
Close
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
<Dialog.CloseTrigger />
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function FileDialog({ open, onOpenChange, item, context }) {
|
||||
<Text textAlign="left" color="gray.100">{item?.summary}</Text>
|
||||
<br />
|
||||
{item?.files?.map((file, index) => {
|
||||
return (
|
||||
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content w="100vw" h="75vh">
|
||||
<Dialog.Header>
|
||||
<Flex height="100%" width="100%" direction="row" align="center" justifyContent="space-between">
|
||||
<Flex direction="column" textAlign="left">
|
||||
<Dialog.Title>{item?.name ? item?.name : 'PDF Attachment'}</Dialog.Title>
|
||||
<Text color="gray.200">{context}</Text>
|
||||
</Flex>
|
||||
<Text bg="gray.800" p="1" borderRadius="sm">{(item?.type || '').toUpperCase()}</Text>
|
||||
</Flex>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Body p={0}>
|
||||
<Flex bg="gray.800" w="100%" h="100%" alignItems="center" justifyContent="center">
|
||||
<object data={`${item?.url}#view=FitH`} type="application/pdf" style={{ width: '100%', height: '100%', display: 'block' }}>
|
||||
<object data={`${file?.url}#view=FitH&toolbar=1`}
|
||||
type="application/pdf"
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
>
|
||||
<Flex w="100%" h="100%" color="gray.400" p={4}>
|
||||
<Text mb={2} color="fg.muted">Your browser cannot display this PDF directly.</Text>
|
||||
<a href={item?.url} target="_blank">
|
||||
<Text mb={2} color="fg.muted">
|
||||
Your browser cannot display this PDF directly.
|
||||
</Text>
|
||||
<a href={file?.url} target="_blank">
|
||||
Download PDF
|
||||
</a>
|
||||
</Flex>
|
||||
</object>
|
||||
</Flex>
|
||||
)})}
|
||||
</Dialog.Body>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
|
||||
<Dialog.Footer p={2} pr={3}>
|
||||
<Button size="xs" variant="subtle" onClick={() => onOpenChange({ open: false })}>
|
||||
Close
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
<Dialog.CloseTrigger />
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageDialog({ open, onOpenChange, item, context }) {
|
||||
return (
|
||||
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content w="100vw" h="40vh">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{item?.name}</Dialog.Title>
|
||||
<Text color="gray.200">{context}</Text>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Body bg="gray.800" overflow="auto">
|
||||
<img src={item?.url} alt={item?.name} />
|
||||
</Dialog.Body>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
|
||||
Close
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
<Dialog.CloseTrigger />
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const downloadReport = ( url, name ) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function ascTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
import {Box, Flex, Spinner, Text} from "@chakra-ui/react";
|
||||
|
||||
export default function PlumeEvents({ settings, widgetID}) {
|
||||
const { data: apiData, isLoading, error } = VdapApi('/api/v2/plume_events?start_date=2026-01-01&end_date=2026-03-01');
|
||||
|
||||
const plotTraces = useMemo(() => {
|
||||
if (!apiData || apiData.length === 0) return [];
|
||||
|
||||
// Array of distinct Plotly shapes
|
||||
const Symbolway = [
|
||||
'circle', 'square', 'diamond',
|
||||
'triangle-up', 'triangle-down',
|
||||
'star', 'hexagram', 'pentagon',
|
||||
'hexagon', 'octagon', 'cross', 'x'
|
||||
];
|
||||
|
||||
// Sort data descending by plume height so smaller dots draw on top of larger ones
|
||||
const sortedData = [...apiData].sort((a, b) => b.h_avs_m - a.h_avs_m);
|
||||
|
||||
// Efficiently group into Plotly traces by volcano
|
||||
const groupedData = sortedData.reduce((acc, curr) => {
|
||||
const name = curr.volcano_name || 'Unknown';
|
||||
if (!acc[name]) acc[name] = { x: [], y: [] };
|
||||
|
||||
// Map the explosion-specific keys
|
||||
acc[name].x.push(new Date(curr.ts_epoch));
|
||||
acc[name].y.push(curr.h_avs_m);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(groupedData).map((volcanoName, index) => ({
|
||||
name: volcanoName,
|
||||
x: groupedData[volcanoName].x,
|
||||
y: groupedData[volcanoName].y,
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean D3/Observable tooltip
|
||||
hovertemplate:
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Plume Height (m): %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#d1d5db' }, // gray.300
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
// title: {
|
||||
// text: '7 Day Consolidated Flight Levels',
|
||||
// font: { size: 16, color: 'white' },
|
||||
// x: 0.05
|
||||
// },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: 'white', size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#4b5563',
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Plume Height',
|
||||
font: { color: 'white', size: 12 }
|
||||
},
|
||||
type: 'linear',
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#ffffff',
|
||||
automargin: true,
|
||||
},
|
||||
// legend: {
|
||||
// orientation: 'h',
|
||||
// y: -0.2,
|
||||
// x: 0.5,
|
||||
// xanchor: 'center'
|
||||
// },
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
type: 'line',
|
||||
x0: 0,
|
||||
x1: 1,
|
||||
xref: 'paper', // Spans entire chart width
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: 'white', width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (plotTraces.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No data available for current filter.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
|
||||
<Plot
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,11 @@ export const WIDGET_REGISTRY = {
|
||||
}
|
||||
},
|
||||
allowedSizes: {
|
||||
small: { w: 6, h: 9 }
|
||||
small: { w: 3, h: 6 },
|
||||
medium: { w: 3, h: 9 },
|
||||
large: { w: 3, h: 12 },
|
||||
gargantuan: { w: 6, h: 12 },
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
@@ -93,6 +97,17 @@ export const WIDGET_REGISTRY = {
|
||||
}
|
||||
},
|
||||
|
||||
"plume_events": {
|
||||
name: "Plume Events",
|
||||
category: "Daily Volcanic Activity",
|
||||
description: "Plot of explosion heights over a weeks time.",
|
||||
component: lazy(() => import('../components/Widgets/PlumeEvents.jsx')),
|
||||
defaultSettings: {},
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
"llm_al_changes_quad_plot": {
|
||||
name: "Alert Level Changes Quad Plot",
|
||||
category: "Computer Science",
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function MyDashboard() {
|
||||
<Box flex="1" overflow="hidden" position="relative" p={1}>
|
||||
{/* Pass the settings object into the actual chart component */}
|
||||
{/* Pass widget ID to allow updating settings in widget component rather than just in settings component */}
|
||||
<Component settings={widget.settings || {}} widgetID={widget.widgetID || {}}/>
|
||||
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
|
||||
</Box>
|
||||
|
||||
</Flex>
|
||||
|
||||
Reference in New Issue
Block a user