refactor(web): удалить мёртвый код (~5.9К строк)
- демо-блоки blocks/ (sheet-9, solution-users-1/6, settings-5, auth-18) — не импортировались; auth-logo перенесён в components/auth-logo.tsx - варианты грида data-grid-table-dnd/-dnd-rows/-virtual и ui/svgs — не импортировались - убраны исключения blocks из tsconfig и eslint - AGENTS.md: добавить apps/api/db/shared в описание стека
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## Стек
|
||||
|
||||
- Monorepo: `apps/web` + `packages/ui` (`@authportal/ui`)
|
||||
- Monorepo: `apps/web` + `apps/api` (`@authportal/api`, Fastify + SQLite/better-sqlite3) + `packages/ui` (`@authportal/ui`) + `packages/db` + `packages/shared`
|
||||
- Vite + React + TanStack + shadcn **base-nova** + ReUI Frame
|
||||
- App Switcher: CFDM / vps / bgp / fw / **dns** (Technitium)
|
||||
- Auth: JWT fragment SSO для своих SPA **и** OIDC IdP для внешних RP (Technitium)
|
||||
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'src/routeTree.gen.ts', 'src/components/blocks/**', 'src/components/reui/**']),
|
||||
globalIgnores(['dist', 'src/routeTree.gen.ts', 'src/components/reui/**']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
+1
-1
@@ -32,4 +32,4 @@ export function AuthLogo({ className }: { className?: string }) {
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from "react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
|
||||
export interface AnimatedGridPatternProps extends ComponentPropsWithoutRef<"svg"> {
|
||||
width?: number
|
||||
height?: number
|
||||
x?: number
|
||||
y?: number
|
||||
strokeDasharray?: number
|
||||
numSquares?: number
|
||||
maxOpacity?: number
|
||||
duration?: number
|
||||
repeatDelay?: number
|
||||
}
|
||||
|
||||
type Square = {
|
||||
id: number
|
||||
pos: [number, number]
|
||||
iteration: number
|
||||
}
|
||||
|
||||
// Deterministic 0..1 hash so cell placement stays stable across renders without
|
||||
// Math.random, so SSR and client agree and the no-randomness gate stays green.
|
||||
function pseudoRandom(seed: number): number {
|
||||
const value = Math.sin(seed * 12.9898) * 43758.5453
|
||||
return value - Math.floor(value)
|
||||
}
|
||||
|
||||
export function AnimatedGridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = 0,
|
||||
numSquares = 30,
|
||||
className,
|
||||
maxOpacity = 0.1,
|
||||
duration = 3,
|
||||
repeatDelay = 1,
|
||||
...props
|
||||
}: AnimatedGridPatternProps) {
|
||||
const id = useId()
|
||||
const containerRef = useRef<SVGSVGElement | null>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
const [squares, setSquares] = useState<Array<Square>>([])
|
||||
|
||||
const getPos = useCallback(
|
||||
(seed: number): [number, number] => {
|
||||
const cols = Math.max(1, Math.floor(dimensions.width / width))
|
||||
const rows = Math.max(1, Math.floor(dimensions.height / height))
|
||||
return [
|
||||
Math.floor(pseudoRandom(seed) * cols),
|
||||
Math.floor(pseudoRandom(seed + 0.5) * rows),
|
||||
]
|
||||
},
|
||||
[dimensions.height, dimensions.width, height, width]
|
||||
)
|
||||
|
||||
const generateSquares = useCallback(
|
||||
(count: number) => {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: index,
|
||||
pos: getPos(index + 1),
|
||||
iteration: 0,
|
||||
}))
|
||||
},
|
||||
[getPos]
|
||||
)
|
||||
|
||||
const updateSquarePosition = useCallback(
|
||||
(squareId: number) => {
|
||||
setSquares((currentSquares) => {
|
||||
const current = currentSquares[squareId]
|
||||
if (!current || current.id !== squareId) {
|
||||
return currentSquares
|
||||
}
|
||||
|
||||
const nextSquares = currentSquares.slice()
|
||||
const nextIteration = current.iteration + 1
|
||||
nextSquares[squareId] = {
|
||||
...current,
|
||||
pos: getPos((squareId + 1) * 97 + nextIteration * 13),
|
||||
iteration: nextIteration,
|
||||
}
|
||||
|
||||
return nextSquares
|
||||
})
|
||||
},
|
||||
[getPos]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (dimensions.width && dimensions.height) {
|
||||
setSquares(generateSquares(numSquares))
|
||||
}
|
||||
}, [dimensions.width, dimensions.height, generateSquares, numSquares])
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setDimensions((currentDimensions) => {
|
||||
const nextWidth = entry.contentRect.width
|
||||
const nextHeight = entry.contentRect.height
|
||||
|
||||
if (
|
||||
currentDimensions.width === nextWidth &&
|
||||
currentDimensions.height === nextHeight
|
||||
) {
|
||||
return currentDimensions
|
||||
}
|
||||
|
||||
return { width: nextWidth, height: nextHeight }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(element)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={containerRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/12 stroke-gray-400/9 dark:fill-gray-500/9 dark:stroke-gray-500/8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="100%" height="100%" fill={`url(#${id})`} />
|
||||
|
||||
<svg x={x} y={y} className="overflow-visible">
|
||||
{squares.map(({ pos: [squareX, squareY], id, iteration }, index) => (
|
||||
<motion.rect
|
||||
key={`${id}-${iteration}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: maxOpacity }}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: 1,
|
||||
delay: index * 0.1,
|
||||
repeatType: "reverse",
|
||||
repeatDelay,
|
||||
}}
|
||||
onAnimationComplete={() => updateSquarePosition(id)}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={squareX * width + 1}
|
||||
y={squareY * height + 1}
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuthGridBackground() {
|
||||
return (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<AnimatedGridPattern
|
||||
numSquares={30}
|
||||
maxOpacity={0.06}
|
||||
duration={3}
|
||||
repeatDelay={1}
|
||||
className="inset-x-0 inset-y-[-30%] h-[200%] skew-y-12 mask-[radial-gradient(460px_circle_at_center,white,transparent)]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useState, type ComponentProps } from "react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@authportal/ui/components/avatar"
|
||||
import { Item, ItemMedia } from "@authportal/ui/components/item"
|
||||
import { AuthGridBackground } from "./auth-grid-background"
|
||||
import { AUTH18_TESTIMONIAL, AUTH18_TRUST_BRANDS } from "./data"
|
||||
import { LoginForm } from "./login-form"
|
||||
import { StarIcon } from "lucide-react"
|
||||
|
||||
type FormSubmitHandler = NonNullable<ComponentProps<"form">["onSubmit"]>
|
||||
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
|
||||
|
||||
function SidebarBackground() {
|
||||
return <AuthGridBackground />
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside className="bg-muted/25 border-border/70 relative flex min-h-[34rem] overflow-hidden px-8 py-10 sm:px-12 lg:min-h-svh lg:border-r lg:px-14 lg:py-12">
|
||||
{/* Sidebar */}
|
||||
<SidebarBackground />
|
||||
|
||||
<div className="relative z-10 flex min-h-full w-full flex-col justify-between gap-10">
|
||||
<div className="mx-auto flex max-w-md flex-1 flex-col items-center justify-center text-center">
|
||||
<div className="text-primary flex items-center gap-1">
|
||||
{Array.from({ length: AUTH18_TESTIMONIAL.stars }).map((_, index) => (
|
||||
<StarIcon aria-hidden="true" className="size-4 fill-current" key={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<blockquote className="mt-6 max-w-[28rem] text-[1.625rem] leading-[1.34] font-semibold text-balance">
|
||||
“{AUTH18_TESTIMONIAL.quote}”
|
||||
</blockquote>
|
||||
|
||||
<div className="mt-5 flex w-full justify-center">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage
|
||||
src={AUTH18_TESTIMONIAL.avatar}
|
||||
alt={AUTH18_TESTIMONIAL.name}
|
||||
/>
|
||||
<AvatarFallback>{AUTH18_TESTIMONIAL.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex min-w-0 flex-col items-start gap-0 text-left">
|
||||
<div className="text-sm font-medium">
|
||||
{AUTH18_TESTIMONIAL.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<span>{AUTH18_TESTIMONIAL.role}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
/>
|
||||
<span>{AUTH18_TESTIMONIAL.company}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-foreground text-sm font-medium">
|
||||
Trusted by leading teams
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
{AUTH18_TRUST_BRANDS.map((brand) => (
|
||||
<Item
|
||||
key={brand.id}
|
||||
className="text-foreground/90 flex w-auto items-center border-0 p-0"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{brand.logo}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function Auth() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
function handleSubmit(event: FormSubmitEvent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full lg:grid lg:min-h-svh lg:grid-cols-[600px_minmax(0,1fr)]">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Form */}
|
||||
<LoginForm
|
||||
showPassword={showPassword}
|
||||
onTogglePassword={() => setShowPassword((current) => !current)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { Apple } from "@authportal/ui/components/svgs/apple"
|
||||
import { AppleDark } from "@authportal/ui/components/svgs/appleDark"
|
||||
import { Google } from "@authportal/ui/components/svgs/google"
|
||||
import { OpenaiWordmarkDark } from "@authportal/ui/components/svgs/openaiWordmarkDark"
|
||||
import { OpenaiWordmarkLight } from "@authportal/ui/components/svgs/openaiWordmarkLight"
|
||||
import { SlackWordmark } from "@authportal/ui/components/svgs/slackWordmark"
|
||||
import { StripeWordmark } from "@authportal/ui/components/svgs/stripeWordmark"
|
||||
import { SupabaseWordmarkDark } from "@authportal/ui/components/svgs/supabaseWordmarkDark"
|
||||
import { SupabaseWordmarkLight } from "@authportal/ui/components/svgs/supabaseWordmarkLight"
|
||||
|
||||
export type AuthProvider = {
|
||||
id: string
|
||||
label: string
|
||||
logo: ReactNode
|
||||
}
|
||||
|
||||
export type TrustBrand = {
|
||||
id: string
|
||||
logo: ReactNode
|
||||
}
|
||||
|
||||
export type Testimonial = {
|
||||
quote: string
|
||||
name: string
|
||||
role: string
|
||||
company: string
|
||||
avatar: string
|
||||
fallback: string
|
||||
stars: number
|
||||
}
|
||||
|
||||
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden="true" className="dark:hidden">
|
||||
{light}
|
||||
</span>
|
||||
<span aria-hidden="true" className="hidden dark:block">
|
||||
{dark}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const providerLogoClassName = "size-4 shrink-0"
|
||||
|
||||
export const AUTH18_PROVIDERS: AuthProvider[] = [
|
||||
{
|
||||
id: "google",
|
||||
label: "Google",
|
||||
logo: <Google className={providerLogoClassName} aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
id: "apple",
|
||||
label: "Apple",
|
||||
logo: (
|
||||
<ThemeLogo
|
||||
light={<Apple className={providerLogoClassName} aria-hidden="true" />}
|
||||
dark={
|
||||
<AppleDark className={providerLogoClassName} aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const AUTH18_TRUST_BRANDS: TrustBrand[] = [
|
||||
{
|
||||
id: "openai",
|
||||
logo: (
|
||||
<ThemeLogo
|
||||
light={
|
||||
<OpenaiWordmarkLight aria-hidden="true" className="h-4 w-auto" />
|
||||
}
|
||||
dark={<OpenaiWordmarkDark aria-hidden="true" className="h-4 w-auto" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
logo: <StripeWordmark aria-hidden="true" className="h-4 w-auto" />,
|
||||
},
|
||||
{
|
||||
id: "supabase",
|
||||
logo: (
|
||||
<ThemeLogo
|
||||
light={
|
||||
<SupabaseWordmarkLight aria-hidden="true" className="h-4 w-auto" />
|
||||
}
|
||||
dark={
|
||||
<SupabaseWordmarkDark aria-hidden="true" className="h-4 w-auto" />
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
logo: (
|
||||
<SlackWordmark
|
||||
aria-hidden="true"
|
||||
className="text-foreground h-4 w-auto"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const AUTH18_TESTIMONIAL: Testimonial = {
|
||||
quote: "The best login pages disappear. This one already feels fast.",
|
||||
name: "Sean Bold",
|
||||
role: "Co-founder",
|
||||
company: "ReUI",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
fallback: "SB",
|
||||
stars: 5,
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { type ComponentProps } from "react"
|
||||
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import { Field, FieldGroup, FieldLabel } from "@authportal/ui/components/field"
|
||||
import { Input } from "@authportal/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@authportal/ui/components/input-group"
|
||||
import { Separator } from "@authportal/ui/components/separator"
|
||||
import { AuthLogo } from "./auth-logo"
|
||||
import { AUTH18_PROVIDERS } from "./data"
|
||||
import { EyeOffIcon, EyeIcon } from "lucide-react"
|
||||
|
||||
type FormSubmitHandler = NonNullable<ComponentProps<"form">["onSubmit"]>
|
||||
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
|
||||
|
||||
export function LoginForm({
|
||||
showPassword,
|
||||
onTogglePassword,
|
||||
onSubmit,
|
||||
}: {
|
||||
showPassword: boolean
|
||||
onTogglePassword: () => void
|
||||
onSubmit: (event: FormSubmitEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col justify-between py-4 sm:py-6 lg:py-8">
|
||||
{/* Heading */}
|
||||
<div className="flex flex-1 flex-col justify-center">
|
||||
<div className="mx-auto flex w-full max-w-90 flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<AuthLogo />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Sign in to ReUI
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">Welcome back.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
<FieldGroup className="gap-3.5">
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="auth-18-identifier">
|
||||
Email or username
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="auth-18-identifier"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="Email or username"
|
||||
className="bg-background"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel htmlFor="auth-18-password">
|
||||
Password
|
||||
</FieldLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="text-muted-foreground h-auto p-0 text-xs font-normal"
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<InputGroup className="bg-background w-full">
|
||||
<InputGroupInput
|
||||
id="auth-18-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
showPassword ? "Hide password" : "Show password"
|
||||
}
|
||||
aria-pressed={showPassword}
|
||||
onClick={onTogglePassword}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon aria-hidden="true" className="size-4" />
|
||||
) : (
|
||||
<EyeIcon aria-hidden="true" className="size-4" />
|
||||
)}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Or continue with
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{AUTH18_PROVIDERS.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{provider.logo}
|
||||
{provider.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
Need an account?{" "}
|
||||
<Button type="button" variant="link" className="h-auto p-0">
|
||||
Sign up
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Auth } from "./components/auth"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="bg-background min-h-svh w-full">
|
||||
<Auth />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
export interface PermissionItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
defaultChecked: boolean
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const permissions: PermissionItem[] = [
|
||||
{
|
||||
id: "workspace-settings",
|
||||
title: "Workspace Settings",
|
||||
description:
|
||||
"Review workspace details, team defaults, and operational preferences.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "billing-management",
|
||||
title: "Billing Management",
|
||||
description: "Access plan details, invoices, and subscription adjustments.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "integration-setup",
|
||||
title: "Integration Setup",
|
||||
description: "Configure apps, credentials, and automation entry points.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "permissions-control",
|
||||
title: "Permissions Control",
|
||||
description: "Grant, revoke, and review access scopes for collaborators.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "map-creation",
|
||||
title: "Map Creation",
|
||||
description: "Create new workspace maps and maintain location structure.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "data-export",
|
||||
title: "Data Export",
|
||||
description:
|
||||
"Download structured workspace reports for analysis and audits.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "user-roles",
|
||||
title: "User Roles",
|
||||
description:
|
||||
"Edit role assignments and keep team responsibility lines clear.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "security-settings",
|
||||
title: "Security Settings",
|
||||
description:
|
||||
"Adjust workspace protection controls and policy requirements.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "insights-access",
|
||||
title: "Insights Access",
|
||||
description:
|
||||
"View performance dashboards, usage trends, and reporting panels.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "merchant-list",
|
||||
title: "Merchant List",
|
||||
description:
|
||||
"Maintain merchant records and workspace-linked account mappings.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
]
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from "@authportal/ui/components/item"
|
||||
import { Switch } from "@authportal/ui/components/switch"
|
||||
|
||||
import { permissions } from "./data"
|
||||
|
||||
// ── Permission Card ──
|
||||
|
||||
function PermissionCard({
|
||||
checked,
|
||||
description,
|
||||
id,
|
||||
onCheckedChange,
|
||||
title,
|
||||
}: {
|
||||
checked: boolean
|
||||
description: string
|
||||
id: string
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<Item variant="outline" className="items-start gap-3">
|
||||
{/* Content */}
|
||||
<ItemContent className="gap-1.5">
|
||||
<ItemTitle>{title}</ItemTitle>
|
||||
<ItemDescription>{description}</ItemDescription>
|
||||
</ItemContent>
|
||||
|
||||
{/* Actions */}
|
||||
<ItemActions className="ml-auto self-center">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
aria-label={title}
|
||||
id={id}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function RolePermissions() {
|
||||
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(
|
||||
permissions.map((permission) => [
|
||||
permission.id,
|
||||
permission.defaultChecked,
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-5xl">
|
||||
{/* Header */}
|
||||
<FrameHeader className="flex-row items-center justify-between gap-5">
|
||||
<div className="space-y-px">
|
||||
<FrameTitle>Role Permissions for Project Manager</FrameTitle>
|
||||
<FrameDescription>
|
||||
Control the workspace capabilities this role can manage.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
|
||||
<Button aria-label="Updates (new)">
|
||||
Permission
|
||||
<Badge variant="success" size="xs" aria-hidden="true">
|
||||
New
|
||||
</Badge>
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
|
||||
{/* Content */}
|
||||
<FramePanel className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
{permissions.map((permission) => (
|
||||
<PermissionCard
|
||||
key={permission.id}
|
||||
id={permission.id}
|
||||
title={permission.title}
|
||||
description={permission.description}
|
||||
checked={values[permission.id]}
|
||||
onCheckedChange={(checked) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[permission.id]: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { RolePermissions } from "./components/role-permissions"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-4xl items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<RolePermissions />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
export type TicketStatus = "open" | "waiting" | "resolved"
|
||||
export type TicketPriority = "low" | "medium" | "high" | "urgent"
|
||||
export type TicketSource = "portal" | "inbox" | "api"
|
||||
export type TicketCategory = "access" | "security" | "billing" | "workflow"
|
||||
|
||||
export type TicketSelectOption<TValue extends string = string> = {
|
||||
value: TValue
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type SupportMember = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
src: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
export type TicketDetailTip = {
|
||||
text: string
|
||||
}
|
||||
|
||||
export type TicketDetailsValue = {
|
||||
dueDate: string
|
||||
status: TicketStatus
|
||||
slaMet: boolean
|
||||
priority: TicketPriority
|
||||
source: TicketSource
|
||||
channel: string
|
||||
requestForm: string
|
||||
category: TicketCategory
|
||||
notifyRequester: boolean
|
||||
tags: string[]
|
||||
collaboratorIds: string[]
|
||||
}
|
||||
|
||||
export const STATUS_OPTIONS: TicketSelectOption<TicketStatus>[] = [
|
||||
{
|
||||
value: "open",
|
||||
label: "Open",
|
||||
description: "Active and ready for the next response.",
|
||||
},
|
||||
{
|
||||
value: "waiting",
|
||||
label: "Waiting",
|
||||
description: "Paused until requester or vendor input arrives.",
|
||||
},
|
||||
{
|
||||
value: "resolved",
|
||||
label: "Resolved",
|
||||
description: "Completed and ready for closure.",
|
||||
},
|
||||
]
|
||||
|
||||
export const PRIORITY_OPTIONS: TicketSelectOption<TicketPriority>[] = [
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "urgent", label: "Urgent" },
|
||||
]
|
||||
|
||||
export const SOURCE_OPTIONS: TicketSelectOption<TicketSource>[] = [
|
||||
{ value: "portal", label: "Customer Portal" },
|
||||
{ value: "inbox", label: "Shared Inbox" },
|
||||
{ value: "api", label: "API Intake" },
|
||||
]
|
||||
|
||||
export const REQUEST_FORM_OPTIONS: TicketSelectOption[] = [
|
||||
{
|
||||
value: "workspace-access",
|
||||
label: "Workspace Access",
|
||||
description: "Provisioning, group access, and app authorization.",
|
||||
},
|
||||
{
|
||||
value: "vendor-review",
|
||||
label: "Vendor Review",
|
||||
description: "Security and procurement review for new tools.",
|
||||
},
|
||||
{
|
||||
value: "billing-exception",
|
||||
label: "Billing Exception",
|
||||
description: "Invoice changes, credits, and payment routing.",
|
||||
},
|
||||
{
|
||||
value: "automation-change",
|
||||
label: "Automation Change",
|
||||
description: "Workflow updates owned by operations.",
|
||||
},
|
||||
]
|
||||
|
||||
export const CATEGORY_OPTIONS: TicketSelectOption<TicketCategory>[] = [
|
||||
{ value: "access", label: "Access Request" },
|
||||
{ value: "security", label: "Security Review" },
|
||||
{ value: "billing", label: "Billing Support" },
|
||||
{ value: "workflow", label: "Workflow Change" },
|
||||
]
|
||||
|
||||
export const TAG_OPTIONS = [
|
||||
"Feature",
|
||||
"VIP",
|
||||
"Automation",
|
||||
"Security",
|
||||
"Renewal",
|
||||
"Finance",
|
||||
]
|
||||
|
||||
export const COLLABORATORS: SupportMember[] = [
|
||||
{
|
||||
id: "mira",
|
||||
name: "Mira Stone",
|
||||
role: "Identity owner",
|
||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
initials: "MS",
|
||||
},
|
||||
{
|
||||
id: "leo",
|
||||
name: "Leo Grant",
|
||||
role: "Support lead",
|
||||
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "LG",
|
||||
},
|
||||
{
|
||||
id: "nora",
|
||||
name: "Nora Vale",
|
||||
role: "Workflow admin",
|
||||
src: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NV",
|
||||
},
|
||||
{
|
||||
id: "theo",
|
||||
name: "Theo Park",
|
||||
role: "Security reviewer",
|
||||
src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "TP",
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_TICKET_DETAILS: TicketDetailsValue = {
|
||||
dueDate: "2026-04-30",
|
||||
status: "open",
|
||||
slaMet: true,
|
||||
priority: "medium",
|
||||
source: "portal",
|
||||
channel: "identity-access",
|
||||
requestForm: "workspace-access",
|
||||
category: "access",
|
||||
notifyRequester: true,
|
||||
tags: ["Feature", "Security"],
|
||||
collaboratorIds: ["mira", "leo"],
|
||||
}
|
||||
|
||||
export const TICKET_TIMESTAMPS = {
|
||||
createdAt: "Jan 21, 2026 at 10:16 PM",
|
||||
updatedAt: "Just now",
|
||||
}
|
||||
|
||||
export const TICKET_DETAIL_TIPS: TicketDetailTip[] = [
|
||||
{
|
||||
text: "Press Enter while editing a text value to save the row.",
|
||||
},
|
||||
{
|
||||
text: "Use row actions for quick updates without leaving the queue.",
|
||||
},
|
||||
]
|
||||
@@ -1,224 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import { Field, FieldTitle } from "@authportal/ui/components/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
} from "@authportal/ui/components/input-group"
|
||||
import { Item, ItemMedia } from "@authportal/ui/components/item"
|
||||
import { Spinner } from "@authportal/ui/components/spinner"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@authportal/ui/components/tooltip"
|
||||
import { InfoIcon, PencilIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface EditableDetailRowProps {
|
||||
label: string
|
||||
hint?: string
|
||||
editing?: boolean
|
||||
display: ReactNode
|
||||
renderEdit?: (active: boolean) => ReactNode
|
||||
align?: "center" | "start"
|
||||
actionsDisabled?: boolean
|
||||
saving?: boolean
|
||||
onEdit?: () => void
|
||||
onCancel?: () => void
|
||||
onSave?: () => void
|
||||
}
|
||||
|
||||
function RowHint({ label, children }: { label: string; children: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground -my-1 shrink-0"
|
||||
aria-label={label}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditableDetailRow({
|
||||
label,
|
||||
hint,
|
||||
editing = false,
|
||||
display,
|
||||
renderEdit,
|
||||
align = "center",
|
||||
actionsDisabled = false,
|
||||
saving = false,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: EditableDetailRowProps) {
|
||||
const editable = Boolean(renderEdit && onEdit && onCancel && onSave)
|
||||
const controlsDisabled = actionsDisabled || saving
|
||||
const controlActive = !controlsDisabled
|
||||
const editActionsDisabled = !editing || controlsDisabled
|
||||
const editRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
return
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const control = editRef.current?.querySelector<HTMLElement>(
|
||||
[
|
||||
"[data-slot='input-group-control']:not(:disabled)",
|
||||
"[data-slot='combobox-chip-input']:not(:disabled)",
|
||||
"button:not(:disabled)",
|
||||
"input:not(:disabled)",
|
||||
].join(",")
|
||||
)
|
||||
|
||||
control?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [editing])
|
||||
|
||||
return (
|
||||
<Field
|
||||
className={cn(
|
||||
"group/row grid gap-x-2 gap-y-0.5 px-4 py-0.5 sm:grid-cols-[minmax(7.75rem,0.5fr)_minmax(0,1.5fr)] sm:gap-x-4",
|
||||
align === "start" ? "sm:items-start" : "sm:items-center"
|
||||
)}
|
||||
>
|
||||
<FieldTitle
|
||||
className={cn(
|
||||
"text-muted-foreground flex min-w-0 items-center gap-1 text-sm font-normal",
|
||||
align === "start" && "sm:min-h-8"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
{hint ? <RowHint label={`${label} info`}>{hint}</RowHint> : null}
|
||||
</FieldTitle>
|
||||
|
||||
{renderEdit ? (
|
||||
<div
|
||||
className={cn(
|
||||
"relative col-start-1 row-start-2 min-h-8 min-w-0 sm:col-start-2 sm:row-start-1",
|
||||
align === "start" ? "items-start" : "items-center"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!editable || controlsDisabled || editing}
|
||||
aria-label={`Edit ${label}`}
|
||||
aria-hidden={editing}
|
||||
className={cn(
|
||||
"group/value flex w-full min-w-0 rounded-md border border-transparent px-2.5 text-left transition-[opacity,color,background-color] duration-150 outline-none",
|
||||
align === "start"
|
||||
? "h-auto min-h-8 items-start py-1"
|
||||
: "h-8 items-center",
|
||||
editable &&
|
||||
"hover:bg-muted/40 active:bg-muted/40 sm:group-hover/row:bg-muted/40 focus-visible:border-transparent! focus-visible:ring-0! focus-visible:outline-none!",
|
||||
controlsDisabled && "pointer-events-none",
|
||||
editing
|
||||
? "pointer-events-none absolute inset-x-0 top-0 opacity-0"
|
||||
: "relative opacity-100"
|
||||
)}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
align === "start" ? "items-start" : "items-center"
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{editable ? (
|
||||
<Item
|
||||
render={<span />}
|
||||
className={cn(
|
||||
"p-0",
|
||||
"text-muted-foreground ml-1.5 flex size-5 shrink-0 items-center justify-center opacity-100 transition-opacity sm:opacity-0 sm:group-hover/row:opacity-100 sm:group-focus-visible/value:opacity-100",
|
||||
controlsDisabled && "invisible opacity-0 sm:opacity-0"
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<PencilIcon className="size-3.5" aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={editRef}
|
||||
aria-hidden={!editing}
|
||||
inert={!editing ? true : undefined}
|
||||
className={cn(
|
||||
"min-w-0 transition-opacity duration-150",
|
||||
editing
|
||||
? "relative opacity-100"
|
||||
: "pointer-events-none absolute inset-x-0 top-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-input! box-border w-full has-[[data-slot=input-group-control]:focus-visible]:shadow-none! has-[[data-slot=input-group-control]:focus-visible]:ring-0!",
|
||||
align === "start" ? "h-auto! min-h-8! items-start" : "h-8"
|
||||
)}
|
||||
>
|
||||
{renderEdit(controlActive)}
|
||||
{editable ? (
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
className={cn(
|
||||
"gap-1 pr-2",
|
||||
align === "start" && "self-start pt-1"
|
||||
)}
|
||||
>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Discard ${label}`}
|
||||
disabled={editActionsDisabled}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<XIcon className="size-4" aria-hidden="true" />
|
||||
</InputGroupButton>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={saving ? `Saving ${label}` : `Save ${label}`}
|
||||
disabled={editActionsDisabled}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving ? (
|
||||
<Spinner className="size-3.5" />
|
||||
) : (
|
||||
<CheckIcon className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
) : null}
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-start-1 row-start-2 flex min-h-8 min-w-0 items-center px-2.5 sm:col-start-2 sm:row-start-1">
|
||||
{display}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
import { TicketDetailsSheet } from "./components/ticket-details-sheet"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-center justify-center p-6 sm:p-10 md:p-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Editable ticket details sheet
|
||||
</h1>
|
||||
<TicketDetailsSheet />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@authportal/ui/components/select"
|
||||
import { BULK_ROLE_OPTIONS, type MemberRole } from "./data"
|
||||
import { PencilIcon, SendIcon, PauseCircleIcon } from "lucide-react"
|
||||
|
||||
interface BulkActionBarProps {
|
||||
selectedCount: number
|
||||
roleValue: MemberRole
|
||||
onRoleChange: (value: MemberRole) => void
|
||||
onChangeRole: () => void
|
||||
onResendInvite: () => void
|
||||
onDeactivate: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function BulkActionBar({
|
||||
selectedCount,
|
||||
roleValue,
|
||||
onRoleChange,
|
||||
onChangeRole,
|
||||
onResendInvite,
|
||||
onDeactivate,
|
||||
onClear,
|
||||
}: BulkActionBarProps) {
|
||||
return (
|
||||
<div className="bg-muted/25 flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py) lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedCount} member{selectedCount === 1 ? "" : "s"} selected
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Change role, resend invites, or revoke access in one step.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={roleValue}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return
|
||||
onRoleChange(value as MemberRole)
|
||||
}}
|
||||
items={BULK_ROLE_OPTIONS}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[164px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{BULK_ROLE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onChangeRole}
|
||||
>
|
||||
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Change role
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onResendInvite}
|
||||
>
|
||||
<SendIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Resend invite
|
||||
</Button>
|
||||
|
||||
<Button type="button" size="sm" onClick={onDeactivate}>
|
||||
<PauseCircleIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Deactivate
|
||||
</Button>
|
||||
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
import { memo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Row, type ColumnDef } from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@authportal/ui/components/alert-dialog"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@authportal/ui/components/avatar"
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@authportal/ui/components/dropdown-menu"
|
||||
import { Skeleton } from "@authportal/ui/components/skeleton"
|
||||
import {
|
||||
MemberRole,
|
||||
MemberStatus,
|
||||
TEAM_LABELS,
|
||||
type AuthMethod,
|
||||
type IMember,
|
||||
type TeamLabel,
|
||||
type TwoFactor,
|
||||
} from "./data"
|
||||
import { ShieldCheckIcon, LockIcon, TriangleAlertIcon, KeyRoundIcon, MoreHorizontalIcon, EyeIcon, PencilIcon, SendIcon, PauseCircleIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
// ── Team tag colors (light + dark) ──
|
||||
|
||||
const teamBadgeClass: Record<TeamLabel, string> = {
|
||||
Engineering:
|
||||
"bg-indigo-100 text-indigo-800 dark:bg-indigo-950/50 dark:text-indigo-300",
|
||||
Product:
|
||||
"bg-violet-100 text-violet-800 dark:bg-violet-950/50 dark:text-violet-300",
|
||||
Design: "bg-sky-100 text-sky-800 dark:bg-sky-950/50 dark:text-sky-300",
|
||||
Sales:
|
||||
"bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300",
|
||||
Marketing:
|
||||
"bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300",
|
||||
"Customer Success":
|
||||
"bg-cyan-100 text-cyan-800 dark:bg-cyan-950/50 dark:text-cyan-300",
|
||||
Finance:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-950/50 dark:text-yellow-300",
|
||||
"IT/Security":
|
||||
"bg-rose-100 text-rose-800 dark:bg-rose-950/50 dark:text-rose-300",
|
||||
}
|
||||
|
||||
function getTeamClasses(tag: string): string {
|
||||
if (TEAM_LABELS.includes(tag as TeamLabel)) {
|
||||
return teamBadgeClass[tag as TeamLabel]
|
||||
}
|
||||
return "bg-muted text-muted-foreground"
|
||||
}
|
||||
|
||||
export const TeamTags = memo(function TeamTags({
|
||||
teams,
|
||||
}: {
|
||||
teams: TeamLabel[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{teams.map((team) => (
|
||||
<Badge
|
||||
key={team}
|
||||
variant="secondary"
|
||||
className={cn("border-0", getTeamClasses(team))}
|
||||
>
|
||||
{team}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Status badge ──
|
||||
|
||||
const statusConfig: Record<MemberStatus, { dot: string }> = {
|
||||
Active: { dot: "bg-emerald-500" },
|
||||
Invited: { dot: "bg-amber-500" },
|
||||
Suspended: { dot: "bg-red-500" },
|
||||
Deactivated: { dot: "bg-muted-foreground" },
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: MemberStatus }) {
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full!",
|
||||
statusConfig[status].dot
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Role badge ──
|
||||
|
||||
const roleConfig: Record<
|
||||
MemberRole,
|
||||
{ variant: React.ComponentProps<typeof Badge>["variant"] }
|
||||
> = {
|
||||
Owner: { variant: "primary-outline" },
|
||||
Admin: { variant: "info-outline" },
|
||||
Member: { variant: "secondary" },
|
||||
Billing: { variant: "warning-outline" },
|
||||
Guest: { variant: "outline" },
|
||||
"Support Agent": { variant: "secondary" },
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role: MemberRole }) {
|
||||
return <Badge variant={roleConfig[role].variant}>{role}</Badge>
|
||||
}
|
||||
|
||||
// ── Member cell (same layout as the donor ContactCell) ──
|
||||
|
||||
const MemberCell = memo(function MemberCell({ row }: { row: Row<IMember> }) {
|
||||
const o = row.original
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
<AvatarImage src={o.avatar} alt="" />
|
||||
<AvatarFallback>
|
||||
{o.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground line-clamp-1 font-medium">{o.name}</div>
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-1 text-xs"
|
||||
title={o.email}
|
||||
>
|
||||
{o.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Auth cell (SSO / 2FA badges) ──
|
||||
|
||||
const authBadgeVariant: Record<
|
||||
AuthMethod,
|
||||
React.ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
SSO: "success-outline",
|
||||
Password: "warning-outline",
|
||||
}
|
||||
|
||||
const twoFactorBadgeVariant: Record<
|
||||
TwoFactor,
|
||||
React.ComponentProps<typeof Badge>["variant"]
|
||||
> = {
|
||||
Authenticator: "info-outline",
|
||||
Passkey: "success-outline",
|
||||
"Security key": "success-outline",
|
||||
SMS: "warning-outline",
|
||||
}
|
||||
|
||||
function AuthMethodIcon({ auth }: { auth: AuthMethod }) {
|
||||
if (auth === "SSO") {
|
||||
return (
|
||||
<ShieldCheckIcon aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LockIcon aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
function TwoFactorIcon({ factor }: { factor: TwoFactor | null }) {
|
||||
if (!factor) {
|
||||
return (
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyRoundIcon aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
function AuthCell({ row }: { row: Row<IMember> }) {
|
||||
const { auth, ssoProvider, twoFactor } = row.original
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant={authBadgeVariant[auth]}>
|
||||
<AuthMethodIcon auth={auth} />
|
||||
{auth === "SSO" ? (ssoProvider ?? "SSO") : "Password"}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
twoFactor ? twoFactorBadgeVariant[twoFactor] : "destructive-outline"
|
||||
}
|
||||
>
|
||||
<TwoFactorIcon factor={twoFactor} />
|
||||
{twoFactor ?? "No 2FA"}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Actions cell ──
|
||||
|
||||
export function ActionsCell({
|
||||
row,
|
||||
onEditRole,
|
||||
onView,
|
||||
}: {
|
||||
row: Row<IMember>
|
||||
onEditRole: (member: IMember) => void
|
||||
onView: (member: IMember) => void
|
||||
}) {
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
const member = row.original
|
||||
|
||||
const handleRemoveConfirm = () => {
|
||||
setRemoveOpen(false)
|
||||
toast.success("Member removed", {
|
||||
description: `${member.name} loses access to Acme Cloud immediately.`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label="Row actions"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onView(member)}>
|
||||
<EyeIcon className="size-4" aria-hidden="true" />
|
||||
View Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEditRole(member)}>
|
||||
<PencilIcon className="size-4" aria-hidden="true" />
|
||||
Edit Role
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Invite resent", {
|
||||
description: `New link sent to ${member.email}. Expires in 7 days.`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SendIcon className="size-4" aria-hidden="true" />
|
||||
Resend Invite
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.message("Member suspended", {
|
||||
description: `${member.name} can no longer sign in until reinstated.`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<PauseCircleIcon className="size-4" aria-hidden="true" />
|
||||
Suspend
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setRemoveOpen(true)}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden="true" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={removeOpen} onOpenChange={setRemoveOpen}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove member?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This revokes access for{""}
|
||||
<span className="text-foreground font-medium">{member.name}</span>
|
||||
{""}
|
||||
and frees their seat. Connect your API to persist changes.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleRemoveConfirm}
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Column definitions ──
|
||||
|
||||
interface ColumnHandlers {
|
||||
onEditRole: (member: IMember) => void
|
||||
onView: (member: IMember) => void
|
||||
}
|
||||
|
||||
export function createMemberColumns({
|
||||
onEditRole,
|
||||
onView,
|
||||
}: ColumnHandlers): ColumnDef<IMember>[] {
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
size: 40,
|
||||
enableResizing: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
skeleton: <Skeleton className="mx-auto size-5" />,
|
||||
headerClassName:
|
||||
"[--data-grid-header-cell-ps:var(--frame-panel-header-px)]",
|
||||
cellClassName: "[--data-grid-body-cell-ps:var(--frame-panel-px)]",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Member"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <MemberCell row={row} />,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
minSize: 220,
|
||||
meta: {
|
||||
headerTitle: "Member",
|
||||
autoSize: true,
|
||||
skeleton: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Skeleton className="size-8 shrink-0 rounded-full" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
id: "role",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Role" visibility={true} column={column} />
|
||||
),
|
||||
cell: ({ row }) => <RoleBadge role={row.original.role} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Role",
|
||||
skeleton: <Skeleton className="h-6 w-16 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "teams",
|
||||
id: "teams",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Teams" visibility={true} column={column} />
|
||||
),
|
||||
cell: ({ row }) => <TeamTags teams={row.original.teams} />,
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Teams",
|
||||
skeleton: (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Status"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Status",
|
||||
skeleton: <Skeleton className="h-6 w-24 rounded-full" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "auth",
|
||||
id: "auth",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Auth" visibility={true} column={column} />
|
||||
),
|
||||
cell: ({ row }) => <AuthCell row={row} />,
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Auth",
|
||||
skeleton: (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lastActiveIso",
|
||||
id: "lastActive",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="Last Active"
|
||||
visibility={true}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{row.original.lastActive}
|
||||
</span>
|
||||
),
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Last Active",
|
||||
skeleton: <Skeleton className="h-4 w-28" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "seatLabel",
|
||||
id: "seat",
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader title="Seat" visibility={true} column={column} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground truncate text-sm font-medium">
|
||||
{row.original.seatLabel}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{row.original.provisioning}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size: 140,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
enableResizing: true,
|
||||
meta: {
|
||||
headerTitle: "Seat",
|
||||
skeleton: (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<ActionsCell row={row} onEditRole={onEditRole} onView={onView} />
|
||||
),
|
||||
size: 60,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: false,
|
||||
meta: {
|
||||
skeleton: <Skeleton className="mx-auto size-7 rounded-md" />,
|
||||
headerClassName:
|
||||
"[--data-grid-header-cell-pe:var(--frame-panel-header-px)]",
|
||||
cellClassName: "[--data-grid-body-cell-pe:var(--frame-panel-px)]",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
export type MemberStatus = "Active" | "Invited" | "Suspended" | "Deactivated"
|
||||
export type MemberRole =
|
||||
| "Owner"
|
||||
| "Admin"
|
||||
| "Member"
|
||||
| "Billing"
|
||||
| "Guest"
|
||||
| "Support Agent"
|
||||
export type AuthMethod = "SSO" | "Password"
|
||||
export type SsoProvider = "Okta" | "Microsoft Entra ID" | "Google Workspace"
|
||||
export type TwoFactor = "Authenticator" | "Passkey" | "Security key" | "SMS"
|
||||
export type Provisioning = "SCIM" | "JIT" | "Manual"
|
||||
export type Scope = "None" | "Read" | "Write" | "Admin"
|
||||
|
||||
/** Closed vocabulary for team tags (filters + badge colors). */
|
||||
export const TEAM_LABELS = [
|
||||
"Engineering",
|
||||
"Product",
|
||||
"Design",
|
||||
"Sales",
|
||||
"Marketing",
|
||||
"Customer Success",
|
||||
"Finance",
|
||||
"IT/Security",
|
||||
] as const
|
||||
|
||||
export type TeamLabel = (typeof TEAM_LABELS)[number]
|
||||
|
||||
export interface IMember {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
email: string
|
||||
role: MemberRole
|
||||
title: string
|
||||
scope: Scope
|
||||
teams: TeamLabel[]
|
||||
status: MemberStatus
|
||||
auth: AuthMethod
|
||||
ssoProvider: SsoProvider | null
|
||||
twoFactor: TwoFactor | null
|
||||
provisioning: Provisioning
|
||||
lastActive: string
|
||||
lastActiveIso: string
|
||||
seatLabel: string
|
||||
}
|
||||
|
||||
// ── Status + role order (filter + bulk options) ──
|
||||
|
||||
export const STATUS_ORDER: MemberStatus[] = [
|
||||
"Active",
|
||||
"Invited",
|
||||
"Suspended",
|
||||
"Deactivated",
|
||||
]
|
||||
|
||||
export const ROLE_ORDER: MemberRole[] = [
|
||||
"Owner",
|
||||
"Admin",
|
||||
"Member",
|
||||
"Billing",
|
||||
"Guest",
|
||||
"Support Agent",
|
||||
]
|
||||
|
||||
/** Roles offered in the bulk change-role control. */
|
||||
export const BULK_ROLE_OPTIONS: { value: MemberRole; label: string }[] = [
|
||||
{ value: "Member", label: "Member" },
|
||||
{ value: "Admin", label: "Admin" },
|
||||
{ value: "Billing", label: "Billing" },
|
||||
{ value: "Guest", label: "Guest" },
|
||||
{ value: "Support Agent", label: "Support Agent" },
|
||||
]
|
||||
|
||||
// ── Data (12 members) ──
|
||||
|
||||
export const MEMBERS: IMember[] = [
|
||||
{
|
||||
id: "usr_a1b2c3d4",
|
||||
name: "Mira Stone",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Owner",
|
||||
title: "Head of Product",
|
||||
scope: "Admin",
|
||||
teams: ["Product", "IT/Security"],
|
||||
status: "Active",
|
||||
auth: "SSO",
|
||||
ssoProvider: "Okta",
|
||||
twoFactor: "Passkey",
|
||||
provisioning: "SCIM",
|
||||
lastActive: "2 min ago",
|
||||
lastActiveIso: "2026-06-17T14:12:00Z",
|
||||
seatLabel: "Seat 1 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_b2c3d4e5",
|
||||
name: "Leo Grant",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Admin",
|
||||
title: "Eng Manager",
|
||||
scope: "Admin",
|
||||
teams: ["Engineering", "IT/Security"],
|
||||
status: "Active",
|
||||
auth: "SSO",
|
||||
ssoProvider: "Microsoft Entra ID",
|
||||
twoFactor: "Authenticator",
|
||||
provisioning: "SCIM",
|
||||
lastActive: "18 min ago",
|
||||
lastActiveIso: "2026-06-17T13:56:00Z",
|
||||
seatLabel: "Seat 4 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_c3d4e5f6",
|
||||
name: "Sarah Chen",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Admin",
|
||||
title: "Eng Manager",
|
||||
scope: "Admin",
|
||||
teams: ["Engineering"],
|
||||
status: "Active",
|
||||
auth: "SSO",
|
||||
ssoProvider: "Okta",
|
||||
twoFactor: "Security key",
|
||||
provisioning: "SCIM",
|
||||
lastActive: "1 hour ago",
|
||||
lastActiveIso: "2026-06-17T13:09:00Z",
|
||||
seatLabel: "Seat 7 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_d4e5f6a7",
|
||||
name: "Nora Vale",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Member",
|
||||
title: "Product Designer",
|
||||
scope: "Write",
|
||||
teams: ["Design", "Product"],
|
||||
status: "Active",
|
||||
auth: "SSO",
|
||||
ssoProvider: "Google Workspace",
|
||||
twoFactor: "Authenticator",
|
||||
provisioning: "JIT",
|
||||
lastActive: "3 hours ago",
|
||||
lastActiveIso: "2026-06-17T11:05:00Z",
|
||||
seatLabel: "Seat 12 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_e5f6a7b8",
|
||||
name: "Sana Qureshi",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Member",
|
||||
title: "Backend Engineer",
|
||||
scope: "Write",
|
||||
teams: ["Engineering"],
|
||||
status: "Active",
|
||||
auth: "SSO",
|
||||
ssoProvider: "Okta",
|
||||
twoFactor: "Authenticator",
|
||||
provisioning: "SCIM",
|
||||
lastActive: "5 hours ago",
|
||||
lastActiveIso: "2026-06-17T09:21:00Z",
|
||||
seatLabel: "Seat 18 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_f6a7b8c9",
|
||||
name: "David Kim",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Member",
|
||||
title: "DevOps",
|
||||
scope: "Write",
|
||||
teams: ["Engineering", "IT/Security"],
|
||||
status: "Active",
|
||||
auth: "SSO",
|
||||
ssoProvider: "Microsoft Entra ID",
|
||||
twoFactor: "Security key",
|
||||
provisioning: "SCIM",
|
||||
lastActive: "Yesterday",
|
||||
lastActiveIso: "2026-06-16T17:40:00Z",
|
||||
seatLabel: "Seat 23 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_a7b8c9d0",
|
||||
name: "Michael Rodriguez",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Member",
|
||||
title: "Account Executive",
|
||||
scope: "Read",
|
||||
teams: ["Sales"],
|
||||
status: "Active",
|
||||
auth: "Password",
|
||||
ssoProvider: null,
|
||||
twoFactor: "SMS",
|
||||
provisioning: "Manual",
|
||||
lastActive: "Yesterday",
|
||||
lastActiveIso: "2026-06-16T15:18:00Z",
|
||||
seatLabel: "Seat 31 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_b8c9d0e1",
|
||||
name: "Priya Patel",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1488426862026-3ee34a7d66df?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Billing",
|
||||
title: "Finance",
|
||||
scope: "Read",
|
||||
teams: ["Finance"],
|
||||
status: "Active",
|
||||
auth: "SSO",
|
||||
ssoProvider: "Google Workspace",
|
||||
twoFactor: "Authenticator",
|
||||
provisioning: "JIT",
|
||||
lastActive: "2 days ago",
|
||||
lastActiveIso: "2026-06-15T10:02:00Z",
|
||||
seatLabel: "Seat 38 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_c9d0e1f2",
|
||||
name: "Emma Wilson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Member",
|
||||
title: "Marketing",
|
||||
scope: "Read",
|
||||
teams: ["Marketing"],
|
||||
status: "Invited",
|
||||
auth: "Password",
|
||||
ssoProvider: null,
|
||||
twoFactor: null,
|
||||
provisioning: "Manual",
|
||||
lastActive: "Invite sent",
|
||||
lastActiveIso: "2026-06-14T09:30:00Z",
|
||||
seatLabel: "Pending seat",
|
||||
},
|
||||
{
|
||||
id: "usr_d0e1f2a3",
|
||||
name: "Omar Haddad",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Member",
|
||||
title: "Data Analyst",
|
||||
scope: "Read",
|
||||
teams: ["Product"],
|
||||
status: "Invited",
|
||||
auth: "Password",
|
||||
ssoProvider: null,
|
||||
twoFactor: null,
|
||||
provisioning: "Manual",
|
||||
lastActive: "Expires in 3 days",
|
||||
lastActiveIso: "2026-06-13T16:45:00Z",
|
||||
seatLabel: "Pending seat",
|
||||
},
|
||||
{
|
||||
id: "usr_e1f2a3b4",
|
||||
name: "Kenji Tan",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Guest",
|
||||
title: "Contractor",
|
||||
scope: "Read",
|
||||
teams: ["Design"],
|
||||
status: "Suspended",
|
||||
auth: "SSO",
|
||||
ssoProvider: "Google Workspace",
|
||||
twoFactor: "SMS",
|
||||
provisioning: "JIT",
|
||||
lastActive: "14 days ago",
|
||||
lastActiveIso: "2026-06-03T12:00:00Z",
|
||||
seatLabel: "Seat 52 of 80",
|
||||
},
|
||||
{
|
||||
id: "usr_f2a3b4c5",
|
||||
name: "Alex Johnson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
|
||||
email: "[email protected]",
|
||||
role: "Member",
|
||||
title: "QA Engineer",
|
||||
scope: "None",
|
||||
teams: ["Engineering"],
|
||||
status: "Deactivated",
|
||||
auth: "Password",
|
||||
ssoProvider: null,
|
||||
twoFactor: null,
|
||||
provisioning: "Manual",
|
||||
lastActive: "97 days ago",
|
||||
lastActiveIso: "2026-03-12T08:15:00Z",
|
||||
seatLabel: "Seat released",
|
||||
},
|
||||
]
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@authportal/ui/components/avatar"
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import { ScrollArea } from "@authportal/ui/components/scroll-area"
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@authportal/ui/components/sheet"
|
||||
import { RoleBadge, StatusBadge } from "./columns"
|
||||
import { type IMember } from "./data"
|
||||
import { XIcon, PencilIcon } from "lucide-react"
|
||||
|
||||
const mutedIconButtonClassName = "text-muted-foreground hover:text-foreground"
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-9 items-center justify-between gap-3 px-4 py-1">
|
||||
<span className="text-muted-foreground shrink-0 text-sm">{label}</span>
|
||||
<span className="flex min-w-0 items-center justify-end gap-1.5 text-sm font-medium">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemberDetailSheet({
|
||||
member,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
member: IMember | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="inset-y-4 right-4 left-auto h-[calc(100svh-2rem)] w-[min(30rem,calc(100vw-2rem))] max-w-none overflow-hidden rounded-xl p-0 outline-none"
|
||||
>
|
||||
{/* Header */}
|
||||
<SheetHeader className="shrink-0 p-0">
|
||||
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
||||
<SheetTitle className="min-w-0 truncate text-base font-semibold">
|
||||
Member Profile
|
||||
</SheetTitle>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close sheet"
|
||||
className={mutedIconButtonClassName}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Review role, teams, and authentication for this member.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
{member ? (
|
||||
<div className="flex min-h-full flex-col pb-6">
|
||||
<div className="flex items-center gap-3 px-4 py-5">
|
||||
<Avatar className="size-12 shrink-0">
|
||||
<AvatarImage src={member.avatar} alt="" />
|
||||
<AvatarFallback>
|
||||
{member.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="text-foreground truncate text-sm font-semibold">
|
||||
{member.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{member.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{member.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0 border-t pt-1">
|
||||
<DetailRow label="Role">
|
||||
<RoleBadge role={member.role} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Status">
|
||||
<StatusBadge status={member.status} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Permission scope">{member.scope}</DetailRow>
|
||||
<DetailRow label="Teams">
|
||||
<span className="truncate">{member.teams.join(", ")}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Sign-in">
|
||||
{member.auth === "SSO"
|
||||
? (member.ssoProvider ?? "SSO")
|
||||
: "Password"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Two-factor">
|
||||
{member.twoFactor ?? "Not enrolled"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Provisioning">
|
||||
{member.provisioning}
|
||||
</DetailRow>
|
||||
<DetailRow label="Seat">{member.seatLabel}</DetailRow>
|
||||
<DetailRow label="Last active">{member.lastActive}</DetailRow>
|
||||
<DetailRow label="Member ID">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{member.id}
|
||||
</span>
|
||||
</DetailRow>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<SheetFooter className="bg-background shrink-0 border-t">
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="min-w-0 flex-1"
|
||||
onClick={() => {
|
||||
if (!member) return
|
||||
toast.info("Role editor", {
|
||||
description: `Update ${member.name} from ${member.role}. Demo only.`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<PencilIcon aria-hidden="true" />
|
||||
Edit role
|
||||
</Button>
|
||||
<SheetClose
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { DataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { DataGridColumnVisibility } from "@/components/reui/data-grid/data-grid-column-visibility"
|
||||
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
|
||||
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
|
||||
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
createFilter,
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
} from "@/components/reui/filters"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import { Separator } from "@authportal/ui/components/separator"
|
||||
import { TooltipProvider } from "@authportal/ui/components/tooltip"
|
||||
import { BulkActionBar } from "./bulk-action-bar"
|
||||
import { createMemberColumns, RoleBadge, StatusBadge } from "./columns"
|
||||
import {
|
||||
MEMBERS,
|
||||
ROLE_ORDER,
|
||||
STATUS_ORDER,
|
||||
TEAM_LABELS,
|
||||
type IMember,
|
||||
type MemberRole,
|
||||
type MemberStatus,
|
||||
type TeamLabel,
|
||||
} from "./data"
|
||||
import { MemberDetailSheet } from "./member-detail-sheet"
|
||||
import { UserIcon, MailIcon, ShieldCheckIcon, CircleDotIcon, UsersIcon, UserPlusIcon, FilterIcon, FunnelXIcon, Settings2Icon } from "lucide-react"
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function getActiveFilters(filters: Filter[]) {
|
||||
return filters.filter((filter) => {
|
||||
const { values } = filter
|
||||
if (!values || values.length === 0) return false
|
||||
if (
|
||||
values.every((value) => typeof value === "string" && value.trim() === "")
|
||||
)
|
||||
return false
|
||||
if (values.every((value) => value === null || value === undefined))
|
||||
return false
|
||||
if (values.every((value) => Array.isArray(value) && value.length === 0))
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function serializeActiveFiltersKey(active: Filter[]) {
|
||||
return JSON.stringify(
|
||||
active.map((f) => ({
|
||||
field: f.field,
|
||||
operator: f.operator,
|
||||
values: f.values,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function filterFieldValue(item: IMember, field: string): unknown {
|
||||
if (field === "teams") return item.teams.join(" ")
|
||||
return item[field as keyof IMember]
|
||||
}
|
||||
|
||||
function applyFiltersToData(data: IMember[], filters: Filter[]): IMember[] {
|
||||
const active = getActiveFilters(filters)
|
||||
let result = [...data]
|
||||
active.forEach((filter) => {
|
||||
const { field, operator, values } = filter
|
||||
result = result.filter((item) => {
|
||||
if (field === "teams") {
|
||||
const selected = values.map(String)
|
||||
switch (operator) {
|
||||
case "is":
|
||||
return (
|
||||
selected.length > 0 &&
|
||||
item.teams.includes(selected[0] as TeamLabel)
|
||||
)
|
||||
case "is_not":
|
||||
return !selected.some((v) => item.teams.includes(v as TeamLabel))
|
||||
case "is_any_of":
|
||||
return selected.some((v) => item.teams.includes(v as TeamLabel))
|
||||
case "is_not_any_of":
|
||||
return !selected.some((v) => item.teams.includes(v as TeamLabel))
|
||||
case "contains": {
|
||||
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
item.teams.some((t) =>
|
||||
t.toLowerCase().includes(token.toLowerCase())
|
||||
)
|
||||
)
|
||||
}
|
||||
case "not_contains":
|
||||
return !values.some((v) =>
|
||||
item.teams.some((t) =>
|
||||
t.toLowerCase().includes(String(v).toLowerCase())
|
||||
)
|
||||
)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const raw = filterFieldValue(item, field)
|
||||
const fieldValue = raw != null ? raw : ""
|
||||
|
||||
switch (operator) {
|
||||
case "is":
|
||||
return values.includes(fieldValue)
|
||||
case "is_not":
|
||||
return !values.includes(fieldValue)
|
||||
case "is_any_of":
|
||||
return values.some((v) => fieldValue === v)
|
||||
case "is_not_any_of":
|
||||
return !values.some((v) => fieldValue === v)
|
||||
case "contains": {
|
||||
const tokens = values.map((v) => String(v).trim()).filter(Boolean)
|
||||
if (tokens.length === 0) return true
|
||||
return tokens.some((token) =>
|
||||
String(fieldValue).toLowerCase().includes(token.toLowerCase())
|
||||
)
|
||||
}
|
||||
case "not_contains":
|
||||
return !values.some((v) =>
|
||||
String(fieldValue).toLowerCase().includes(String(v).toLowerCase())
|
||||
)
|
||||
case "starts_with":
|
||||
return values.some((v) =>
|
||||
String(fieldValue).toLowerCase().startsWith(String(v).toLowerCase())
|
||||
)
|
||||
case "ends_with":
|
||||
return values.some((v) =>
|
||||
String(fieldValue).toLowerCase().endsWith(String(v).toLowerCase())
|
||||
)
|
||||
case "empty":
|
||||
return fieldValue === "" || fieldValue == null
|
||||
case "not_empty":
|
||||
return fieldValue !== "" && fieldValue != null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: { value: MemberStatus; label: string }[] =
|
||||
STATUS_ORDER.map((status) => ({ value: status, label: status }))
|
||||
|
||||
const ROLE_OPTIONS: { value: MemberRole; label: string }[] = ROLE_ORDER.map(
|
||||
(role) => ({ value: role, label: role })
|
||||
)
|
||||
|
||||
function renderSelectedCount(values: unknown[]) {
|
||||
if (values.length === 0) return "Select..."
|
||||
if (values.length > 1) return `${values.length} selected`
|
||||
return null
|
||||
}
|
||||
|
||||
function createDefaultMemberFilters(): Filter[] {
|
||||
return [createFilter("name", "contains", [""])]
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return (
|
||||
<span
|
||||
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
|
||||
export function MembersGrid() {
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "name", desc: false },
|
||||
])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
seat: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [filters, setFilters] = useState<Filter[]>(createDefaultMemberFilters)
|
||||
const [bulkRole, setBulkRole] = useState<MemberRole>("Member")
|
||||
|
||||
const [activeMember, setActiveMember] = useState<IMember | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [filteredData, setFilteredData] = useState<IMember[]>(MEMBERS)
|
||||
const isInitialLoad = useRef(true)
|
||||
const lastAppliedActiveKey = useRef<string>(
|
||||
serializeActiveFiltersKey(getActiveFilters(createDefaultMemberFilters()))
|
||||
)
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
label: "Member",
|
||||
icon: (
|
||||
<UserIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "text",
|
||||
className: "w-44",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "Email",
|
||||
icon: (
|
||||
<MailIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "text",
|
||||
className: "w-48",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
{
|
||||
key: "role",
|
||||
label: "Role",
|
||||
icon: (
|
||||
<ShieldCheckIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[160px]",
|
||||
options: ROLE_OPTIONS,
|
||||
customValueRenderer: (values) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
return <RoleBadge role={values[0] as MemberRole} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
icon: (
|
||||
<CircleDotIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: false,
|
||||
className: "w-[150px]",
|
||||
options: STATUS_OPTIONS,
|
||||
customValueRenderer: (values) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
return <StatusBadge status={values[0] as MemberStatus} />
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
label: "Team",
|
||||
icon: (
|
||||
<UsersIcon className="size-3.5" aria-hidden />
|
||||
),
|
||||
type: "select",
|
||||
searchable: true,
|
||||
className: "w-[190px]",
|
||||
options: TEAM_LABELS.map((team) => ({
|
||||
value: team,
|
||||
label: team,
|
||||
})),
|
||||
customValueRenderer: (values) => {
|
||||
const state = renderSelectedCount(values)
|
||||
if (state) return state
|
||||
|
||||
return String(values[0])
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const applyFilters = useCallback((newFilters: Filter[]) => {
|
||||
return applyFiltersToData(MEMBERS, newFilters)
|
||||
}, [])
|
||||
|
||||
const simulateAsyncFiltering = useCallback(
|
||||
async (newFilters: Filter[]) => {
|
||||
setIsLoading(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
setFilteredData(applyFilters(newFilters))
|
||||
setIsLoading(false)
|
||||
},
|
||||
[applyFilters]
|
||||
)
|
||||
|
||||
const handleFiltersChange = useCallback(
|
||||
(newFilters: Filter[]) => {
|
||||
setFilters(newFilters)
|
||||
const newActive = getActiveFilters(newFilters)
|
||||
const nextKey = serializeActiveFiltersKey(newActive)
|
||||
if (nextKey === lastAppliedActiveKey.current) return
|
||||
lastAppliedActiveKey.current = nextKey
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
|
||||
setRowSelection({})
|
||||
simulateAsyncFiltering(newFilters)
|
||||
},
|
||||
[simulateAsyncFiltering]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoad.current) {
|
||||
setFilteredData(applyFilters(filters))
|
||||
isInitialLoad.current = false
|
||||
}
|
||||
}, [filters, applyFilters])
|
||||
|
||||
const handleView = useCallback((member: IMember) => {
|
||||
setActiveMember(member)
|
||||
setSheetOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleEditRole = useCallback((member: IMember) => {
|
||||
toast.info("Role editor", {
|
||||
description: `Update ${member.name} from ${member.role}. Demo only.`,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createMemberColumns({ onEditRole: handleEditRole, onView: handleView }),
|
||||
[handleEditRole, handleView]
|
||||
)
|
||||
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(
|
||||
columns.map((c) => c.id as string)
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: filteredData,
|
||||
pageCount: Math.ceil(filteredData.length / pagination.pageSize),
|
||||
getRowId: (row) => row.id,
|
||||
state: {
|
||||
pagination,
|
||||
sorting,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
columnResizeMode: "onChange",
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
const selectedCount = table.getSelectedRowModel().rows.length
|
||||
|
||||
const handleClearSelection = useCallback(() => setRowSelection({}), [])
|
||||
|
||||
const handleChangeRole = useCallback(() => {
|
||||
if (selectedCount === 0) return
|
||||
setRowSelection({})
|
||||
toast.success("Role updated", {
|
||||
description: `${selectedCount} member${selectedCount === 1 ? "" : "s"} moved to ${bulkRole}.`,
|
||||
})
|
||||
}, [bulkRole, selectedCount])
|
||||
|
||||
const handleBulkResend = useCallback(() => {
|
||||
if (selectedCount === 0) return
|
||||
setRowSelection({})
|
||||
toast.info("Invites resent", {
|
||||
description: `${selectedCount} link${selectedCount === 1 ? "" : "s"} sent. Each expires in 7 days.`,
|
||||
})
|
||||
}, [selectedCount])
|
||||
|
||||
const handleBulkDeactivate = useCallback(() => {
|
||||
if (selectedCount === 0) return
|
||||
setRowSelection({})
|
||||
toast.message("Members deactivated", {
|
||||
description: `${selectedCount} member${selectedCount === 1 ? "" : "s"} can no longer sign in.`,
|
||||
})
|
||||
}, [selectedCount])
|
||||
|
||||
const showClearButton = filters.length > 0
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={200}>
|
||||
{/* Table */}
|
||||
<DataGrid
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
recordCount={filteredData.length}
|
||||
emptyMessage={
|
||||
!isLoading && filteredData.length === 0
|
||||
? "No members match your filters. Clear filters or adjust operators."
|
||||
: undefined
|
||||
}
|
||||
tableLayout={{
|
||||
columnsResizable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
headerSticky: true,
|
||||
dense: true,
|
||||
}}
|
||||
>
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FrameTitle id="page-heading" className="text-balance">
|
||||
Members
|
||||
</FrameTitle>
|
||||
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
|
||||
<span>68 of 80 seats</span>
|
||||
<DotSeparator />
|
||||
<span>5 pending invites</span>
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
onClick={() =>
|
||||
toast.info("Invite people", {
|
||||
description: "Send to acmecloud.com addresses. Demo only.",
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
Invite people
|
||||
</Button>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0 shadow-none">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
variant="outline"
|
||||
aria-label="Filters"
|
||||
>
|
||||
<FilterIcon aria-hidden />
|
||||
Filters
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
const next = createDefaultMemberFilters()
|
||||
lastAppliedActiveKey.current = serializeActiveFiltersKey(
|
||||
getActiveFilters(next)
|
||||
)
|
||||
setFilters(next)
|
||||
setRowSelection({})
|
||||
simulateAsyncFiltering(next)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FunnelXIcon aria-hidden />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
variant="outline"
|
||||
aria-label="View settings"
|
||||
>
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
View settings
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<BulkActionBar
|
||||
selectedCount={selectedCount}
|
||||
roleValue={bulkRole}
|
||||
onRoleChange={setBulkRole}
|
||||
onChangeRole={handleChangeRole}
|
||||
onResendInvite={handleBulkResend}
|
||||
onDeactivate={handleBulkDeactivate}
|
||||
onClear={handleClearSelection}
|
||||
/>
|
||||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
<DataGridScrollArea>
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
<Separator />
|
||||
<FrameFooter>
|
||||
<DataGridPagination sizes={[10, 20, 30]} />
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
|
||||
<MemberDetailSheet
|
||||
member={activeMember}
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { MembersGrid as DataGridView }
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { MembersGrid } from "./components/members-grid"
|
||||
|
||||
export function Page() {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
|
||||
useEffect(() => setIsReady(true), [])
|
||||
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-svh w-full max-w-7xl items-start justify-center p-8 pt-12"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Members directory data grid
|
||||
</h1>
|
||||
{isReady ? (
|
||||
<MembersGrid />
|
||||
) : (
|
||||
<div className="bg-background min-h-svh w-full" aria-hidden="true" />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@authportal/ui/components/avatar"
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@authportal/ui/components/collapsible"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@authportal/ui/components/empty"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@authportal/ui/components/select"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@authportal/ui/components/tabs"
|
||||
import {
|
||||
AUDIT_DAYS,
|
||||
FILTER_OPTIONS,
|
||||
RANGE_OPTIONS,
|
||||
severityDotClass,
|
||||
severityLabel,
|
||||
severityVariant,
|
||||
type AuditEvent,
|
||||
type EventType,
|
||||
} from "./data"
|
||||
import { ChevronRightIcon, CopyIcon, CalendarIcon, DownloadIcon, FilterIcon } from "lucide-react"
|
||||
|
||||
const TOTAL_EVENTS = AUDIT_DAYS.reduce((sum, day) => sum + day.events.length, 0)
|
||||
|
||||
function copyValue(value: string) {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(value).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single audit event row (reuses timeline-1 Collapsible-in-Frame grammar) ──
|
||||
function EventRow({
|
||||
event,
|
||||
isLast,
|
||||
step,
|
||||
defaultOpen,
|
||||
}: {
|
||||
event: AuditEvent
|
||||
isLast: boolean
|
||||
step: number
|
||||
defaultOpen: boolean
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<TimelineItem step={step} className={cn("ms-10", isLast ? "pb-0" : "pb-6")}>
|
||||
<TimelineHeader className="flex min-w-0 items-center justify-between gap-2.5">
|
||||
<TimelineSeparator className="bg-border! group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.5rem)] group-data-[orientation=vertical]/timeline:translate-y-7" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TimelineTitle className="text-sm font-semibold">
|
||||
{event.action}
|
||||
</TimelineTitle>
|
||||
<Badge variant={severityVariant[event.severity]} className="gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
severityDotClass[event.severity]
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{severityLabel[event.severity]}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">{event.time}</span>
|
||||
</div>
|
||||
<TimelineIndicator className="border-border bg-background text-muted-foreground flex size-6 items-center justify-center border shadow-xs group-data-[orientation=vertical]/timeline:-left-7 [&_svg]:size-3.5">
|
||||
{event.icon}
|
||||
</TimelineIndicator>
|
||||
</TimelineHeader>
|
||||
|
||||
<TimelineContent className="mt-2">
|
||||
<Frame stacked dense spacing="sm">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => setOpen(nextOpen)}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
type="button"
|
||||
className="flex w-full"
|
||||
aria-label={`Toggle ${event.action} details`}
|
||||
>
|
||||
<FrameHeader className="flex grow flex-row items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage
|
||||
src={event.actor.avatar}
|
||||
alt={event.actor.name}
|
||||
/>
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{event.actor.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{event.actor.name}, {event.label}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-open/collapsible:rotate-90" aria-hidden="true" />
|
||||
</FrameHeader>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<FramePanel className="space-y-3">
|
||||
<dl className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||
<DetailRow label="Target">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.target}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Actor">
|
||||
<span className="text-foreground truncate font-medium">
|
||||
{event.actor.email}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Source IP">
|
||||
<span className="text-foreground inline-flex min-w-0 items-center gap-2 font-medium tabular-nums">
|
||||
<span className="truncate">{event.ip}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{event.location}
|
||||
</span>
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Session">
|
||||
<span className="text-foreground truncate font-mono text-xs">
|
||||
{event.detail.sessionId}
|
||||
</span>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
{event.detail.reason}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2.5 border-t pt-2.5">
|
||||
<Badge variant="outline" className="gap-1.5 font-mono">
|
||||
{event.ref}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
copyValue(event.ref)
|
||||
toast.success("Reference copied", {
|
||||
description: `${event.ref} is on your clipboard.`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="opacity-60" aria-hidden="true" />
|
||||
Copy reference
|
||||
</Button>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Frame>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="flex min-w-0 items-center text-sm">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuditLogTimeline() {
|
||||
const [filter, setFilter] = React.useState<string[]>(["All"])
|
||||
const [range, setRange] = React.useState("24h")
|
||||
|
||||
const activeFilter = (filter[0] ?? "All") as EventType | "All"
|
||||
|
||||
const visibleDays = React.useMemo(() => {
|
||||
if (activeFilter === "All") return AUDIT_DAYS
|
||||
return AUDIT_DAYS.map((day) => ({
|
||||
...day,
|
||||
events: day.events.filter((event) => event.type === activeFilter),
|
||||
})).filter((day) => day.events.length > 0)
|
||||
}, [activeFilter])
|
||||
|
||||
const visibleCount = visibleDays.reduce(
|
||||
(sum, day) => sum + day.events.length,
|
||||
0
|
||||
)
|
||||
|
||||
const handleExport = () => {
|
||||
toast.success("Export ready", {
|
||||
description: `${visibleCount} events queued as CSV. Link valid for 24 hours.`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mx-auto w-full max-w-2xl"
|
||||
aria-labelledby="audit-log-title"
|
||||
>
|
||||
{/* ── Content header (title + filter chips + range + export) ── */}
|
||||
<div className="mb-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1
|
||||
id="audit-log-title"
|
||||
className="text-xl font-semibold tracking-tight"
|
||||
>
|
||||
Audit Log
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-5">
|
||||
{TOTAL_EVENTS} events in Acme Cloud workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(value) => value && setRange(value)}
|
||||
items={RANGE_OPTIONS}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<CalendarIcon className="text-muted-foreground size-4" aria-hidden="true" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button size="sm" type="button" onClick={handleExport}>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span className="hidden sm:block">Export CSV</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeFilter}
|
||||
onValueChange={(value) => value && setFilter([value])}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
aria-label="Filter by event type"
|
||||
className="h-10! w-full justify-start gap-6 overflow-x-auto border-b"
|
||||
>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="px-1 text-sm after:-bottom-px!"
|
||||
>
|
||||
{option.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{visibleDays.length === 0 ? (
|
||||
<Empty className="min-h-[280px] border-0 bg-transparent">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FilterIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No {activeFilter} events</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
No {activeFilter} events in the last 24 hours. Try another type or
|
||||
widen the range.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setFilter(["All"])}
|
||||
>
|
||||
Clear filter
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{visibleDays.map((day) => (
|
||||
<div key={day.id} className="space-y-4">
|
||||
<h2 className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{day.date}
|
||||
</h2>
|
||||
<Timeline>
|
||||
{day.events.map((event, index) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
step={index + 1}
|
||||
isLast={index === day.events.length - 1}
|
||||
defaultOpen={day.id === 1 && index < 2}
|
||||
/>
|
||||
))}
|
||||
</Timeline>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
import type { BadgeProps } from "@/components/reui/badge"
|
||||
import { CircleCheckIcon, TriangleAlertIcon, ArrowLeftRightIcon, MailIcon, ShieldCheckIcon, UsersIcon, RefreshCwIcon, LogOutIcon, KeyRoundIcon, DatabaseIcon } from "lucide-react"
|
||||
|
||||
// ── Audit log world (Acme Cloud workspace) ──
|
||||
// Severity drives the timeline indicator + the inline severity badge. Event
|
||||
// type drives the filter chips. Each event carries an actor (avatar + email +
|
||||
// IP), a target, and an expandable detail block (session id, reason).
|
||||
|
||||
export type EventSeverity = "info" | "notice" | "critical"
|
||||
|
||||
export type EventType = "Auth" | "Roles" | "SSO/SCIM" | "Sessions" | "API"
|
||||
|
||||
export type AuditActor = {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
export type AuditEvent = {
|
||||
id: string
|
||||
ref: string
|
||||
type: EventType
|
||||
action: string
|
||||
label: string
|
||||
target: string
|
||||
severity: EventSeverity
|
||||
time: string
|
||||
actor: AuditActor
|
||||
ip: string
|
||||
location: string
|
||||
icon: React.ReactNode
|
||||
detail: { sessionId: string; reason: string }
|
||||
}
|
||||
|
||||
export type AuditDay = {
|
||||
id: number
|
||||
date: string
|
||||
events: AuditEvent[]
|
||||
}
|
||||
|
||||
export type FilterOption = { value: EventType | "All"; label: string }
|
||||
|
||||
export type RangeOption = { value: string; label: string }
|
||||
|
||||
// ── Filter chips (event-type) ──
|
||||
export const FILTER_OPTIONS: FilterOption[] = [
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "Auth", label: "Auth" },
|
||||
{ value: "Roles", label: "Roles" },
|
||||
{ value: "SSO/SCIM", label: "SSO/SCIM" },
|
||||
{ value: "Sessions", label: "Sessions" },
|
||||
{ value: "API", label: "API" },
|
||||
]
|
||||
|
||||
// ── Date-range select ──
|
||||
export const RANGE_OPTIONS: RangeOption[] = [
|
||||
{ value: "24h", label: "Last 24 hours" },
|
||||
{ value: "7d", label: "Last 7 days" },
|
||||
{ value: "30d", label: "Last 30 days" },
|
||||
{ value: "90d", label: "Last 90 days" },
|
||||
]
|
||||
|
||||
// ── Severity → badge variant + indicator dot ──
|
||||
export const severityVariant: Record<EventSeverity, BadgeProps["variant"]> = {
|
||||
info: "success-outline",
|
||||
notice: "warning-outline",
|
||||
critical: "destructive-outline",
|
||||
}
|
||||
|
||||
export const severityLabel: Record<EventSeverity, string> = {
|
||||
info: "Info",
|
||||
notice: "Notice",
|
||||
critical: "Critical",
|
||||
}
|
||||
|
||||
export const severityDotClass: Record<EventSeverity, string> = {
|
||||
info: "bg-success",
|
||||
notice: "bg-warning",
|
||||
critical: "bg-destructive",
|
||||
}
|
||||
|
||||
const MIRA: AuditActor = {
|
||||
name: "Mira Stone",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
||||
initials: "MS",
|
||||
}
|
||||
const LEO: AuditActor = {
|
||||
name: "Leo Grant",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "LG",
|
||||
}
|
||||
const SANA: AuditActor = {
|
||||
name: "Sana Qureshi",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80",
|
||||
initials: "SQ",
|
||||
}
|
||||
const SARAH: AuditActor = {
|
||||
name: "Sarah Chen",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
||||
initials: "SC",
|
||||
}
|
||||
const DAVID: AuditActor = {
|
||||
name: "David Kim",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
|
||||
initials: "DK",
|
||||
}
|
||||
const KENJI: AuditActor = {
|
||||
name: "Kenji Tan",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=96&h=96&dpr=2&q=80",
|
||||
initials: "KT",
|
||||
}
|
||||
const OMAR: AuditActor = {
|
||||
name: "Omar Haddad",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507591064344-4c6ce005b128?w=96&h=96&dpr=2&q=80",
|
||||
initials: "OH",
|
||||
}
|
||||
const NORA: AuditActor = {
|
||||
name: "Nora Vale",
|
||||
email: "[email protected]",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&dpr=2&q=80",
|
||||
initials: "NV",
|
||||
}
|
||||
|
||||
const authIcon = (
|
||||
<CircleCheckIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const authFailIcon = (
|
||||
<TriangleAlertIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const roleIcon = (
|
||||
<ArrowLeftRightIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const inviteIcon = (
|
||||
<MailIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const ssoIcon = (
|
||||
<ShieldCheckIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const scimIcon = (
|
||||
<UsersIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const mfaIcon = (
|
||||
<RefreshCwIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const sessionIcon = (
|
||||
<LogOutIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const apiIcon = (
|
||||
<KeyRoundIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
const exportIcon = (
|
||||
<DatabaseIcon className="size-3.5" aria-hidden="true" />
|
||||
)
|
||||
|
||||
// ── Audit events grouped by day (newest first) ──
|
||||
export const AUDIT_DAYS: AuditDay[] = [
|
||||
{
|
||||
id: 1,
|
||||
date: "Today, Jun 17",
|
||||
events: [
|
||||
{
|
||||
id: "e1",
|
||||
ref: "evt_9f3a21c8",
|
||||
type: "Auth",
|
||||
action: "Login failed",
|
||||
label: "Password rejected",
|
||||
target: "[email protected]",
|
||||
severity: "critical",
|
||||
time: "2:14 PM",
|
||||
actor: KENJI,
|
||||
ip: "192.0.2.51",
|
||||
location: "Berlin",
|
||||
icon: authFailIcon,
|
||||
detail: {
|
||||
sessionId: "sess_b71e0d44",
|
||||
reason: "3 failed attempts in 5 minutes, account temporarily locked",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
ref: "evt_71b0a9d2",
|
||||
type: "Roles",
|
||||
action: "Role changed",
|
||||
label: "Member to Admin",
|
||||
target: "Sana Qureshi",
|
||||
severity: "notice",
|
||||
time: "1:02 PM",
|
||||
actor: LEO,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: roleIcon,
|
||||
detail: {
|
||||
sessionId: "sess_c98a2f10",
|
||||
reason: "Promotion approved by Mira Stone, scope raised to Write",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e3",
|
||||
ref: "evt_4c2d80ae",
|
||||
type: "Sessions",
|
||||
action: "Session revoked",
|
||||
label: "Chrome on Windows",
|
||||
target: "David Kim",
|
||||
severity: "notice",
|
||||
time: "11:48 AM",
|
||||
actor: SARAH,
|
||||
ip: "192.0.2.22",
|
||||
location: "Seattle",
|
||||
icon: sessionIcon,
|
||||
detail: {
|
||||
sessionId: "sess_5d1c6b09",
|
||||
reason: "Revoked from a stale device, last active 14 days ago",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e4",
|
||||
ref: "evt_2a6f13bb",
|
||||
type: "Auth",
|
||||
action: "Login success",
|
||||
label: "SSO via Okta",
|
||||
target: "[email protected]",
|
||||
severity: "info",
|
||||
time: "9:05 AM",
|
||||
actor: MIRA,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: authIcon,
|
||||
detail: {
|
||||
sessionId: "sess_a02d7e58",
|
||||
reason: "Passkey verified, session valid for 12 hours",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: "Yesterday, Jun 16",
|
||||
events: [
|
||||
{
|
||||
id: "e5",
|
||||
ref: "evt_88e1c5f0",
|
||||
type: "API",
|
||||
action: "API key created",
|
||||
label: "Production, ci-deploy",
|
||||
target: "key_3f9a...c712",
|
||||
severity: "notice",
|
||||
time: "6:21 PM",
|
||||
actor: DAVID,
|
||||
ip: "192.0.2.31",
|
||||
location: "Seattle",
|
||||
icon: apiIcon,
|
||||
detail: {
|
||||
sessionId: "sess_7b40e1aa",
|
||||
reason: "Scopes: deployments:write, logs:read, expires in 90 days",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e6",
|
||||
ref: "evt_15d7a3e9",
|
||||
type: "SSO/SCIM",
|
||||
action: "SSO config changed",
|
||||
label: "Okta to Microsoft Entra ID",
|
||||
target: "Acme Cloud workspace",
|
||||
severity: "critical",
|
||||
time: "4:37 PM",
|
||||
actor: MIRA,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: ssoIcon,
|
||||
detail: {
|
||||
sessionId: "sess_e21f9c03",
|
||||
reason: "Default identity provider switched, 68 members affected",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e7",
|
||||
ref: "evt_6b094d27",
|
||||
type: "SSO/SCIM",
|
||||
action: "SCIM provision",
|
||||
label: "4 members imported",
|
||||
target: "Engineering team",
|
||||
severity: "info",
|
||||
time: "4:30 PM",
|
||||
actor: LEO,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: scimIcon,
|
||||
detail: {
|
||||
sessionId: "sess_d4c7b210",
|
||||
reason: "JIT provisioning from Entra ID, 80 of 80 seats reconciled",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e8",
|
||||
ref: "evt_33a8e0c1",
|
||||
type: "Auth",
|
||||
action: "MFA reset",
|
||||
label: "Authenticator re-enrolled",
|
||||
target: "Omar Haddad",
|
||||
severity: "notice",
|
||||
time: "2:10 PM",
|
||||
actor: SARAH,
|
||||
ip: "192.0.2.22",
|
||||
location: "Seattle",
|
||||
icon: mfaIcon,
|
||||
detail: {
|
||||
sessionId: "sess_9f0b2d6e",
|
||||
reason: "Lost device reported, TOTP factor reset by admin",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e9",
|
||||
ref: "evt_07c4f2a5",
|
||||
type: "Roles",
|
||||
action: "Member invited",
|
||||
label: "Guest, Support Agent",
|
||||
target: "[email protected]",
|
||||
severity: "info",
|
||||
time: "10:55 AM",
|
||||
actor: MIRA,
|
||||
ip: "192.0.2.14",
|
||||
location: "San Francisco",
|
||||
icon: inviteIcon,
|
||||
detail: {
|
||||
sessionId: "sess_1ab39e7c",
|
||||
reason: "Invite expires in 7 days, scope set to Read",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: "Jun 15",
|
||||
events: [
|
||||
{
|
||||
id: "e10",
|
||||
ref: "evt_5e2b9114",
|
||||
type: "API",
|
||||
action: "Data export",
|
||||
label: "Audit log, CSV",
|
||||
target: "8,420 events",
|
||||
severity: "notice",
|
||||
time: "5:42 PM",
|
||||
actor: OMAR,
|
||||
ip: "192.0.2.40",
|
||||
location: "Toronto",
|
||||
icon: exportIcon,
|
||||
detail: {
|
||||
sessionId: "sess_4c8d1f93",
|
||||
reason: "Export covered 90 days, download link valid for 24 hours",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e11",
|
||||
ref: "evt_9012ad6f",
|
||||
type: "Sessions",
|
||||
action: "Session revoked",
|
||||
label: "Safari on iOS",
|
||||
target: "Nora Vale",
|
||||
severity: "info",
|
||||
time: "3:18 PM",
|
||||
actor: NORA,
|
||||
ip: "192.0.2.47",
|
||||
location: "Austin",
|
||||
icon: sessionIcon,
|
||||
detail: {
|
||||
sessionId: "sess_2f7a0c61",
|
||||
reason: "Signed out of all other devices from account settings",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e12",
|
||||
ref: "evt_a4f60b38",
|
||||
type: "Auth",
|
||||
action: "Login success",
|
||||
label: "Password, 2FA passed",
|
||||
target: "[email protected]",
|
||||
severity: "info",
|
||||
time: "8:47 AM",
|
||||
actor: SANA,
|
||||
ip: "192.0.2.33",
|
||||
location: "London",
|
||||
icon: authIcon,
|
||||
detail: {
|
||||
sessionId: "sess_88be4d02",
|
||||
reason: "Security key verified, new device added to trusted list",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AuditLogTimeline } from "./components/audit-log-timeline"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-8 md:p-12">
|
||||
<AuditLogTimeline />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* ReUI Empty + IconStack — Frame-friendly (empty-state-14 / empty-state-12).
|
||||
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
|
||||
* Docs: https://reui.io/docs/components/base/icon-stack
|
||||
*/
|
||||
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||
import { IconStack } from '@/components/reui/icon-stack'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@authportal/ui/components/empty'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
stackedIcon?: boolean
|
||||
centered?: boolean
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon = InboxIcon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
stackedIcon = true,
|
||||
centered = true,
|
||||
}: EmptyStateProps) {
|
||||
const body = (
|
||||
<Empty
|
||||
className={cn(
|
||||
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||
!centered && className,
|
||||
)}
|
||||
>
|
||||
<EmptyHeader className="gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
{stackedIcon ? (
|
||||
<IconStack aria-hidden="true" className="h-14 w-12">
|
||||
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
||||
</IconStack>
|
||||
) : (
|
||||
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</EmptyMedia>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
|
||||
{description ? (
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
|
||||
) : null}
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
{action ? (
|
||||
<EmptyContent className="mt-1 items-center justify-center">{action}</EmptyContent>
|
||||
) : null}
|
||||
</Empty>
|
||||
)
|
||||
|
||||
if (!centered) return body
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-1 items-center justify-center py-14 sm:py-16',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import { ensureAuthConfig, setToken } from '@/lib/auth'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { login, meQueryKey } from '@/queries/auth'
|
||||
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
|
||||
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
|
||||
import { AuthLogo } from '@/components/auth-logo'
|
||||
|
||||
export function PortalLoginForm() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import { GripHorizontalIcon } from "lucide-react"
|
||||
|
||||
// Context to share sortable listeners from row to handle
|
||||
type SortableContextValue = ReturnType<typeof useSortable>
|
||||
const SortableRowContext = createContext<Pick<
|
||||
SortableContextValue,
|
||||
"attributes" | "listeners"
|
||||
> | null>(null)
|
||||
|
||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
const context = useContext(SortableRowContext)
|
||||
|
||||
if (!context) {
|
||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
const {
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: "relative",
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRows<TData>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRow) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingRow])
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableContainer: Modifier = ({
|
||||
transform,
|
||||
draggingNodeRect,
|
||||
}) => {
|
||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
||||
return transform
|
||||
}
|
||||
|
||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
||||
const { x, y } = transform
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left
|
||||
const maxX = containerRect.right - draggingNodeRect.right
|
||||
const minY = containerRect.top - draggingNodeRect.top
|
||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingRow(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingRow(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={tableContainerRef}
|
||||
className={
|
||||
isDraggingRow
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
@@ -1,319 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
Modifier,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
Cell,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@authportal/ui/components/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
function DataGridTableDndHeader<TData>({
|
||||
header,
|
||||
}: {
|
||||
header: Header<TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Check if column ordering is enabled for this column
|
||||
const canOrder =
|
||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
||||
.enableColumnOrdering !== false
|
||||
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
||||
: header.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
dndStyle={style}
|
||||
dndRef={setNodeRef}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-0.5">
|
||||
{canOrder && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="grow">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</div>
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||
const { props } = useDataGrid()
|
||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||
id: cell.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
||||
: cell.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDnd<TData>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingColumn) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableBounds: Modifier = ({
|
||||
draggingNodeRect,
|
||||
transform,
|
||||
}) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToTableBounds]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingColumn(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={containerRef}
|
||||
className={
|
||||
isDraggingColumn
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => (
|
||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDnd }
|
||||
@@ -1,597 +0,0 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
getDataGridTableRowSections,
|
||||
getPinningStyles,
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
VirtualItem,
|
||||
Virtualizer,
|
||||
VirtualizerOptions,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
import { cn } from "@authportal/ui/lib/utils"
|
||||
import { Spinner } from "@authportal/ui/components/spinner"
|
||||
|
||||
type DataGridTableVirtualScrollElements = {
|
||||
containerElement: HTMLDivElement | null
|
||||
scrollElement: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
HTMLElement,
|
||||
HTMLTableRowElement
|
||||
>
|
||||
|
||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||
> & {
|
||||
estimateSize?: (index: number, row: Row<TData>) => number
|
||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
||||
getScrollElement?: (
|
||||
elements: DataGridTableVirtualScrollElements
|
||||
) => HTMLElement | null
|
||||
}
|
||||
|
||||
interface DataGridTableVirtualProps<TData> {
|
||||
height?: number | string
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
isFetchingMore?: boolean
|
||||
hasMore?: boolean
|
||||
fetchMoreOffset?: number
|
||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||
}
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
virtualItems: VirtualItem[]
|
||||
totalSize: number
|
||||
isVirtualizationEnabled: boolean
|
||||
isInfiniteMode: boolean
|
||||
isFetchingMore: boolean
|
||||
hasMore?: boolean
|
||||
loadingMoreMessage: ReactNode
|
||||
allRowsLoadedMessage: ReactNode
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
}
|
||||
|
||||
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
|
||||
column,
|
||||
}: {
|
||||
column: Column<TData>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const isPinned = column.getIsPinned()
|
||||
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
||||
const isFirstRightPinned =
|
||||
isPinned === "right" && column.getIsFirstColumn("right")
|
||||
|
||||
return (
|
||||
<td
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...(props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
getPinningStyles(column)),
|
||||
...(props.tableLayout?.columnsResizable && {
|
||||
width: `calc(var(--col-${column.id}-size) * 1px)`,
|
||||
}),
|
||||
}}
|
||||
data-pinned={isPinned || undefined}
|
||||
data-last-col={
|
||||
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"p-0",
|
||||
props.tableLayout?.cellBorder && "border-e",
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualUtilityRow<TData>({
|
||||
table,
|
||||
children,
|
||||
centerCellClassName,
|
||||
centerCellStyle,
|
||||
rowClassName,
|
||||
ariaHidden,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
children: ReactNode
|
||||
centerCellClassName?: string
|
||||
centerCellStyle?: CSSProperties
|
||||
rowClassName?: string
|
||||
ariaHidden?: boolean
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
|
||||
{leftVisibleColumns.map((column) => (
|
||||
<DataGridTableVirtualPinnedPlaceholderCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
/>
|
||||
))}
|
||||
<td
|
||||
colSpan={Math.max(centerVisibleColumns.length, 1)}
|
||||
className={centerCellClassName}
|
||||
style={centerCellStyle}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
{rightVisibleColumns.map((column) => (
|
||||
<DataGridTableVirtualPinnedPlaceholderCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
/>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer<TData>({
|
||||
table,
|
||||
height,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
ariaHidden
|
||||
centerCellClassName="p-0"
|
||||
centerCellStyle={{ height, padding: 0 }}
|
||||
>
|
||||
{null}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow<TData>({
|
||||
table,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
centerCellClassName={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
virtualItems,
|
||||
totalSize,
|
||||
isVirtualizationEnabled,
|
||||
isInfiniteMode,
|
||||
isFetchingMore,
|
||||
hasMore,
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
|
||||
if (!totalRows) return <DataGridTableEmpty />
|
||||
|
||||
const hasCenterRows = centerRows.length > 0
|
||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
||||
const leadingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? (virtualItems[0]?.start ?? 0)
|
||||
: 0
|
||||
const trailingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
||||
)
|
||||
: 0
|
||||
|
||||
const renderedRows: ReactNode[] = []
|
||||
|
||||
topRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (isVirtualizationEnabled) {
|
||||
if (leadingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
table={table}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
virtualItems.forEach((virtualRow) => {
|
||||
const row = centerRows[virtualRow.index]
|
||||
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (trailingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
table={table}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
centerRows.forEach((row) => {
|
||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||
})
|
||||
}
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
</div>
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (showCompleteRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
table={table}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
bottomRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||
? "bottom"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <>{renderedRows}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized virtual body: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedVirtualBody = memo(
|
||||
DataGridTableVirtualBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||
) as typeof DataGridTableVirtualBody
|
||||
|
||||
function DataGridTableVirtual<TData>({
|
||||
height,
|
||||
estimateSize = 48,
|
||||
overscan = 10,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
isFetchingMore = false,
|
||||
hasMore,
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
)
|
||||
const isInfiniteMode = typeof onFetchMore === "function"
|
||||
const [viewportElements, setViewportElements] =
|
||||
useState<DataGridTableVirtualScrollElements>({
|
||||
containerElement: null,
|
||||
scrollElement: null,
|
||||
})
|
||||
|
||||
const {
|
||||
estimateSize: customEstimateSize,
|
||||
getItemKey: customGetItemKey,
|
||||
getScrollElement: customGetScrollElement,
|
||||
measureElement: customMeasureElement,
|
||||
overscan: customOverscan,
|
||||
...virtualizerOptionsRest
|
||||
} = virtualizerOptions ?? {}
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
containerElement: node,
|
||||
scrollElement:
|
||||
(node?.closest(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
) as HTMLElement | null) ?? node,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const usesExternalScrollArea =
|
||||
viewportElements.scrollElement !== null &&
|
||||
viewportElements.scrollElement !== viewportElements.containerElement
|
||||
|
||||
const resolveScrollElement = useCallback(() => {
|
||||
if (customGetScrollElement) {
|
||||
return customGetScrollElement(viewportElements)
|
||||
}
|
||||
|
||||
return viewportElements.scrollElement
|
||||
}, [customGetScrollElement, viewportElements])
|
||||
|
||||
const resolveItemKey = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
if (!row) return index
|
||||
|
||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
||||
},
|
||||
[centerRows, customGetItemKey]
|
||||
)
|
||||
|
||||
const resolveEstimateSize = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
return row
|
||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
||||
: estimateSize
|
||||
},
|
||||
[centerRows, customEstimateSize, estimateSize]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: centerRows.length,
|
||||
getScrollElement: resolveScrollElement,
|
||||
getItemKey: resolveItemKey,
|
||||
estimateSize: resolveEstimateSize,
|
||||
overscan: customOverscan ?? overscan,
|
||||
measureElement: customMeasureElement,
|
||||
...virtualizerOptionsRest,
|
||||
}) as DataGridTableVirtualizerInstance
|
||||
|
||||
const virtualItems = isVirtualizationEnabled
|
||||
? virtualizer.getVirtualItems()
|
||||
: []
|
||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
||||
const measureRowRef =
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
? virtualizer.measureElement
|
||||
: undefined
|
||||
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isVirtualizationEnabled ||
|
||||
!isInfiniteMode ||
|
||||
hasMore === false ||
|
||||
isFetchingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastItem = virtualItems[virtualItems.length - 1]
|
||||
if (!lastItem) return
|
||||
|
||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||
onFetchMore?.()
|
||||
}
|
||||
}, [
|
||||
centerRows.length,
|
||||
hasMore,
|
||||
isFetchingMore,
|
||||
isInfiniteMode,
|
||||
isVirtualizationEnabled,
|
||||
onFetchMore,
|
||||
resolvedFetchMoreOffset,
|
||||
virtualItems,
|
||||
])
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: { height, overflow: "auto", position: "relative" }
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{mergedHeaderGroups.map((headerGroup) => (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() !== "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
!hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
virtualItems={virtualItems}
|
||||
totalSize={totalSize}
|
||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
||||
isInfiniteMode={isInfiniteMode}
|
||||
isFetchingMore={isFetchingMore}
|
||||
hasMore={hasMore}
|
||||
loadingMoreMessage={loadingMoreMessage}
|
||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||
measureRowRef={measureRowRef}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableVirtual }
|
||||
export type {
|
||||
DataGridTableVirtualProps,
|
||||
DataGridTableVirtualScrollElements,
|
||||
DataGridTableVirtualizerOptions,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Apple = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} xmlSpace="preserve" viewBox="0 0 814 1000">
|
||||
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Apple };
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const AppleDark = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} xmlSpace="preserve" viewBox="0 0 814 1000">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { AppleDark };
|
||||
@@ -1,243 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const Google = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
xmlSpace="preserve"
|
||||
overflow="hidden"
|
||||
viewBox="0 0 268.152 273.883"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="a">
|
||||
<stop offset="0" stopColor="#0fbc5c" />
|
||||
<stop offset="1" stopColor="#0cba65" />
|
||||
</linearGradient>
|
||||
<linearGradient id="g">
|
||||
<stop offset=".231" stopColor="#0fbc5f" />
|
||||
<stop offset=".312" stopColor="#0fbc5f" />
|
||||
<stop offset=".366" stopColor="#0fbc5e" />
|
||||
<stop offset=".458" stopColor="#0fbc5d" />
|
||||
<stop offset=".54" stopColor="#12bc58" />
|
||||
<stop offset=".699" stopColor="#28bf3c" />
|
||||
<stop offset=".771" stopColor="#38c02b" />
|
||||
<stop offset=".861" stopColor="#52c218" />
|
||||
<stop offset=".915" stopColor="#67c30f" />
|
||||
<stop offset="1" stopColor="#86c504" />
|
||||
</linearGradient>
|
||||
<linearGradient id="h">
|
||||
<stop offset=".142" stopColor="#1abd4d" />
|
||||
<stop offset=".248" stopColor="#6ec30d" />
|
||||
<stop offset=".312" stopColor="#8ac502" />
|
||||
<stop offset=".366" stopColor="#a2c600" />
|
||||
<stop offset=".446" stopColor="#c8c903" />
|
||||
<stop offset=".54" stopColor="#ebcb03" />
|
||||
<stop offset=".616" stopColor="#f7cd07" />
|
||||
<stop offset=".699" stopColor="#fdcd04" />
|
||||
<stop offset=".771" stopColor="#fdce05" />
|
||||
<stop offset=".861" stopColor="#ffce0a" />
|
||||
</linearGradient>
|
||||
<linearGradient id="f">
|
||||
<stop offset=".316" stopColor="#ff4c3c" />
|
||||
<stop offset=".604" stopColor="#ff692c" />
|
||||
<stop offset=".727" stopColor="#ff7825" />
|
||||
<stop offset=".885" stopColor="#ff8d1b" />
|
||||
<stop offset="1" stopColor="#ff9f13" />
|
||||
</linearGradient>
|
||||
<linearGradient id="b">
|
||||
<stop offset=".231" stopColor="#ff4541" />
|
||||
<stop offset=".312" stopColor="#ff4540" />
|
||||
<stop offset=".458" stopColor="#ff4640" />
|
||||
<stop offset=".54" stopColor="#ff473f" />
|
||||
<stop offset=".699" stopColor="#ff5138" />
|
||||
<stop offset=".771" stopColor="#ff5b33" />
|
||||
<stop offset=".861" stopColor="#ff6c29" />
|
||||
<stop offset="1" stopColor="#ff8c18" />
|
||||
</linearGradient>
|
||||
<linearGradient id="d">
|
||||
<stop offset=".408" stopColor="#fb4e5a" />
|
||||
<stop offset="1" stopColor="#ff4540" />
|
||||
</linearGradient>
|
||||
<linearGradient id="c">
|
||||
<stop offset=".132" stopColor="#0cba65" />
|
||||
<stop offset=".21" stopColor="#0bb86d" />
|
||||
<stop offset=".297" stopColor="#09b479" />
|
||||
<stop offset=".396" stopColor="#08ad93" />
|
||||
<stop offset=".477" stopColor="#0aa6a9" />
|
||||
<stop offset=".568" stopColor="#0d9cc6" />
|
||||
<stop offset=".667" stopColor="#1893dd" />
|
||||
<stop offset=".769" stopColor="#258bf1" />
|
||||
<stop offset=".859" stopColor="#3086ff" />
|
||||
</linearGradient>
|
||||
<linearGradient id="e">
|
||||
<stop offset=".366" stopColor="#ff4e3a" />
|
||||
<stop offset=".458" stopColor="#ff8a1b" />
|
||||
<stop offset=".54" stopColor="#ffa312" />
|
||||
<stop offset=".616" stopColor="#ffb60c" />
|
||||
<stop offset=".771" stopColor="#ffcd0a" />
|
||||
<stop offset=".861" stopColor="#fecf0a" />
|
||||
<stop offset=".915" stopColor="#fecf08" />
|
||||
<stop offset="1" stopColor="#fdcd01" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
xlinkHref="#a"
|
||||
id="s"
|
||||
x1="219.7"
|
||||
x2="254.467"
|
||||
y1="329.535"
|
||||
y2="329.535"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<radialGradient
|
||||
xlinkHref="#b"
|
||||
id="m"
|
||||
cx="109.627"
|
||||
cy="135.862"
|
||||
r="71.46"
|
||||
fx="109.627"
|
||||
fy="135.862"
|
||||
gradientTransform="matrix(-1.93688 1.043 1.45573 2.55542 290.525 -400.634)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<radialGradient
|
||||
xlinkHref="#c"
|
||||
id="n"
|
||||
cx="45.259"
|
||||
cy="279.274"
|
||||
r="71.46"
|
||||
fx="45.259"
|
||||
fy="279.274"
|
||||
gradientTransform="matrix(-3.5126 -4.45809 -1.69255 1.26062 870.8 191.554)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<radialGradient
|
||||
xlinkHref="#d"
|
||||
id="l"
|
||||
cx="304.017"
|
||||
cy="118.009"
|
||||
r="47.854"
|
||||
fx="304.017"
|
||||
fy="118.009"
|
||||
gradientTransform="matrix(2.06435 0 0 2.59204 -297.679 -151.747)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<radialGradient
|
||||
xlinkHref="#e"
|
||||
id="o"
|
||||
cx="181.001"
|
||||
cy="177.201"
|
||||
r="71.46"
|
||||
fx="181.001"
|
||||
fy="177.201"
|
||||
gradientTransform="matrix(-.24858 2.08314 2.96249 .33417 -255.146 -331.164)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<radialGradient
|
||||
xlinkHref="#f"
|
||||
id="p"
|
||||
cx="207.673"
|
||||
cy="108.097"
|
||||
r="41.102"
|
||||
fx="207.673"
|
||||
fy="108.097"
|
||||
gradientTransform="matrix(-1.2492 1.34326 -3.89684 -3.4257 880.501 194.905)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<radialGradient
|
||||
xlinkHref="#g"
|
||||
id="r"
|
||||
cx="109.627"
|
||||
cy="135.862"
|
||||
r="71.46"
|
||||
fx="109.627"
|
||||
fy="135.862"
|
||||
gradientTransform="matrix(-1.93688 -1.043 1.45573 -2.55542 290.525 838.683)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<radialGradient
|
||||
xlinkHref="#h"
|
||||
id="j"
|
||||
cx="154.87"
|
||||
cy="145.969"
|
||||
r="71.46"
|
||||
fx="154.87"
|
||||
fy="145.969"
|
||||
gradientTransform="matrix(-.0814 -1.93722 2.92674 -.11625 -215.135 632.86)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
/>
|
||||
<filter
|
||||
id="q"
|
||||
width="1.097"
|
||||
height="1.116"
|
||||
x="-.048"
|
||||
y="-.058"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="1.701" />
|
||||
</filter>
|
||||
<filter
|
||||
id="k"
|
||||
width="1.033"
|
||||
height="1.02"
|
||||
x="-.017"
|
||||
y="-.01"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feGaussianBlur stdDeviation=".242" />
|
||||
</filter>
|
||||
<clipPath id="i" clipPathUnits="userSpaceOnUse">
|
||||
<path d="M371.378 193.24H237.083v53.438h77.167c-1.241 7.563-4.026 15.003-8.105 21.786-4.674 7.773-10.451 13.69-16.373 18.196-17.74 13.498-38.42 16.258-52.783 16.258-36.283 0-67.283-23.286-79.285-54.928-.484-1.149-.805-2.335-1.197-3.507a81.115 81.115 0 0 1-4.101-25.448c0-9.226 1.569-18.057 4.43-26.398 11.285-32.897 42.985-57.467 80.179-57.467 7.481 0 14.685.884 21.517 2.648a77.668 77.668 0 0 1 33.425 18.25l40.834-39.712c-24.839-22.616-57.219-36.32-95.844-36.32-30.878 0-59.386 9.553-82.748 25.7-18.945 13.093-34.483 30.625-44.97 50.985-9.753 18.879-15.094 39.8-15.094 62.294 0 22.495 5.35 43.633 15.103 62.337v.126c10.302 19.857 25.368 36.954 43.678 49.988 15.997 11.386 44.68 26.551 84.031 26.551 22.63 0 42.687-4.051 60.375-11.644 12.76-5.478 24.065-12.622 34.301-21.804 13.525-12.132 24.117-27.139 31.347-44.404 7.23-17.265 11.097-36.79 11.097-57.957 0-9.858-.998-19.87-2.689-28.968Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#i)" transform="matrix(.95792 0 0 .98525 -90.174 -78.856)">
|
||||
<path
|
||||
fill="url(#j)"
|
||||
d="M92.076 219.958c.148 22.14 6.501 44.983 16.117 63.424v.127c6.949 13.392 16.445 23.97 27.26 34.452l65.327-23.67c-12.36-6.235-14.246-10.055-23.105-17.026-9.054-9.066-15.802-19.473-20.004-31.677h-.17l.17-.127c-2.765-8.058-3.037-16.613-3.14-25.503Z"
|
||||
filter="url(#k)"
|
||||
/>
|
||||
<path
|
||||
fill="url(#l)"
|
||||
d="M237.083 79.025c-6.456 22.526-3.988 44.421 0 57.161 7.457.006 14.64.888 21.45 2.647a77.662 77.662 0 0 1 33.424 18.25l41.88-40.726c-24.81-22.59-54.667-37.297-96.754-37.332Z"
|
||||
filter="url(#k)"
|
||||
/>
|
||||
<path
|
||||
fill="url(#m)"
|
||||
d="M236.943 78.847c-31.67 0-60.91 9.798-84.871 26.359a145.533 145.533 0 0 0-24.332 21.15c-1.904 17.744 14.257 39.551 46.262 39.37 15.528-17.936 38.495-29.542 64.056-29.542l.07.002-1.044-57.335c-.048 0-.093-.004-.14-.004Z"
|
||||
filter="url(#k)"
|
||||
/>
|
||||
<path
|
||||
fill="url(#n)"
|
||||
d="m341.475 226.379-28.268 19.285c-1.24 7.562-4.028 15.002-8.107 21.786-4.674 7.772-10.45 13.69-16.373 18.196-17.702 13.47-38.328 16.244-52.687 16.255-14.842 25.102-17.444 37.675 1.043 57.934 22.877-.016 43.157-4.117 61.046-11.796 12.931-5.551 24.388-12.792 34.761-22.097 13.706-12.295 24.442-27.503 31.769-45 7.327-17.497 11.245-37.282 11.245-58.734Z"
|
||||
filter="url(#k)"
|
||||
/>
|
||||
<path
|
||||
fill="#3086ff"
|
||||
d="M234.996 191.21v57.498h136.006c1.196-7.874 5.152-18.064 5.152-26.5 0-9.858-.996-21.899-2.687-30.998Z"
|
||||
filter="url(#k)"
|
||||
/>
|
||||
<path
|
||||
fill="url(#o)"
|
||||
d="M128.39 124.327c-8.394 9.119-15.564 19.326-21.249 30.364-9.753 18.879-15.094 41.83-15.094 64.324 0 .317.026.627.029.944 4.32 8.224 59.666 6.649 62.456 0-.004-.31-.039-.613-.039-.924 0-9.226 1.57-16.026 4.43-24.367 3.53-10.289 9.056-19.763 16.123-27.926 1.602-2.031 5.875-6.397 7.121-9.016.475-.997-.862-1.557-.937-1.908-.083-.393-1.876-.077-2.277-.37-1.275-.929-3.8-1.414-5.334-1.845-3.277-.921-8.708-2.953-11.725-5.06-9.536-6.658-24.417-14.612-33.505-24.216Z"
|
||||
filter="url(#k)"
|
||||
/>
|
||||
<path
|
||||
fill="url(#p)"
|
||||
d="M162.099 155.857c22.112 13.301 28.471-6.714 43.173-12.977l-25.574-52.664a144.74 144.74 0 0 0-26.543 14.504c-12.316 8.512-23.192 18.9-32.176 30.72Z"
|
||||
filter="url(#q)"
|
||||
/>
|
||||
<path
|
||||
fill="url(#r)"
|
||||
d="M171.099 290.222c-29.683 10.641-34.33 11.023-37.062 29.29a144.806 144.806 0 0 0 16.792 13.984c15.996 11.386 46.766 26.551 86.118 26.551.046 0 .09-.004.137-.004v-59.157l-.094.002c-14.736 0-26.512-3.843-38.585-10.527-2.977-1.648-8.378 2.777-11.123.799-3.786-2.729-12.9 2.35-16.183-.938Z"
|
||||
filter="url(#k)"
|
||||
/>
|
||||
<path
|
||||
fill="url(#s)"
|
||||
d="M219.7 299.023v59.996c5.506.64 11.236 1.028 17.247 1.028 6.026 0 11.855-.307 17.52-.872v-59.748a105.119 105.119 0 0 1-17.477 1.461c-5.932 0-11.7-.686-17.29-1.865Z"
|
||||
filter="url(#k)"
|
||||
opacity=".5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { Google };
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { SVGProps } from "react"
|
||||
|
||||
const OPENAI_WORDMARK_DARK_PATH = [
|
||||
"M367.44 153.84c0 52.32 33.6 88.8 80.16 88.8 46.56 0 80.16-36.48 80.16-88.8s-33.6-88.8-80.16-88.8c-46.56 0-80.16 36.48-80.16 88.8Z",
|
||||
"m129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68Z",
|
||||
"M614.27 242.64c35.28 0 55.44-29.76 55.44-65.52 0-35.76-20.16-65.52-55.44-65.52-16.32 0-28.32 6.48-36.24 15.84V114h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84Z",
|
||||
"m-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48v-7.44Z",
|
||||
"M747.65 242.64c25.2 0 45.12-13.2 54-35.28L776.93 198c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16-36.48 0-60 28.56-60 65.52 0 38.88 25.2 65.52 61.2 65.52Z",
|
||||
"m-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92Z",
|
||||
"M823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88V240h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84V114h-28.8v126Z",
|
||||
"M1014.17 67.68 948.89 240h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32h-34.8Z",
|
||||
"m16.8 34.08 27.36 72h-54.24l26.88-72Z",
|
||||
"M1163.69 68.18h-30.72V240.5h30.72V68.18Z",
|
||||
"M297.06 130.97a79.712 79.712 0 0 0-6.85-65.48c-17.46-30.4-52.56-46.04-86.84-38.68A79.747 79.747 0 0 0 143.24 0C108.2-.08 77.11 22.48 66.33 55.82a79.754 79.754 0 0 0-53.31 38.67c-17.59 30.32-13.58 68.54 9.92 94.54a79.712 79.712 0 0 0 6.85 65.48c17.46 30.4 52.56 46.04 86.84 38.68a79.687 79.687 0 0 0 60.13 26.8c35.06.09 66.16-22.49 76.94-55.86a79.754 79.754 0 0 0 53.31-38.67c17.57-30.32 13.55-68.51-9.94-94.51l-.01.02Z",
|
||||
"M176.78 299.08a59.77 59.77 0 0 1-38.39-13.88c.49-.26 1.34-.73 1.89-1.07l63.72-36.8a10.36 10.36 0 0 0 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97Z",
|
||||
"M47.94 244.05a59.71 59.71 0 0 1-7.15-40.18c.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83L129.87 266c-28.69 16.52-65.33 6.7-81.92-21.95h-.01Z",
|
||||
"M31.17 104.96c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91L118.44 224c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89l.01-.01Z",
|
||||
"m221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94a59.94 59.94 0 0 1-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06h-.01Z",
|
||||
"m26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8a10.375 10.375 0 0 0-10.47 0l-77.79 44.92V92c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22a59.95 59.95 0 0 1 7.15 40.1h.02Z",
|
||||
"m-168.51 55.43-26.94-15.55a.943.943 0 0 1-.52-.74V80.86c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07L116 72.67a10.344 10.344 0 0 0-5.24 9.06l-.04 89.79v.02Z",
|
||||
"M125.35 140 160 119.99l34.65 20V180L160 200l-34.65-20v-40Z",
|
||||
].join(" ")
|
||||
|
||||
const OpenaiWordmarkDark = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} fill="none" viewBox="0 0 1180 320">
|
||||
<g fill="#fff" clipPath="url(#a)">
|
||||
<path d={OPENAI_WORDMARK_DARK_PATH} />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h1180v320H0z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export { OpenaiWordmarkDark }
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { SVGProps } from "react"
|
||||
|
||||
const OPENAI_WORDMARK_LIGHT_PATH = [
|
||||
"M367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8z",
|
||||
"m129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z",
|
||||
"M614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84V114h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84z",
|
||||
"m-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z",
|
||||
"M747.65 242.64c25.2 0 45.12-13.2 54-35.28L776.93 198c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52z",
|
||||
"m-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z",
|
||||
"M823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88V240h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84V114h-28.8z",
|
||||
"M1014.17 67.68 948.89 240h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32z",
|
||||
"m16.8 34.08 27.36 72h-54.24z",
|
||||
"M1163.69 68.18h-30.72V240.5h30.72z",
|
||||
"M297.06 130.97a79.712 79.712 0 0 0-6.85-65.48c-17.46-30.4-52.56-46.04-86.84-38.68A79.747 79.747 0 0 0 143.24 0C108.2-.08 77.11 22.48 66.33 55.82a79.754 79.754 0 0 0-53.31 38.67c-17.59 30.32-13.58 68.54 9.92 94.54a79.712 79.712 0 0 0 6.85 65.48c17.46 30.4 52.56 46.04 86.84 38.68a79.687 79.687 0 0 0 60.13 26.8c35.06.09 66.16-22.49 76.94-55.86a79.754 79.754 0 0 0 53.31-38.67c17.57-30.32 13.55-68.51-9.94-94.51z",
|
||||
"M176.78 299.08a59.77 59.77 0 0 1-38.39-13.88c.49-.26 1.34-.73 1.89-1.07l63.72-36.8a10.36 10.36 0 0 0 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97z",
|
||||
"M47.94 244.05a59.71 59.71 0 0 1-7.15-40.18c.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83L129.87 266c-28.69 16.52-65.33 6.7-81.92-21.95z",
|
||||
"M31.17 104.96c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91L118.44 224c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89z",
|
||||
"m221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94a59.94 59.94 0 0 1-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06z",
|
||||
"m26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8a10.375 10.375 0 0 0-10.47 0l-77.79 44.92V92c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22a59.95 59.95 0 0 1 7.15 40.1z",
|
||||
"m-168.51 55.43-26.94-15.55a.943.943 0 0 1-.52-.74V80.86c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07L116 72.67a10.344 10.344 0 0 0-5.24 9.06l-.04 89.79z",
|
||||
"M125.35 140 160 119.99l34.65 20V180L160 200l-34.65-20z",
|
||||
].join(" ")
|
||||
|
||||
const OpenaiWordmarkLight = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 1180 320">
|
||||
<path d={OPENAI_WORDMARK_LIGHT_PATH} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export { OpenaiWordmarkLight }
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { SVGProps } from "react"
|
||||
|
||||
const SlackWordmark = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} fill="currentColor" viewBox="0 0 2500 632.6">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="m799.8 498.1 31.2-72.5c33.7 25.2 78.6 38.3 122.9 38.3 32.7 0 53.4-12.6 53.4-31.7-.5-53.4-195.9-11.6-197.4-145.5-.5-68 59.9-120.4 145.5-120.4 50.9 0 101.7 12.6 138 41.3l-29.2 74c-33.2-21.2-74.5-36.3-113.8-36.3-26.7 0-44.3 12.6-44.3 28.7.5 52.4 197.4 23.7 199.4 151.6 0 69.5-58.9 118.4-143.5 118.4-62-.1-118.9-14.7-162.2-45.9m1198.1-98.7c-15.6 27.2-44.8 45.8-78.6 45.8-49.9 0-90.1-40.3-90.1-90.1s40.3-90.1 90.1-90.1c33.7 0 63 18.6 78.6 45.8L2084 263c-32.2-57.4-94.2-96.7-164.7-96.7-104.3 0-188.9 84.6-188.9 188.9s84.6 188.9 188.9 188.9c71 0 132.5-38.8 164.7-96.7zM1148.8 9.6h107.8v527.3h-107.8zm977.5 0v527.3h107.8V378.7L2362 536.9h138L2337.3 349l150.6-175.3h-132L2234 319.2V9.6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M1576.9 400.4c-15.6 25.7-47.8 44.8-84.1 44.8-49.9 0-90.1-40.3-90.1-90.1s40.3-90.1 90.1-90.1c36.3 0 68.5 20.1 84.1 46.3zm0-226.6v42.8c-17.6-29.7-61.4-50.4-107.3-50.4-94.7 0-169.2 83.6-169.2 188.4S1374.9 544 1469.6 544c45.8 0 89.6-20.6 107.3-50.4v42.8h107.8V173.8z" />
|
||||
<g fillRule="evenodd" clipRule="evenodd">
|
||||
<path
|
||||
fill="#e01e5a"
|
||||
d="M133.5 399.9c0 36.8-29.7 66.5-66.5 66.5S.5 436.6.5 399.9s29.7-66.5 66.5-66.5h66.5zm33.2 0c0-36.8 29.7-66.5 66.5-66.5s66.5 29.7 66.5 66.5v166.2c0 36.8-29.7 66.5-66.5 66.5s-66.5-29.7-66.5-66.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#36c5f0"
|
||||
d="M233.2 133c-36.8 0-66.5-29.7-66.5-66.5S196.4 0 233.2 0s66.5 29.7 66.5 66.5V133zm0 33.7c36.8 0 66.5 29.7 66.5 66.5s-29.7 66.5-66.5 66.5H66.5C29.7 299.7 0 269.9 0 233.2s29.7-66.5 66.5-66.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#2eb67d"
|
||||
d="M499.6 233.2c0-36.8 29.7-66.5 66.5-66.5s66.5 29.7 66.5 66.5-29.7 66.5-66.5 66.5h-66.5zm-33.2 0c0 36.8-29.7 66.5-66.5 66.5s-66.5-29.7-66.5-66.5V66.5c0-36.8 29.7-66.5 66.5-66.5s66.5 29.7 66.5 66.5z"
|
||||
/>
|
||||
<path
|
||||
fill="#ecb22e"
|
||||
d="M399.9 499.6c36.8 0 66.5 29.7 66.5 66.5s-29.7 66.5-66.5 66.5-66.5-29.7-66.5-66.5v-66.5zm0-33.2c-36.8 0-66.5-29.7-66.5-66.5s29.7-66.5 66.5-66.5h166.7c36.8 0 66.5 29.7 66.5 66.5s-29.7 66.5-66.5 66.5z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export { SlackWordmark }
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const StripeWordmark = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 512 214">
|
||||
<path
|
||||
fill="#635bff"
|
||||
d="M512 110.08c0-36.409-17.636-65.138-51.342-65.138-33.85 0-54.33 28.73-54.33 64.854 0 42.808 24.179 64.426 58.88 64.426 16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658m-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756 8.675 0 17.92 6.685 17.92 22.756zm-90.31-51.627c-13.939 0-22.899 6.542-27.876 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96 25.458 0 48.64-20.48 48.64-65.564-.142-41.245-23.609-63.716-48.498-63.716m-8.534 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968 12.942 0 21.902 14.506 21.902 33.137 0 19.058-8.818 33.28-21.902 33.28M241.493 36.551l35.698-7.68V0l-35.698 7.538zm0 10.809h35.698v124.444h-35.698zm-38.257 10.524L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524m-71.112-41.386-34.702 7.395-.142 113.92c0 21.05 15.787 36.551 36.836 36.551 11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022zM35.982 83.484c0-5.546 4.551-7.68 12.09-7.68 10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92 0 6.541-5.688 8.675-13.653 8.675-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106 29.582 0 49.92-14.648 49.92-40.106-.142-42.382-54.329-34.845-54.329-50.774"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export { StripeWordmark };
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { SVGProps } from "react"
|
||||
|
||||
const SupabaseWordmarkDark = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 581 113" fill="none">
|
||||
<path
|
||||
d="M151.397 66.7608C151.996 72.3621 157.091 81.9642 171.877 81.9642C184.764 81.9642 190.959 73.7624 190.959 65.7607C190.959 58.559 186.063 52.6577 176.373 50.6571L169.379 49.1569C166.682 48.6568 164.884 47.1565 164.884 44.7559C164.884 41.9552 167.681 39.8549 171.178 39.8549C176.772 39.8549 178.87 43.5556 179.27 46.4564L190.359 43.9558C189.76 38.6546 185.064 29.7527 171.078 29.7527C160.488 29.7527 152.696 37.0543 152.696 45.8561C152.696 52.7576 156.991 58.4591 166.482 60.5594L172.976 62.0598C176.772 62.8599 178.271 64.6605 178.271 66.8609C178.271 69.4615 176.173 71.762 171.777 71.762C165.983 71.762 163.085 68.1611 162.786 64.2602L151.397 66.7608Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M233.421 80.4639H246.109C245.909 78.7635 245.609 75.3628 245.609 71.5618V31.2529H232.321V59.8592C232.321 65.5606 228.925 69.5614 223.031 69.5614C216.837 69.5614 214.039 65.1604 214.039 59.6592V31.2529H200.752V62.3599C200.752 73.0622 207.545 81.7642 219.434 81.7642C224.628 81.7642 230.325 79.7638 233.022 75.1627C233.022 77.1631 233.221 79.4636 233.421 80.4639Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M273.076 99.4682V75.663C275.473 78.9636 280.469 81.6644 287.263 81.6644C301.149 81.6644 310.439 70.6617 310.439 55.7584C310.439 41.1553 302.148 30.1528 287.762 30.1528C280.37 30.1528 274.875 33.4534 272.677 37.2544V31.253H259.79V99.4682H273.076ZM297.352 55.8585C297.352 64.6606 291.958 69.7616 285.164 69.7616C278.372 69.7616 272.877 64.5605 272.877 55.8585C272.877 47.1566 278.372 42.0554 285.164 42.0554C291.958 42.0554 297.352 47.1566 297.352 55.8585Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M317.964 67.0609C317.964 74.7627 324.357 81.8643 334.848 81.8643C342.139 81.8643 346.835 78.4634 349.332 74.5625C349.332 76.463 349.532 79.1635 349.832 80.4639H362.02C361.72 78.7635 361.422 75.2627 361.422 72.6622V48.4567C361.422 38.5545 355.627 29.7527 340.043 29.7527C326.855 29.7527 319.761 38.2544 318.963 45.9562L330.751 48.4567C331.151 44.1558 334.348 40.455 340.141 40.455C345.737 40.455 348.434 43.3556 348.434 46.8564C348.434 48.5568 347.536 49.9572 344.738 50.3572L332.65 52.1576C324.458 53.3579 317.964 58.2589 317.964 67.0609ZM337.644 71.962C333.349 71.962 331.25 69.1614 331.25 66.2608C331.25 62.4599 333.947 60.5594 337.345 60.0594L348.434 58.359V60.5594C348.434 69.2615 343.239 71.962 337.644 71.962Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M387.703 80.4641V74.4627C390.299 78.6637 395.494 81.6644 402.288 81.6644C416.276 81.6644 425.467 70.5618 425.467 55.6585C425.467 41.0552 417.174 29.9528 402.788 29.9528C395.494 29.9528 390.1 33.1535 387.902 36.6541V8.04785H374.815V80.4641H387.703ZM412.178 55.7584C412.178 64.7605 406.784 69.7616 399.99 69.7616C393.297 69.7616 387.703 64.6606 387.703 55.7584C387.703 46.7564 393.297 41.8554 399.99 41.8554C406.784 41.8554 412.178 46.7564 412.178 55.7584Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M432.99 67.0609C432.99 74.7627 439.383 81.8643 449.873 81.8643C457.165 81.8643 461.862 78.4634 464.358 74.5625C464.358 76.463 464.559 79.1635 464.858 80.4639H477.046C476.748 78.7635 476.448 75.2627 476.448 72.6622V48.4567C476.448 38.5545 470.653 29.7527 455.068 29.7527C441.881 29.7527 434.788 38.2544 433.989 45.9562L445.776 48.4567C446.177 44.1558 449.374 40.455 455.167 40.455C460.763 40.455 463.46 43.3556 463.46 46.8564C463.46 48.5568 462.561 49.9572 459.763 50.3572L447.676 52.1576C439.484 53.3579 432.99 58.2589 432.99 67.0609ZM452.671 71.962C448.375 71.962 446.276 69.1614 446.276 66.2608C446.276 62.4599 448.973 60.5594 452.371 60.0594L463.46 58.359V60.5594C463.46 69.2615 458.265 71.962 452.671 71.962Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M485.645 66.7608C486.243 72.3621 491.339 81.9642 506.124 81.9642C519.012 81.9642 525.205 73.7624 525.205 65.7607C525.205 58.559 520.311 52.6577 510.62 50.6571L503.626 49.1569C500.929 48.6568 499.132 47.1565 499.132 44.7559C499.132 41.9552 501.928 39.8549 505.425 39.8549C511.021 39.8549 513.118 43.5556 513.519 46.4564L524.607 43.9558C524.007 38.6546 519.312 29.7527 505.326 29.7527C494.735 29.7527 486.944 37.0543 486.944 45.8561C486.944 52.7576 491.238 58.4591 500.73 60.5594L507.224 62.0598C511.021 62.8599 512.519 64.6605 512.519 66.8609C512.519 69.4615 510.421 71.762 506.025 71.762C500.23 71.762 497.334 68.1611 497.034 64.2602L485.645 66.7608Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M545.385 50.2571C545.685 45.7562 549.482 40.5549 556.375 40.5549C563.967 40.5549 567.165 45.3561 567.365 50.2571H545.385ZM568.664 63.0601C567.065 67.4609 563.668 70.5617 557.474 70.5617C550.88 70.5617 545.385 65.8606 545.087 59.3593H580.252C580.252 59.159 580.451 57.1587 580.451 55.2582C580.451 39.4547 571.361 29.7527 556.175 29.7527C543.588 29.7527 531.998 39.9548 531.998 55.6584C531.998 72.262 543.886 81.9642 557.374 81.9642C569.462 81.9642 577.255 74.8626 579.753 66.3607L568.664 63.0601Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
|
||||
fill="url(#paint0_linear)"
|
||||
/>
|
||||
<path
|
||||
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
|
||||
fill="url(#paint1_linear)"
|
||||
fillOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
|
||||
fill="#3ECF8E"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="53.9738"
|
||||
y1="54.974"
|
||||
x2="94.1635"
|
||||
y2="71.8295"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#249361" />
|
||||
<stop offset="1" stopColor="#3ECF8E" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear"
|
||||
x1="36.1558"
|
||||
y1="30.578"
|
||||
x2="54.4844"
|
||||
y2="65.0806"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export { SupabaseWordmarkDark }
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { SVGProps } from "react"
|
||||
|
||||
const SupabaseWordmarkLight = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} viewBox="0 0 581 113" fill="none">
|
||||
<path
|
||||
d="M151.397 66.7608C151.996 72.3621 157.091 81.9642 171.877 81.9642C184.764 81.9642 190.959 73.7624 190.959 65.7607C190.959 58.559 186.063 52.6577 176.373 50.6571L169.379 49.1569C166.682 48.6568 164.884 47.1565 164.884 44.7559C164.884 41.9552 167.681 39.8549 171.178 39.8549C176.772 39.8549 178.87 43.5556 179.27 46.4564L190.359 43.9558C189.76 38.6546 185.064 29.7527 171.078 29.7527C160.488 29.7527 152.696 37.0543 152.696 45.8561C152.696 52.7576 156.991 58.4591 166.482 60.5594L172.976 62.0598C176.772 62.8599 178.271 64.6605 178.271 66.8609C178.271 69.4615 176.173 71.762 171.777 71.762C165.983 71.762 163.085 68.1611 162.786 64.2602L151.397 66.7608Z"
|
||||
fill="#1F1F1F"
|
||||
/>
|
||||
<path
|
||||
d="M233.421 80.4639H246.109C245.909 78.7635 245.609 75.3628 245.609 71.5618V31.2529H232.321V59.8592C232.321 65.5606 228.925 69.5614 223.031 69.5614C216.837 69.5614 214.039 65.1604 214.039 59.6592V31.2529H200.752V62.3599C200.752 73.0622 207.545 81.7642 219.434 81.7642C224.628 81.7642 230.325 79.7638 233.022 75.1627C233.022 77.1631 233.221 79.4636 233.421 80.4639Z"
|
||||
fill="#1F1F1F"
|
||||
/>
|
||||
<path
|
||||
d="M273.076 99.4682V75.663C275.473 78.9636 280.469 81.6644 287.263 81.6644C301.149 81.6644 310.439 70.6617 310.439 55.7584C310.439 41.1553 302.148 30.1528 287.762 30.1528C280.37 30.1528 274.875 33.4534 272.677 37.2544V31.253H259.79V99.4682H273.076ZM297.352 55.8585C297.352 64.6606 291.958 69.7616 285.164 69.7616C278.372 69.7616 272.877 64.5605 272.877 55.8585C272.877 47.1566 278.372 42.0554 285.164 42.0554C291.958 42.0554 297.352 47.1566 297.352 55.8585Z"
|
||||
fill="#1F1F1F"
|
||||
/>
|
||||
<path
|
||||
d="M317.964 67.0609C317.964 74.7627 324.357 81.8643 334.848 81.8643C342.139 81.8643 346.835 78.4634 349.332 74.5625C349.332 76.463 349.532 79.1635 349.832 80.4639H362.02C361.72 78.7635 361.422 75.2627 361.422 72.6622V48.4567C361.422 38.5545 355.627 29.7527 340.043 29.7527C326.855 29.7527 319.761 38.2544 318.963 45.9562L330.751 48.4567C331.151 44.1558 334.348 40.455 340.141 40.455C345.737 40.455 348.434 43.3556 348.434 46.8564C348.434 48.5568 347.536 49.9572 344.738 50.3572L332.65 52.1576C324.458 53.3579 317.964 58.2589 317.964 67.0609ZM337.644 71.962C333.349 71.962 331.25 69.1614 331.25 66.2608C331.25 62.4599 333.947 60.5594 337.345 60.0594L348.434 58.359V60.5594C348.434 69.2615 343.239 71.962 337.644 71.962Z"
|
||||
fill="#1F1F1F"
|
||||
/>
|
||||
<path
|
||||
d="M387.703 80.4641V74.4627C390.299 78.6637 395.494 81.6644 402.288 81.6644C416.276 81.6644 425.467 70.5618 425.467 55.6585C425.467 41.0552 417.174 29.9528 402.788 29.9528C395.494 29.9528 390.1 33.1535 387.902 36.6541V8.04785H374.815V80.4641H387.703ZM412.178 55.7584C412.178 64.7605 406.784 69.7616 399.99 69.7616C393.297 69.7616 387.703 64.6606 387.703 55.7584C387.703 46.7564 393.297 41.8554 399.99 41.8554C406.784 41.8554 412.178 46.7564 412.178 55.7584Z"
|
||||
fill="#1F1F1F"
|
||||
/>
|
||||
<path
|
||||
d="M432.99 67.0609C432.99 74.7627 439.383 81.8643 449.873 81.8643C457.165 81.8643 461.862 78.4634 464.358 74.5625C464.358 76.463 464.559 79.1635 464.858 80.4639H477.046C476.748 78.7635 476.448 75.2627 476.448 72.6622V48.4567C476.448 38.5545 470.653 29.7527 455.068 29.7527C441.881 29.7527 434.788 38.2544 433.989 45.9562L445.776 48.4567C446.177 44.1558 449.374 40.455 455.167 40.455C460.763 40.455 463.46 43.3556 463.46 46.8564C463.46 48.5568 462.561 49.9572 459.763 50.3572L447.676 52.1576C439.484 53.3579 432.99 58.2589 432.99 67.0609ZM452.671 71.962C448.375 71.962 446.276 69.1614 446.276 66.2608C446.276 62.4599 448.973 60.5594 452.371 60.0594L463.46 58.359V60.5594C463.46 69.2615 458.265 71.962 452.671 71.962Z"
|
||||
fill="#1F1F1F"
|
||||
/>
|
||||
<path
|
||||
d="M485.645 66.7608C486.243 72.3621 491.339 81.9642 506.124 81.9642C519.012 81.9642 525.205 73.7624 525.205 65.7607C525.205 58.559 520.311 52.6577 510.62 50.6571L503.626 49.1569C500.929 48.6568 499.132 47.1565 499.132 44.7559C499.132 41.9552 501.928 39.8549 505.425 39.8549C511.021 39.8549 513.118 43.5556 513.519 46.4564L524.607 43.9558C524.007 38.6546 519.312 29.7527 505.326 29.7527C494.735 29.7527 486.944 37.0543 486.944 45.8561C486.944 52.7576 491.238 58.4591 500.73 60.5594L507.224 62.0598C511.021 62.8599 512.519 64.6605 512.519 66.8609C512.519 69.4615 510.421 71.762 506.025 71.762C500.23 71.762 497.334 68.1611 497.034 64.2602L485.645 66.7608Z"
|
||||
fill="#1F1F1F"
|
||||
/>
|
||||
<path
|
||||
d="M545.385 50.2571C545.685 45.7562 549.482 40.5549 556.375 40.5549C563.967 40.5549 567.165 45.3561 567.365 50.2571H545.385ZM568.664 63.0601C567.065 67.4609 563.668 70.5617 557.474 70.5617C550.88 70.5617 545.385 65.8606 545.087 59.3593H580.252C580.252 59.159 580.451 57.1587 580.451 55.2582C580.451 39.4547 571.361 29.7527 556.175 29.7527C543.588 29.7527 531.998 39.9548 531.998 55.6584C531.998 72.262 543.886 81.9642 557.374 81.9642C569.462 81.9642 577.255 74.8626 579.753 66.3607L568.664 63.0601Z"
|
||||
fill="#1F1F1F"
|
||||
/>
|
||||
<path
|
||||
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
|
||||
fill="url(#paint0_linear)"
|
||||
/>
|
||||
<path
|
||||
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
|
||||
fill="url(#paint1_linear)"
|
||||
fillOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
|
||||
fill="#3ECF8E"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="53.9738"
|
||||
y1="54.974"
|
||||
x2="94.1635"
|
||||
y2="71.8295"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#249361" />
|
||||
<stop offset="1" stopColor="#3ECF8E" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear"
|
||||
x1="36.1558"
|
||||
y1="30.578"
|
||||
x2="54.4844"
|
||||
y2="65.0806"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export { SupabaseWordmarkLight }
|
||||
@@ -25,6 +25,5 @@
|
||||
"@authportal/shared": ["../../packages/shared/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/components/blocks/**"]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user