mirror of
https://codeberg.org/MarkusThielker/next-ory.git
synced 2025-04-10 11:58:41 +00:00
NORY-15: add identity page (#28)
This commit is contained in:
commit
6e0bbf3954
12 changed files with 453 additions and 11 deletions
Binary file not shown.
|
@ -16,6 +16,7 @@
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.2",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
@ -25,6 +26,7 @@
|
||||||
"@serwist/next": "^9.0.0-preview.21",
|
"@serwist/next": "^9.0.0-preview.21",
|
||||||
"@serwist/precaching": "^9.0.0-preview.21",
|
"@serwist/precaching": "^9.0.0-preview.21",
|
||||||
"@serwist/sw": "^9.0.0-preview.21",
|
"@serwist/sw": "^9.0.0-preview.21",
|
||||||
|
"@tanstack/react-query": "^5.62.3",
|
||||||
"@tanstack/react-table": "^8.20.5",
|
"@tanstack/react-table": "^8.20.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
|
|
@ -73,6 +73,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-sidebar text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -35,7 +35,7 @@ export const metadata = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: '#0B0908',
|
themeColor: '#18181b',
|
||||||
width: 'device-width',
|
width: 'device-width',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -56,10 +56,10 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<SidebarProvider>
|
<SidebarProvider className="max-h-screen min-h-screen">
|
||||||
<AppSidebar className="mx-1"/>
|
<AppSidebar className="mx-1"/>
|
||||||
<SidebarInset className="p-2">
|
<SidebarInset className="overflow-hidden p-6 space-y-6">
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
|
<header className="flex h-4 items-center gap-2">
|
||||||
<SidebarTrigger className="-ml-1 p-1"/>
|
<SidebarTrigger className="-ml-1 p-1"/>
|
||||||
<Separator orientation="vertical" className="mr-2 h-4"/>
|
<Separator orientation="vertical" className="mr-2 h-4"/>
|
||||||
{
|
{
|
||||||
|
@ -75,12 +75,12 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-col p-4 pt-0">
|
<div className="flex-1 overflow-scroll">
|
||||||
{children}
|
{children}
|
||||||
<Toaster/>
|
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
<Toaster/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
146
dashboard/src/app/user/data-table.tsx
Normal file
146
dashboard/src/app/user/data-table.tsx
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Identity } from '@ory/client';
|
||||||
|
import { DataTable } from '@/components/ui/data-table';
|
||||||
|
import { CircleCheck, CircleX } from 'lucide-react';
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { FetchIdentityPageProps } from '@/app/user/page';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
|
||||||
|
interface IdentityDataTableProps {
|
||||||
|
data: Identity[];
|
||||||
|
pageSize: number;
|
||||||
|
pageToken: string | undefined;
|
||||||
|
query: string;
|
||||||
|
fetchIdentityPage: (props: FetchIdentityPageProps) => Promise<{ data: Identity[], tokens: Map<string, string> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdentityDataTable({ data, pageSize, pageToken, query, fetchIdentityPage }: IdentityDataTableProps) {
|
||||||
|
|
||||||
|
const columns: ColumnDef<Identity>[] = [
|
||||||
|
{
|
||||||
|
id: 'id',
|
||||||
|
accessorKey: 'id',
|
||||||
|
header: 'ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'active',
|
||||||
|
header: 'Active',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
|
||||||
|
const identity = row.original;
|
||||||
|
|
||||||
|
if (identity.state === 'active') {
|
||||||
|
return <CircleCheck/>;
|
||||||
|
} else {
|
||||||
|
return <CircleX/>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorKey: 'traits.name',
|
||||||
|
header: 'Name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
|
||||||
|
const identity = row.original;
|
||||||
|
const email = identity.verifiable_addresses ?
|
||||||
|
identity.verifiable_addresses[0] : undefined;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return <p>Something went wrong</p>;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row space-x-2 items-center">
|
||||||
|
<span>{email.value}</span>
|
||||||
|
{
|
||||||
|
email.verified ?
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger>
|
||||||
|
<CircleCheck/>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent>
|
||||||
|
<span>This email was confirmed at </span>
|
||||||
|
{new Date(email.verified_at!!).toLocaleString()}
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
:
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger>
|
||||||
|
<CircleX/>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent>
|
||||||
|
This email is not confirmed yet
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const [items, setItems] = useState<Identity[]>(data);
|
||||||
|
const [nextToken, setNextToken] = useState<string | undefined>(pageToken);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(data);
|
||||||
|
setNextToken(pageToken);
|
||||||
|
}, [data, pageSize, pageToken, query]);
|
||||||
|
|
||||||
|
const fetchMore = async () => {
|
||||||
|
if (!nextToken) return;
|
||||||
|
|
||||||
|
const response = await fetchIdentityPage({
|
||||||
|
pageSize: pageSize,
|
||||||
|
pageToken: nextToken,
|
||||||
|
query: query,
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems([...items, ...response.data]);
|
||||||
|
setNextToken(response.tokens.get('next') ?? undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const infiniteScrollSensor = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
fetchMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 }, // Adjust threshold as needed
|
||||||
|
);
|
||||||
|
|
||||||
|
if (infiniteScrollSensor.current) {
|
||||||
|
observer.observe(infiniteScrollSensor.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (infiniteScrollSensor.current) {
|
||||||
|
observer.unobserve(infiniteScrollSensor.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable columns={columns} data={items}/>
|
||||||
|
{
|
||||||
|
nextToken && (
|
||||||
|
<div className="flex w-full justify-center">
|
||||||
|
<Spinner ref={infiniteScrollSensor} className="h-10"/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +1,83 @@
|
||||||
export default async function UserPage() {
|
import React from 'react';
|
||||||
return <></>;
|
import { IdentityDataTable } from '@/app/user/data-table';
|
||||||
|
import { getIdentityApi } from '@/ory/sdk/server';
|
||||||
|
import { SearchInput } from '@/components/search-input';
|
||||||
|
|
||||||
|
export interface FetchIdentityPageProps {
|
||||||
|
pageSize: number;
|
||||||
|
pageToken: string;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIdentityPage({ pageSize, pageToken, query }: FetchIdentityPageProps) {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const identityApi = await getIdentityApi();
|
||||||
|
const response = await identityApi.listIdentities({
|
||||||
|
pageSize: pageSize,
|
||||||
|
pageToken: pageToken,
|
||||||
|
previewCredentialsIdentifierSimilar: query,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: response.data,
|
||||||
|
tokens: parseTokens(response.headers.link),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokens(link: string) {
|
||||||
|
|
||||||
|
const parsed = link.split(',').map((it) => {
|
||||||
|
const startRel = it.lastIndexOf('rel="');
|
||||||
|
const endRel = it.lastIndexOf('"');
|
||||||
|
const rel = it.slice(startRel, endRel);
|
||||||
|
|
||||||
|
const startToken = it.lastIndexOf('page_token=');
|
||||||
|
const endToken = it.lastIndexOf('&');
|
||||||
|
const token = it.slice(startToken, endToken);
|
||||||
|
|
||||||
|
return [rel, token];
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Map(parsed.map(obj => [
|
||||||
|
obj[0].replace('rel="', ''),
|
||||||
|
obj[1].replace('page_token=', ''),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserPage(
|
||||||
|
{
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
|
||||||
|
const params = await searchParams;
|
||||||
|
const query = params.query ? params.query as string : '';
|
||||||
|
|
||||||
|
let pageSize = 250;
|
||||||
|
let pageToken: string = '00000000-0000-0000-0000-000000000000';
|
||||||
|
|
||||||
|
const initialFetch = await fetchIdentityPage({ pageSize, pageToken, query: query });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold leading-tight tracking-tight">Users</p>
|
||||||
|
<p className="text-lg font-light">
|
||||||
|
See and manage all identities registered with your Ory Kratos instance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SearchInput queryParamKey="query" placeholder="Search"/>
|
||||||
|
<IdentityDataTable
|
||||||
|
data={initialFetch.data}
|
||||||
|
pageSize={pageSize}
|
||||||
|
pageToken={initialFetch.tokens.get('next')}
|
||||||
|
query={query}
|
||||||
|
fetchIdentityPage={fetchIdentityPage}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { LogoutLink } from '@/ory';
|
import { LogoutLink } from '@/ory';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
|
||||||
|
@ -54,10 +55,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<a href={item.url}>
|
<Link href={item.url}>
|
||||||
<item.icon/>
|
<item.icon/>
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</a>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
|
|
40
dashboard/src/components/search-input.tsx
Normal file
40
dashboard/src/components/search-input.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { ChangeEvent, HTMLInputTypeAttribute } from 'react';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
placeholder: string;
|
||||||
|
queryParamKey: string;
|
||||||
|
type?: HTMLInputTypeAttribute;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchInput({ placeholder, queryParamKey, type, className }: SearchInputProps) {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
|
||||||
|
const onSearchChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
||||||
|
const value = event.target.value;
|
||||||
|
const newParams = new URLSearchParams(params.toString());
|
||||||
|
|
||||||
|
if (value.length < 1) {
|
||||||
|
newParams.delete(queryParamKey);
|
||||||
|
} else {
|
||||||
|
newParams.set(queryParamKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace('?' + newParams.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type={type ?? 'text'}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={className}
|
||||||
|
onChange={onSearchChange}/>
|
||||||
|
);
|
||||||
|
}
|
53
dashboard/src/components/ui/data-table-fallback.tsx
Normal file
53
dashboard/src/components/ui/data-table-fallback.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
interface DataTableFallbackProps {
|
||||||
|
columnCount: number,
|
||||||
|
rowCount: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DataTableFallback({ columnCount, rowCount }: DataTableFallbackProps) {
|
||||||
|
|
||||||
|
const columns: string[] = [];
|
||||||
|
for (let i = 0; i < columnCount; i++) {
|
||||||
|
columns.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: string[] = [];
|
||||||
|
for (let i = 0; i < rowCount; i++) {
|
||||||
|
rows.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{
|
||||||
|
columns.map((_, index) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={index}>
|
||||||
|
<Skeleton className="w-20 h-6"/>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{
|
||||||
|
rows.map((_, index) =>
|
||||||
|
<TableRow key={index} className="h-4">
|
||||||
|
{
|
||||||
|
columns.map((_, index) =>
|
||||||
|
<TableCell key={index} className="h-4">
|
||||||
|
<Skeleton className="w-full h-4"/>
|
||||||
|
</TableCell>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</TableRow>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
71
dashboard/src/components/ui/data-table.tsx
Normal file
71
dashboard/src/components/ui/data-table.tsx
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: ColumnDef<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<TData, TValue>(
|
||||||
|
{
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
}: DataTableProps<TData, TValue>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
29
dashboard/src/components/ui/hover-card.tsx
Normal file
29
dashboard/src/components/ui/hover-card.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const HoverCard = HoverCardPrimitive.Root;
|
||||||
|
|
||||||
|
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||||
|
|
||||||
|
const HoverCardContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
20
dashboard/src/components/ui/spinner.tsx
Normal file
20
dashboard/src/components/ui/spinner.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
|
export const Spinner = (
|
||||||
|
{ className, ref }: { className?: string, ref: RefObject<any> },
|
||||||
|
) => <svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={cn('animate-spin', className)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>;
|
Loading…
Add table
Reference in a new issue