mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-03 18:01:39 +00:00
@@ -0,0 +1,268 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export const signInButtonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-3 rounded-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-gradient-to-r from-purple-600 to-indigo-600 text-white hover:from-purple-700 hover:to-indigo-700 shadow-lg hover:shadow-xl',
|
||||
outline:
|
||||
'border-2 border-purple-600 text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-950',
|
||||
ghost: 'text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-950',
|
||||
dark: 'bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200',
|
||||
},
|
||||
size: {
|
||||
default: 'h-12 px-6 text-base',
|
||||
sm: 'h-10 px-4 text-sm',
|
||||
lg: 'h-14 px-8 text-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SignInWithSonrProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof signInButtonVariants> {
|
||||
/**
|
||||
* OAuth client ID for your application
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* OAuth redirect URI after authentication
|
||||
*/
|
||||
redirectUri: string;
|
||||
/**
|
||||
* OAuth scopes to request
|
||||
*/
|
||||
scopes?: string[];
|
||||
/**
|
||||
* OAuth state parameter for CSRF protection
|
||||
*/
|
||||
state?: string;
|
||||
/**
|
||||
* Custom authorization endpoint URL
|
||||
*/
|
||||
authorizationUrl?: string;
|
||||
/**
|
||||
* Loading state
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Custom text for the button
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* Show Sonr logo
|
||||
*/
|
||||
showLogo?: boolean;
|
||||
/**
|
||||
* Callback when authorization starts
|
||||
*/
|
||||
onAuthStart?: () => void;
|
||||
/**
|
||||
* Callback on authorization error
|
||||
*/
|
||||
onAuthError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SignInWithSonr button component for OAuth authentication
|
||||
*/
|
||||
export const SignInWithSonr = React.forwardRef<HTMLButtonElement, SignInWithSonrProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
clientId,
|
||||
redirectUri,
|
||||
scopes = ['openid', 'profile'],
|
||||
state,
|
||||
authorizationUrl = '/oauth2/authorize',
|
||||
isLoading = false,
|
||||
text = 'Sign in with Sonr',
|
||||
showLogo = true,
|
||||
onAuthStart,
|
||||
onAuthError,
|
||||
disabled,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const handleClick = React.useCallback(
|
||||
async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isLoading || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call custom onClick if provided
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
|
||||
// Call auth start callback
|
||||
if (onAuthStart) {
|
||||
onAuthStart();
|
||||
}
|
||||
|
||||
try {
|
||||
// Build OAuth authorization URL
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes.join(' '),
|
||||
state: state || generateRandomState(),
|
||||
});
|
||||
|
||||
// Add PKCE challenge for public clients
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
|
||||
// Store code verifier in session storage
|
||||
sessionStorage.setItem('sonr_oauth_code_verifier', codeVerifier);
|
||||
|
||||
params.append('code_challenge', codeChallenge);
|
||||
params.append('code_challenge_method', 'S256');
|
||||
|
||||
// Construct full authorization URL
|
||||
const fullAuthUrl = `${authorizationUrl}?${params.toString()}`;
|
||||
|
||||
// Redirect to authorization endpoint
|
||||
window.location.href = fullAuthUrl;
|
||||
} catch (error) {
|
||||
if (onAuthError) {
|
||||
onAuthError(error as Error);
|
||||
}
|
||||
console.error('Failed to initiate OAuth flow:', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
clientId,
|
||||
redirectUri,
|
||||
scopes,
|
||||
state,
|
||||
authorizationUrl,
|
||||
isLoading,
|
||||
disabled,
|
||||
onClick,
|
||||
onAuthStart,
|
||||
onAuthError,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(signInButtonVariants({ variant, size, className }))}
|
||||
onClick={handleClick}
|
||||
disabled={disabled || isLoading}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? <LoadingSpinner /> : showLogo ? <SonrLogo /> : null}
|
||||
<span>{isLoading ? 'Signing in...' : text}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SignInWithSonr.displayName = 'SignInWithSonr';
|
||||
|
||||
/**
|
||||
* Sonr logo SVG component
|
||||
*/
|
||||
const SonrLogo = () => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" opacity="0.8" />
|
||||
<path
|
||||
d="M2 17L12 22L22 17"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 12L12 17L22 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Loading spinner component
|
||||
*/
|
||||
const LoadingSpinner = () => (
|
||||
<svg
|
||||
className="animate-spin h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate random state for CSRF protection
|
||||
*/
|
||||
function generateRandomState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier
|
||||
*/
|
||||
function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code challenge from verifier
|
||||
*/
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
export default SignInWithSonr;
|
||||
@@ -0,0 +1,350 @@
|
||||
import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { SignInWithSonr } from './SignInWithSonr';
|
||||
|
||||
interface SignInWithSonrModalProps {
|
||||
/**
|
||||
* Whether the modal is open
|
||||
*/
|
||||
isOpen: boolean;
|
||||
/**
|
||||
* Callback when modal should close
|
||||
*/
|
||||
onClose: () => void;
|
||||
/**
|
||||
* OAuth client ID
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* OAuth redirect URI
|
||||
*/
|
||||
redirectUri: string;
|
||||
/**
|
||||
* OAuth scopes to request
|
||||
*/
|
||||
scopes?: string[];
|
||||
/**
|
||||
* Modal title
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Modal description
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Show terms and privacy links
|
||||
*/
|
||||
showTerms?: boolean;
|
||||
/**
|
||||
* Terms URL
|
||||
*/
|
||||
termsUrl?: string;
|
||||
/**
|
||||
* Privacy URL
|
||||
*/
|
||||
privacyUrl?: string;
|
||||
/**
|
||||
* Custom authorization URL
|
||||
*/
|
||||
authorizationUrl?: string;
|
||||
/**
|
||||
* Additional content to show below the button
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Callback when auth starts
|
||||
*/
|
||||
onAuthStart?: () => void;
|
||||
/**
|
||||
* Callback on auth error
|
||||
*/
|
||||
onAuthError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal component for SignInWithSonr authentication
|
||||
*/
|
||||
export const SignInWithSonrModal: React.FC<SignInWithSonrModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
clientId,
|
||||
redirectUri,
|
||||
scopes = ['openid', 'profile'],
|
||||
title = 'Sign in to continue',
|
||||
description = 'Use your Sonr account to securely sign in',
|
||||
showTerms = true,
|
||||
termsUrl = '/terms',
|
||||
privacyUrl = '/privacy',
|
||||
authorizationUrl,
|
||||
children,
|
||||
onAuthStart,
|
||||
onAuthError,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
// Handle escape key
|
||||
React.useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAuthStart = () => {
|
||||
setIsLoading(true);
|
||||
if (onAuthStart) {
|
||||
onAuthStart();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthError = (error: Error) => {
|
||||
setIsLoading(false);
|
||||
if (onAuthError) {
|
||||
onAuthError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 transform"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
>
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-xl">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-r from-purple-600 to-indigo-600">
|
||||
<SonrLogoLarge />
|
||||
</div>
|
||||
<h2
|
||||
id="modal-title"
|
||||
className="text-2xl font-semibold text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p id="modal-description" className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sign in button */}
|
||||
<SignInWithSonr
|
||||
clientId={clientId}
|
||||
redirectUri={redirectUri}
|
||||
scopes={scopes}
|
||||
authorizationUrl={authorizationUrl}
|
||||
isLoading={isLoading}
|
||||
onAuthStart={handleAuthStart}
|
||||
onAuthError={handleAuthError}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Alternative sign in methods */}
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-700" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-gray-900 px-2 text-gray-500 dark:text-gray-400">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alternative auth buttons */}
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center gap-2',
|
||||
'rounded-lg border border-gray-300 dark:border-gray-700',
|
||||
'bg-white dark:bg-gray-800 px-4 py-2.5',
|
||||
'text-sm font-medium text-gray-700 dark:text-gray-300',
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<WalletIcon />
|
||||
Wallet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center gap-2',
|
||||
'rounded-lg border border-gray-300 dark:border-gray-700',
|
||||
'bg-white dark:bg-gray-800 px-4 py-2.5',
|
||||
'text-sm font-medium text-gray-700 dark:text-gray-300',
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-700',
|
||||
'focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<PasskeyIcon />
|
||||
Passkey
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional content */}
|
||||
{children && <div className="mt-6">{children}</div>}
|
||||
|
||||
{/* Terms and privacy */}
|
||||
{showTerms && (
|
||||
<p className="mt-6 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
By signing in, you agree to our{' '}
|
||||
<a
|
||||
href={termsUrl}
|
||||
className="text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href={privacyUrl}
|
||||
className="text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Large Sonr logo for modal header
|
||||
*/
|
||||
const SonrLogoLarge = () => (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-white"
|
||||
>
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" opacity="0.9" />
|
||||
<path
|
||||
d="M2 17L12 22L22 17"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 12L12 17L22 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Wallet icon
|
||||
*/
|
||||
const WalletIcon = () => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M21 4H3C1.89543 4 1 4.89543 1 6V18C1 19.1046 1.89543 20 3 20H21C22.1046 20 23 19.1046 23 18V6C23 4.89543 22.1046 4 21 4Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1 10H23"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Passkey icon
|
||||
*/
|
||||
const PasskeyIcon = () => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M21 2L19 4M15 10L17 8L21 4L15 10ZM15 10L9 16L3 20L7 14L13 8L19 2L15 10Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SignInWithSonrModal;
|
||||
@@ -0,0 +1,896 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
type Uniforms = {
|
||||
[key: string]: {
|
||||
value: number[] | number[][] | number;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface ShaderProps {
|
||||
source: string;
|
||||
uniforms: {
|
||||
[key: string]: {
|
||||
value: number[] | number[][] | number;
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
maxFps?: number;
|
||||
}
|
||||
|
||||
interface SignInPageProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CanvasRevealEffect = ({
|
||||
animationSpeed = 10,
|
||||
opacities = [0.3, 0.3, 0.3, 0.5, 0.5, 0.5, 0.8, 0.8, 0.8, 1],
|
||||
colors = [[0, 255, 255]],
|
||||
containerClassName,
|
||||
dotSize,
|
||||
showGradient = true,
|
||||
reverse = false, // This controls the direction
|
||||
}: {
|
||||
animationSpeed?: number;
|
||||
opacities?: number[];
|
||||
colors?: number[][];
|
||||
containerClassName?: string;
|
||||
dotSize?: number;
|
||||
showGradient?: boolean;
|
||||
reverse?: boolean; // This prop determines the direction
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('h-full relative w-full', containerClassName)}>
|
||||
{' '}
|
||||
{/* Removed bg-white */}
|
||||
<div className="h-full w-full">
|
||||
<DotMatrix
|
||||
colors={colors ?? [[0, 255, 255]]}
|
||||
dotSize={dotSize ?? 3}
|
||||
opacities={opacities ?? [0.3, 0.3, 0.3, 0.5, 0.5, 0.5, 0.8, 0.8, 0.8, 1]}
|
||||
// Pass reverse state and speed via string flags in the empty shader prop
|
||||
shader={`
|
||||
${reverse ? 'u_reverse_active' : 'false'}_;
|
||||
animation_speed_factor_${animationSpeed.toFixed(1)}_;
|
||||
`}
|
||||
center={['x', 'y']}
|
||||
/>
|
||||
</div>
|
||||
{showGradient && (
|
||||
// Adjust gradient colors if needed based on background (was bg-white, now likely uses containerClassName bg)
|
||||
// Example assuming a dark background like the SignInPage uses:
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DotMatrixProps {
|
||||
colors?: number[][];
|
||||
opacities?: number[];
|
||||
totalSize?: number;
|
||||
dotSize?: number;
|
||||
shader?: string;
|
||||
center?: ('x' | 'y')[];
|
||||
}
|
||||
|
||||
const DotMatrix: React.FC<DotMatrixProps> = ({
|
||||
colors = [[0, 0, 0]],
|
||||
opacities = [0.04, 0.04, 0.04, 0.04, 0.04, 0.08, 0.08, 0.08, 0.08, 0.14],
|
||||
totalSize = 20,
|
||||
dotSize = 2,
|
||||
shader = '', // This shader string will now contain the animation logic
|
||||
center = ['x', 'y'],
|
||||
}) => {
|
||||
// ... uniforms calculation remains the same for colors, opacities, etc.
|
||||
const uniforms = React.useMemo(() => {
|
||||
let colorsArray = [colors[0], colors[0], colors[0], colors[0], colors[0], colors[0]];
|
||||
if (colors.length === 2) {
|
||||
colorsArray = [colors[0], colors[0], colors[0], colors[1], colors[1], colors[1]];
|
||||
} else if (colors.length === 3) {
|
||||
colorsArray = [colors[0], colors[0], colors[1], colors[1], colors[2], colors[2]];
|
||||
}
|
||||
return {
|
||||
u_colors: {
|
||||
value: colorsArray.map((color) => [
|
||||
(color?.[0] ?? 0) / 255,
|
||||
(color?.[1] ?? 0) / 255,
|
||||
(color?.[2] ?? 0) / 255,
|
||||
]),
|
||||
type: 'uniform3fv',
|
||||
},
|
||||
u_opacities: {
|
||||
value: opacities,
|
||||
type: 'uniform1fv',
|
||||
},
|
||||
u_total_size: {
|
||||
value: totalSize,
|
||||
type: 'uniform1f',
|
||||
},
|
||||
u_dot_size: {
|
||||
value: dotSize,
|
||||
type: 'uniform1f',
|
||||
},
|
||||
u_reverse: {
|
||||
value: shader.includes('u_reverse_active') ? 1 : 0, // Convert boolean to number (1 or 0)
|
||||
type: 'uniform1i', // Use 1i for bool in WebGL1/GLSL100, or just bool for GLSL300+ if supported
|
||||
},
|
||||
};
|
||||
}, [colors, opacities, totalSize, dotSize, shader]); // Add shader to dependencies
|
||||
|
||||
return (
|
||||
<Shader
|
||||
// The main animation logic is now built *outside* the shader prop
|
||||
source={`
|
||||
precision mediump float;
|
||||
in vec2 fragCoord;
|
||||
|
||||
uniform float u_time;
|
||||
uniform float u_opacities[10];
|
||||
uniform vec3 u_colors[6];
|
||||
uniform float u_total_size;
|
||||
uniform float u_dot_size;
|
||||
uniform vec2 u_resolution;
|
||||
uniform int u_reverse; // Changed from bool to int
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
float PHI = 1.61803398874989484820459;
|
||||
float random(vec2 xy) {
|
||||
return fract(tan(distance(xy * PHI, xy) * 0.5) * xy.x);
|
||||
}
|
||||
float map(float value, float min1, float max1, float min2, float max2) {
|
||||
return min2 + (value - min1) * (max2 - min2) / (max1 - min1);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 st = fragCoord.xy;
|
||||
${
|
||||
center.includes('x')
|
||||
? 'st.x -= abs(floor((mod(u_resolution.x, u_total_size) - u_dot_size) * 0.5));'
|
||||
: ''
|
||||
}
|
||||
${
|
||||
center.includes('y')
|
||||
? 'st.y -= abs(floor((mod(u_resolution.y, u_total_size) - u_dot_size) * 0.5));'
|
||||
: ''
|
||||
}
|
||||
|
||||
float opacity = step(0.0, st.x);
|
||||
opacity *= step(0.0, st.y);
|
||||
|
||||
vec2 st2 = vec2(int(st.x / u_total_size), int(st.y / u_total_size));
|
||||
|
||||
float frequency = 5.0;
|
||||
float show_offset = random(st2); // Used for initial opacity random pick and color
|
||||
float rand = random(st2 * floor((u_time / frequency) + show_offset + frequency));
|
||||
opacity *= u_opacities[int(rand * 10.0)];
|
||||
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.x / u_total_size));
|
||||
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.y / u_total_size));
|
||||
|
||||
vec3 color = u_colors[int(show_offset * 6.0)];
|
||||
|
||||
// --- Animation Timing Logic ---
|
||||
float animation_speed_factor = 0.5; // Extract speed from shader string
|
||||
vec2 center_grid = u_resolution / 2.0 / u_total_size;
|
||||
float dist_from_center = distance(center_grid, st2);
|
||||
|
||||
// Calculate timing offset for Intro (from center)
|
||||
float timing_offset_intro = dist_from_center * 0.01 + (random(st2) * 0.15);
|
||||
|
||||
// Calculate timing offset for Outro (from edges)
|
||||
// Max distance from center to a corner of the grid
|
||||
float max_grid_dist = distance(center_grid, vec2(0.0, 0.0));
|
||||
float timing_offset_outro = (max_grid_dist - dist_from_center) * 0.02 + (random(st2 + 42.0) * 0.2);
|
||||
|
||||
|
||||
float current_timing_offset;
|
||||
if (u_reverse == 1) {
|
||||
current_timing_offset = timing_offset_outro;
|
||||
// Outro logic: opacity starts high, goes to 0 when time passes offset
|
||||
opacity *= 1.0 - step(current_timing_offset, u_time * animation_speed_factor);
|
||||
// Clamp for fade-out transition
|
||||
opacity *= clamp((step(current_timing_offset + 0.1, u_time * animation_speed_factor)) * 1.25, 1.0, 1.25);
|
||||
} else {
|
||||
current_timing_offset = timing_offset_intro;
|
||||
// Intro logic: opacity starts 0, goes to base opacity when time passes offset
|
||||
opacity *= step(current_timing_offset, u_time * animation_speed_factor);
|
||||
// Clamp for fade-in transition
|
||||
opacity *= clamp((1.0 - step(current_timing_offset + 0.1, u_time * animation_speed_factor)) * 1.25, 1.0, 1.25);
|
||||
}
|
||||
|
||||
|
||||
fragColor = vec4(color, opacity);
|
||||
fragColor.rgb *= fragColor.a; // Premultiply alpha
|
||||
}`}
|
||||
uniforms={uniforms}
|
||||
maxFps={60}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ShaderMaterial = ({
|
||||
source,
|
||||
uniforms,
|
||||
}: {
|
||||
source: string;
|
||||
hovered?: boolean;
|
||||
maxFps?: number;
|
||||
uniforms: Uniforms;
|
||||
}) => {
|
||||
const { size } = useThree();
|
||||
const ref = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
if (!ref.current) return;
|
||||
const timestamp = clock.getElapsedTime();
|
||||
|
||||
const material: any = ref.current.material;
|
||||
const timeLocation = material.uniforms.u_time;
|
||||
timeLocation.value = timestamp;
|
||||
});
|
||||
|
||||
const getUniforms = () => {
|
||||
const preparedUniforms: any = {};
|
||||
|
||||
for (const uniformName in uniforms) {
|
||||
const uniform: any = uniforms[uniformName];
|
||||
|
||||
switch (uniform.type) {
|
||||
case 'uniform1f':
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: '1f' };
|
||||
break;
|
||||
case 'uniform1i':
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: '1i' };
|
||||
break;
|
||||
case 'uniform3f':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: new THREE.Vector3().fromArray(uniform.value),
|
||||
type: '3f',
|
||||
};
|
||||
break;
|
||||
case 'uniform1fv':
|
||||
preparedUniforms[uniformName] = { value: uniform.value, type: '1fv' };
|
||||
break;
|
||||
case 'uniform3fv':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: uniform.value.map((v: number[]) => new THREE.Vector3().fromArray(v)),
|
||||
type: '3fv',
|
||||
};
|
||||
break;
|
||||
case 'uniform2f':
|
||||
preparedUniforms[uniformName] = {
|
||||
value: new THREE.Vector2().fromArray(uniform.value),
|
||||
type: '2f',
|
||||
};
|
||||
break;
|
||||
default:
|
||||
console.error(`Invalid uniform type for '${uniformName}'.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
preparedUniforms.u_time = { value: 0, type: '1f' };
|
||||
preparedUniforms.u_resolution = {
|
||||
value: new THREE.Vector2(size.width * 2, size.height * 2),
|
||||
}; // Initialize u_resolution
|
||||
return preparedUniforms;
|
||||
};
|
||||
|
||||
// Shader material
|
||||
const material = useMemo(() => {
|
||||
const materialObject = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
precision mediump float;
|
||||
in vec2 coordinates;
|
||||
uniform vec2 u_resolution;
|
||||
out vec2 fragCoord;
|
||||
void main(){
|
||||
float x = position.x;
|
||||
float y = position.y;
|
||||
gl_Position = vec4(x, y, 0.0, 1.0);
|
||||
fragCoord = (position.xy + vec2(1.0)) * 0.5 * u_resolution;
|
||||
fragCoord.y = u_resolution.y - fragCoord.y;
|
||||
}
|
||||
`,
|
||||
fragmentShader: source,
|
||||
uniforms: getUniforms(),
|
||||
glslVersion: THREE.GLSL3,
|
||||
blending: THREE.CustomBlending,
|
||||
blendSrc: THREE.SrcAlphaFactor,
|
||||
blendDst: THREE.OneFactor,
|
||||
});
|
||||
|
||||
return materialObject;
|
||||
}, [size.width, size.height, source]);
|
||||
|
||||
return (
|
||||
<mesh ref={ref as any}>
|
||||
<planeGeometry args={[2, 2]} />
|
||||
<primitive object={material} attach="material" />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
const Shader: React.FC<ShaderProps> = ({ source, uniforms, maxFps = 60 }) => {
|
||||
return (
|
||||
<Canvas className="absolute inset-0 h-full w-full">
|
||||
<ShaderMaterial source={source} uniforms={uniforms} maxFps={maxFps} />
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const AnimatedNavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
|
||||
const defaultTextColor = 'text-gray-300';
|
||||
const hoverTextColor = 'text-white';
|
||||
const textSizeClass = 'text-sm';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={`group relative inline-block overflow-hidden h-5 flex items-center ${textSizeClass}`}
|
||||
>
|
||||
<div className="flex flex-col transition-transform duration-400 ease-out transform group-hover:-translate-y-1/2">
|
||||
<span className={defaultTextColor}>{children}</span>
|
||||
<span className={hoverTextColor}>{children}</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
function MiniNavbar() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [headerShapeClass, setHeaderShapeClass] = useState('rounded-full');
|
||||
const shapeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shapeTimeoutRef.current) {
|
||||
clearTimeout(shapeTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
setHeaderShapeClass('rounded-xl');
|
||||
} else {
|
||||
shapeTimeoutRef.current = setTimeout(() => {
|
||||
setHeaderShapeClass('rounded-full');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (shapeTimeoutRef.current) {
|
||||
clearTimeout(shapeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const logoElement = (
|
||||
<div className="relative w-5 h-5 flex items-center justify-center">
|
||||
<span className="absolute w-1.5 h-1.5 rounded-full bg-gray-200 top-0 left-1/2 transform -translate-x-1/2 opacity-80" />
|
||||
<span className="absolute w-1.5 h-1.5 rounded-full bg-gray-200 left-0 top-1/2 transform -translate-y-1/2 opacity-80" />
|
||||
<span className="absolute w-1.5 h-1.5 rounded-full bg-gray-200 right-0 top-1/2 transform -translate-y-1/2 opacity-80" />
|
||||
<span className="absolute w-1.5 h-1.5 rounded-full bg-gray-200 bottom-0 left-1/2 transform -translate-x-1/2 opacity-80" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const navLinksData = [
|
||||
{ label: 'Manifesto', href: '#1' },
|
||||
{ label: 'Careers', href: '#2' },
|
||||
{ label: 'Discover', href: '#3' },
|
||||
];
|
||||
|
||||
const loginButtonElement = (
|
||||
<button className="px-4 py-2 sm:px-3 text-xs sm:text-sm border border-[#333] bg-[rgba(31,31,31,0.62)] text-gray-300 rounded-full hover:border-white/50 hover:text-white transition-colors duration-200 w-full sm:w-auto">
|
||||
LogIn
|
||||
</button>
|
||||
);
|
||||
|
||||
const signupButtonElement = (
|
||||
<div className="relative group w-full sm:w-auto">
|
||||
<div
|
||||
className="absolute inset-0 -m-2 rounded-full
|
||||
hidden sm:block
|
||||
bg-gray-100
|
||||
opacity-40 filter blur-lg pointer-events-none
|
||||
transition-all duration-300 ease-out
|
||||
group-hover:opacity-60 group-hover:blur-xl group-hover:-m-3"
|
||||
/>
|
||||
<button className="relative z-10 px-4 py-2 sm:px-3 text-xs sm:text-sm font-semibold text-black bg-gradient-to-br from-gray-100 to-gray-300 rounded-full hover:from-gray-200 hover:to-gray-400 transition-all duration-200 w-full sm:w-auto">
|
||||
Signup
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-20
|
||||
flex flex-col items-center
|
||||
pl-6 pr-6 py-3 backdrop-blur-sm
|
||||
${headerShapeClass}
|
||||
border border-[#333] bg-[#1f1f1f57]
|
||||
w-[calc(100%-2rem)] sm:w-auto
|
||||
transition-[border-radius] duration-0 ease-in-out`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-x-6 sm:gap-x-8">
|
||||
<div className="flex items-center">{logoElement}</div>
|
||||
|
||||
<nav className="hidden sm:flex items-center space-x-4 sm:space-x-6 text-sm">
|
||||
{navLinksData.map((link) => (
|
||||
<AnimatedNavLink key={link.href} href={link.href}>
|
||||
{link.label}
|
||||
</AnimatedNavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3">
|
||||
{loginButtonElement}
|
||||
{signupButtonElement}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="sm:hidden flex items-center justify-center w-8 h-8 text-gray-300 focus:outline-none"
|
||||
onClick={toggleMenu}
|
||||
aria-label={isOpen ? 'Close Menu' : 'Open Menu'}
|
||||
>
|
||||
{isOpen ? (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`sm:hidden flex flex-col items-center w-full transition-all ease-in-out duration-300 overflow-hidden
|
||||
${isOpen ? 'max-h-[1000px] opacity-100 pt-4' : 'max-h-0 opacity-0 pt-0 pointer-events-none'}`}
|
||||
>
|
||||
<nav className="flex flex-col items-center space-y-4 text-base w-full">
|
||||
{navLinksData.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-gray-300 hover:text-white transition-colors w-full text-center"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex flex-col items-center space-y-4 mt-4 w-full">
|
||||
{loginButtonElement}
|
||||
{signupButtonElement}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export const SignInPage = ({ className }: SignInPageProps) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [step, setStep] = useState<'email' | 'code' | 'success'>('email');
|
||||
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const [initialCanvasVisible, setInitialCanvasVisible] = useState(true);
|
||||
const [reverseCanvasVisible, setReverseCanvasVisible] = useState(false);
|
||||
|
||||
const handleEmailSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (email) {
|
||||
setStep('code');
|
||||
}
|
||||
};
|
||||
|
||||
// Focus first input when code screen appears
|
||||
useEffect(() => {
|
||||
if (step === 'code') {
|
||||
setTimeout(() => {
|
||||
codeInputRefs.current[0]?.focus();
|
||||
}, 500);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const handleCodeChange = (index: number, value: string) => {
|
||||
if (value.length <= 1) {
|
||||
const newCode = [...code];
|
||||
newCode[index] = value;
|
||||
setCode(newCode);
|
||||
|
||||
// Focus next input if value is entered
|
||||
if (value && index < 5) {
|
||||
codeInputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
// Check if code is complete
|
||||
if (index === 5 && value) {
|
||||
const isComplete = newCode.every((digit) => digit.length === 1);
|
||||
if (isComplete) {
|
||||
// First show the new reverse canvas
|
||||
setReverseCanvasVisible(true);
|
||||
|
||||
// Then hide the original canvas after a small delay
|
||||
setTimeout(() => {
|
||||
setInitialCanvasVisible(false);
|
||||
}, 50);
|
||||
|
||||
// Transition to success screen after animation
|
||||
setTimeout(() => {
|
||||
setStep('success');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
||||
codeInputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackClick = () => {
|
||||
setStep('email');
|
||||
setCode(['', '', '', '', '', '']);
|
||||
// Reset animations if going back
|
||||
setReverseCanvasVisible(false);
|
||||
setInitialCanvasVisible(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-[100%] flex-col min-h-screen bg-black relative', className)}>
|
||||
<div className="absolute inset-0 z-0">
|
||||
{/* Initial canvas (forward animation) */}
|
||||
{initialCanvasVisible && (
|
||||
<div className="absolute inset-0">
|
||||
<CanvasRevealEffect
|
||||
animationSpeed={3}
|
||||
containerClassName="bg-black"
|
||||
colors={[
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
]}
|
||||
dotSize={6}
|
||||
reverse={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reverse canvas (appears when code is complete) */}
|
||||
{reverseCanvasVisible && (
|
||||
<div className="absolute inset-0">
|
||||
<CanvasRevealEffect
|
||||
animationSpeed={4}
|
||||
containerClassName="bg-black"
|
||||
colors={[
|
||||
[255, 255, 255],
|
||||
[255, 255, 255],
|
||||
]}
|
||||
dotSize={6}
|
||||
reverse={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_rgba(0,0,0,1)_0%,_transparent_100%)]" />
|
||||
<div className="absolute top-0 left-0 right-0 h-1/3 bg-gradient-to-b from-black to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Content Layer */}
|
||||
<div className="relative z-10 flex flex-col flex-1">
|
||||
{/* Top navigation */}
|
||||
<MiniNavbar />
|
||||
|
||||
{/* Main content container */}
|
||||
<div className="flex flex-1 flex-col lg:flex-row ">
|
||||
{/* Left side (form) */}
|
||||
<div className="flex-1 flex flex-col justify-center items-center">
|
||||
<div className="w-full mt-[150px] max-w-sm">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 'email' ? (
|
||||
<motion.div
|
||||
key="email-step"
|
||||
initial={{ opacity: 0, x: -100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -100 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="space-y-6 text-center"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[2.5rem] font-bold leading-[1.1] tracking-tight text-white">
|
||||
Welcome Developer
|
||||
</h1>
|
||||
<p className="text-[1.8rem] text-white/70 font-light">
|
||||
Your sign in component
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button className="backdrop-blur-[2px] w-full flex items-center justify-center gap-2 bg-white/5 hover:bg-white/10 text-white border border-white/10 rounded-full py-3 px-4 transition-colors">
|
||||
<span className="text-lg">G</span>
|
||||
<span>Sign in with Google</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-px bg-white/10 flex-1" />
|
||||
<span className="text-white/40 text-sm">or</span>
|
||||
<div className="h-px bg-white/10 flex-1" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleEmailSubmit}>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="info@gmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full backdrop-blur-[1px] text-white border-1 border-white/10 rounded-full py-3 px-4 focus:outline-none focus:border focus:border-white/30 text-center"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-1.5 top-1.5 text-white w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors group overflow-hidden"
|
||||
>
|
||||
<span className="relative w-full h-full block overflow-hidden">
|
||||
<span className="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-hover:translate-x-full">
|
||||
→
|
||||
</span>
|
||||
<span className="absolute inset-0 flex items-center justify-center transition-transform duration-300 -translate-x-full group-hover:translate-x-0">
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-white/40 pt-10">
|
||||
By signing up, you agree to the{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
MSA
|
||||
</Link>
|
||||
,{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Product Terms
|
||||
</Link>
|
||||
,{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Policies
|
||||
</Link>
|
||||
,{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Privacy Notice
|
||||
</Link>
|
||||
, and{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Cookie Notice
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</motion.div>
|
||||
) : step === 'code' ? (
|
||||
<motion.div
|
||||
key="code-step"
|
||||
initial={{ opacity: 0, x: 100 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 100 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="space-y-6 text-center"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[2.5rem] font-bold leading-[1.1] tracking-tight text-white">
|
||||
We sent you a code
|
||||
</h1>
|
||||
<p className="text-[1.25rem] text-white/50 font-light">Please enter it</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<div className="relative rounded-full py-4 px-5 border border-white/10 bg-transparent">
|
||||
<div className="flex items-center justify-center">
|
||||
{code.map((digit, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={(el) => {
|
||||
codeInputRefs.current[i] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleCodeChange(i, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
className="w-8 text-center text-xl bg-transparent text-white border-none focus:outline-none focus:ring-0 appearance-none"
|
||||
style={{ caretColor: 'transparent' }}
|
||||
/>
|
||||
{!digit && (
|
||||
<div className="absolute top-0 left-0 w-full h-full flex items-center justify-center pointer-events-none">
|
||||
<span className="text-xl text-white">0</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{i < 5 && <span className="text-white/20 text-xl">|</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<motion.p
|
||||
className="text-white/50 hover:text-white/70 transition-colors cursor-pointer text-sm"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
Resend code
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<motion.button
|
||||
onClick={handleBackClick}
|
||||
className="rounded-full bg-white text-black font-medium px-8 py-3 hover:bg-white/90 transition-colors w-[30%]"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
Back
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className={`flex-1 rounded-full font-medium py-3 border transition-all duration-300 ${
|
||||
code.every((d) => d !== '')
|
||||
? 'bg-white text-black border-transparent hover:bg-white/90 cursor-pointer'
|
||||
: 'bg-[#111] text-white/50 border-white/10 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!code.every((d) => d !== '')}
|
||||
>
|
||||
Continue
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="pt-16">
|
||||
<p className="text-xs text-white/40">
|
||||
By signing up, you agree to the{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
MSA
|
||||
</Link>
|
||||
,{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Product Terms
|
||||
</Link>
|
||||
,{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Policies
|
||||
</Link>
|
||||
,{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Privacy Notice
|
||||
</Link>
|
||||
, and{' '}
|
||||
<Link
|
||||
href="#"
|
||||
className="underline text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Cookie Notice
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="success-step"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut', delay: 0.3 }}
|
||||
className="space-y-6 text-center"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-[2.5rem] font-bold leading-[1.1] tracking-tight text-white">
|
||||
You're in!
|
||||
</h1>
|
||||
<p className="text-[1.25rem] text-white/50 font-light">Welcome</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="py-10"
|
||||
>
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-gradient-to-br from-white to-white/70 flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8 text-black"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1 }}
|
||||
className="w-full rounded-full bg-white text-black font-medium py-3 hover:bg-white/90 transition-colors"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '../../ui/chart';
|
||||
|
||||
export interface ActivityData {
|
||||
date: string;
|
||||
requests?: number;
|
||||
errors?: number;
|
||||
latency?: number;
|
||||
users?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ActivityChartProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
data: ActivityData[];
|
||||
type?: 'line' | 'area' | 'bar';
|
||||
dataKeys: {
|
||||
key: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
}[];
|
||||
timeRange?: string;
|
||||
loading?: boolean;
|
||||
height?: number; // Note: height is managed by ChartContainer
|
||||
showGrid?: boolean;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
|
||||
export function ActivityChart({
|
||||
title,
|
||||
description,
|
||||
data,
|
||||
type = 'line',
|
||||
dataKeys,
|
||||
timeRange,
|
||||
loading = false,
|
||||
// height = 350, // Note: height is managed by ChartContainer
|
||||
showGrid = true,
|
||||
showLegend = true,
|
||||
}: ActivityChartProps) {
|
||||
const chartConfig: ChartConfig = dataKeys.reduce((acc, { key, label, color }) => {
|
||||
acc[key] = {
|
||||
label,
|
||||
color: color || `hsl(var(--chart-${Object.keys(acc).length + 1}))`,
|
||||
};
|
||||
return acc;
|
||||
}, {} as ChartConfig);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</div>
|
||||
{timeRange && <Badge variant="secondary">{timeRange}</Badge>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] w-full bg-muted rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const renderChart = () => {
|
||||
const commonProps = {
|
||||
data,
|
||||
margin: { top: 10, right: 10, left: 0, bottom: 0 },
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'area':
|
||||
return (
|
||||
<AreaChart {...commonProps}>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map(({ key, color }) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color || chartConfig[key]?.color}
|
||||
strokeWidth={2}
|
||||
fill={color || chartConfig[key]?.color}
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
);
|
||||
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart {...commonProps}>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map(({ key, color }) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={color || chartConfig[key]?.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
);
|
||||
|
||||
default: // line
|
||||
return (
|
||||
<LineChart {...commonProps}>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />}
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map(({ key, color }) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color || chartConfig[key]?.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</div>
|
||||
{timeRange && <Badge variant="secondary">{timeRange}</Badge>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
<ChartContainer config={chartConfig} className="h-[350px] w-full">
|
||||
{renderChart()}
|
||||
</ChartContainer>
|
||||
{showLegend && <ChartLegend content={<ChartLegendContent payload={[]} />} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Activity, Minus, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
|
||||
export interface MetricsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
direction: 'up' | 'down' | 'neutral';
|
||||
period?: string;
|
||||
};
|
||||
icon?: React.ReactNode;
|
||||
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function MetricsCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
trend,
|
||||
icon = <Activity className="h-4 w-4" />,
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
}: MetricsCardProps) {
|
||||
const getTrendIcon = () => {
|
||||
if (!trend) return null;
|
||||
|
||||
switch (trend.direction) {
|
||||
case 'up':
|
||||
return <TrendingUp className="h-4 w-4" />;
|
||||
case 'down':
|
||||
return <TrendingDown className="h-4 w-4" />;
|
||||
default:
|
||||
return <Minus className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = () => {
|
||||
if (!trend) return 'text-muted-foreground';
|
||||
|
||||
if (trend.direction === 'up') {
|
||||
return trend.value >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
}
|
||||
if (trend.direction === 'down') {
|
||||
return trend.value < 0 ? 'text-green-600' : 'text-red-600';
|
||||
}
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return 'border-primary/20 bg-primary/5';
|
||||
case 'success':
|
||||
return 'border-green-500/20 bg-green-50 dark:bg-green-950/20';
|
||||
case 'warning':
|
||||
return 'border-yellow-500/20 bg-yellow-50 dark:bg-yellow-950/20';
|
||||
case 'danger':
|
||||
return 'border-red-500/20 bg-red-50 dark:bg-red-950/20';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={getVariantStyles()}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<div className="h-4 w-24 bg-muted rounded animate-pulse" />
|
||||
</CardTitle>
|
||||
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-32 bg-muted rounded animate-pulse mb-2" />
|
||||
<div className="h-3 w-20 bg-muted rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={getVariantStyles()}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<div className="text-muted-foreground">{icon}</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{description && <CardDescription className="text-xs mt-1">{description}</CardDescription>}
|
||||
{trend && (
|
||||
<div className={`flex items-center gap-1 mt-2 text-xs ${getTrendColor()}`}>
|
||||
{getTrendIcon()}
|
||||
<span className="font-medium">{Math.abs(trend.value)}%</span>
|
||||
{trend.period && <span className="text-muted-foreground">vs {trend.period}</span>}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Wifi,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '../../ui/alert';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Progress } from '../../ui/progress';
|
||||
|
||||
export interface PerformanceMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
threshold?: {
|
||||
warning: number;
|
||||
critical: number;
|
||||
};
|
||||
status?: 'healthy' | 'warning' | 'critical';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface PerformanceMetricsProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
metrics: PerformanceMetric[];
|
||||
showAlerts?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function PerformanceMetrics({
|
||||
title = 'Performance Metrics',
|
||||
description,
|
||||
metrics,
|
||||
showAlerts = true,
|
||||
loading = false,
|
||||
}: PerformanceMetricsProps) {
|
||||
const getMetricIcon = (name: string) => {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
latency: <Clock className="h-4 w-4" />,
|
||||
throughput: <Zap className="h-4 w-4" />,
|
||||
uptime: <Activity className="h-4 w-4" />,
|
||||
cpu: <Cpu className="h-4 w-4" />,
|
||||
memory: <HardDrive className="h-4 w-4" />,
|
||||
network: <Wifi className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const key = name.toLowerCase();
|
||||
for (const [k, icon] of Object.entries(iconMap)) {
|
||||
if (key.includes(k)) return icon;
|
||||
}
|
||||
return <Activity className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
|
||||
case 'critical':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'text-green-600';
|
||||
case 'warning':
|
||||
return 'text-yellow-600';
|
||||
case 'critical':
|
||||
return 'text-red-600';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const getProgressColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'bg-green-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500';
|
||||
case 'critical':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const calculateProgress = (metric: PerformanceMetric) => {
|
||||
if (!metric.threshold) return metric.value;
|
||||
|
||||
const max = metric.threshold.critical * 1.2;
|
||||
return Math.min((metric.value / max) * 100, 100);
|
||||
};
|
||||
|
||||
const criticalMetrics = metrics.filter((m) => m.status === 'critical');
|
||||
const warningMetrics = metrics.filter((m) => m.status === 'warning');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
||||
<div className="h-2 w-full bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{criticalMetrics.length > 0 && (
|
||||
<Badge variant="destructive">{criticalMetrics.length} Critical</Badge>
|
||||
)}
|
||||
{warningMetrics.length > 0 && (
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
|
||||
{warningMetrics.length} Warning
|
||||
</Badge>
|
||||
)}
|
||||
{criticalMetrics.length === 0 && warningMetrics.length === 0 && (
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
All Healthy
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{showAlerts && criticalMetrics.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{criticalMetrics.length} metric{criticalMetrics.length > 1 ? 's' : ''} exceeded
|
||||
critical threshold
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{metrics.map((metric, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getMetricIcon(metric.name)}
|
||||
<span className="text-sm font-medium">{metric.name}</span>
|
||||
{getStatusIcon(metric.status)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${getStatusColor(metric.status)}`}>
|
||||
{metric.value} {metric.unit}
|
||||
</span>
|
||||
{metric.threshold && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>W: {metric.threshold.warning}</span>
|
||||
<span className="mx-1">|</span>
|
||||
<span>C: {metric.threshold.critical}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
value={calculateProgress(metric)}
|
||||
className={`h-2 ${getProgressColor(metric.status)}`}
|
||||
/>
|
||||
|
||||
{metric.description && (
|
||||
<p className="text-xs text-muted-foreground">{metric.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
RadarChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '../../ui/chart';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
||||
|
||||
export interface RequestPatternData {
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
status?: string;
|
||||
count: number;
|
||||
percentage?: number;
|
||||
avgLatency?: number;
|
||||
errorRate?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface RequestPatternChartProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
data: RequestPatternData[];
|
||||
type?: 'pie' | 'radar' | 'bar';
|
||||
groupBy?: 'endpoint' | 'method' | 'status';
|
||||
loading?: boolean;
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
onGroupByChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
];
|
||||
|
||||
export function RequestPatternChart({
|
||||
title,
|
||||
description,
|
||||
data,
|
||||
type = 'pie',
|
||||
groupBy = 'endpoint',
|
||||
loading = false,
|
||||
// height = 350, // Note: height is managed by ChartContainer
|
||||
showLegend = true,
|
||||
onGroupByChange,
|
||||
}: RequestPatternChartProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</div>
|
||||
{onGroupByChange && (
|
||||
<Select value={groupBy} onValueChange={onGroupByChange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="endpoint">By Endpoint</SelectItem>
|
||||
<SelectItem value="method">By Method</SelectItem>
|
||||
<SelectItem value="status">By Status</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px] w-full bg-muted rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const chartConfig: ChartConfig = data.reduce((acc, item, index) => {
|
||||
const key = item[groupBy] || `item-${index}`;
|
||||
acc[key] = {
|
||||
label: key,
|
||||
color: COLORS[index % COLORS.length],
|
||||
};
|
||||
return acc;
|
||||
}, {} as ChartConfig);
|
||||
|
||||
const renderChart = () => {
|
||||
switch (type) {
|
||||
case 'radar':
|
||||
return (
|
||||
<RadarChart data={data}>
|
||||
<PolarGrid className="stroke-muted" />
|
||||
<PolarAngleAxis dataKey={groupBy} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 'dataMax']} />
|
||||
<Radar
|
||||
name="Requests"
|
||||
dataKey="count"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="hsl(var(--primary))"
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
{data[0]?.avgLatency && (
|
||||
<Radar
|
||||
name="Avg Latency"
|
||||
dataKey="avgLatency"
|
||||
stroke="hsl(var(--chart-2))"
|
||||
fill="hsl(var(--chart-2))"
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
)}
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</RadarChart>
|
||||
);
|
||||
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey={groupBy}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]}>
|
||||
{data.map((_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
{data[0]?.errorRate !== undefined && (
|
||||
<Bar dataKey="errorRate" fill="hsl(var(--destructive))" radius={[4, 4, 0, 0]} />
|
||||
)}
|
||||
</BarChart>
|
||||
);
|
||||
|
||||
default: // pie
|
||||
return (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ percentage }) => `${(percentage * 100).toFixed(0)}%`}
|
||||
outerRadius={120}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey={groupBy}
|
||||
>
|
||||
{data.map((_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number) => value.toLocaleString()}
|
||||
labelFormatter={(label) => `${groupBy}: ${label}`}
|
||||
/>
|
||||
{showLegend && <Legend />}
|
||||
</PieChart>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</div>
|
||||
{onGroupByChange && (
|
||||
<Select value={groupBy} onValueChange={onGroupByChange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="endpoint">By Endpoint</SelectItem>
|
||||
<SelectItem value="method">By Method</SelectItem>
|
||||
<SelectItem value="status">By Status</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
<ChartContainer config={chartConfig} className="h-[350px] w-full">
|
||||
{renderChart()}
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Calendar } from '../../ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
||||
|
||||
export interface TimeRangeSelectorProps {
|
||||
onRangeChange?: (range: DateRange | undefined) => void;
|
||||
onPresetChange?: (preset: string) => void;
|
||||
showPresets?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ value: '1h', label: 'Last hour' },
|
||||
{ value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
{ value: '90d', label: 'Last 90 days' },
|
||||
{ value: 'custom', label: 'Custom range' },
|
||||
];
|
||||
|
||||
export function TimeRangeSelector({
|
||||
onRangeChange,
|
||||
onPresetChange,
|
||||
showPresets = true,
|
||||
className,
|
||||
}: TimeRangeSelectorProps) {
|
||||
const [date, setDate] = useState<DateRange | undefined>();
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('7d');
|
||||
|
||||
const handlePresetChange = (value: string) => {
|
||||
setSelectedPreset(value);
|
||||
if (value === 'custom') {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date();
|
||||
|
||||
switch (value) {
|
||||
case '1h':
|
||||
from.setHours(from.getHours() - 1);
|
||||
break;
|
||||
case '24h':
|
||||
from.setDate(from.getDate() - 1);
|
||||
break;
|
||||
case '7d':
|
||||
from.setDate(from.getDate() - 7);
|
||||
break;
|
||||
case '30d':
|
||||
from.setDate(from.getDate() - 30);
|
||||
break;
|
||||
case '90d':
|
||||
from.setDate(from.getDate() - 90);
|
||||
break;
|
||||
}
|
||||
|
||||
const range = { from, to: now };
|
||||
setDate(range);
|
||||
onRangeChange?.(range);
|
||||
onPresetChange?.(value);
|
||||
};
|
||||
|
||||
const handleDateChange = (newDate: DateRange | undefined) => {
|
||||
setDate(newDate);
|
||||
onRangeChange?.(newDate);
|
||||
if (newDate) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange?.('custom');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{showPresets && (
|
||||
<Select value={selectedPreset} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{presets.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{selectedPreset === 'custom' && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!date && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date?.from ? (
|
||||
date.to ? (
|
||||
<>
|
||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
||||
</>
|
||||
) : (
|
||||
format(date.from, 'LLL dd, y')
|
||||
)
|
||||
) : (
|
||||
<span>Pick a date range</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={date?.from}
|
||||
selected={date}
|
||||
onSelect={handleDateChange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Area, AreaChart, ResponsiveContainer, XAxis, YAxis } from 'recharts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '../../ui/chart';
|
||||
|
||||
type TimeRange = '24h' | '7d' | '30d' | '90d';
|
||||
|
||||
interface UsageChartProps {
|
||||
timeRange: TimeRange;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
usage: {
|
||||
label: 'Usage',
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
};
|
||||
|
||||
export function UsageChart({ timeRange, height = 300 }: UsageChartProps) {
|
||||
// Generate mock data based on time range
|
||||
const generateUsageData = () => {
|
||||
const days = timeRange === '24h' ? 24 : timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
|
||||
const points = Math.min(days, 30); // Limit to 30 data points for readability
|
||||
|
||||
return Array.from({ length: points }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (points - 1 - i));
|
||||
|
||||
return {
|
||||
date:
|
||||
timeRange === '24h'
|
||||
? date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
||||
: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
||||
usage: Math.floor(Math.random() * 1000) + 200,
|
||||
previousUsage: Math.floor(Math.random() * 800) + 150,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const data = generateUsageData();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage Trends</CardTitle>
|
||||
<CardDescription>
|
||||
Historical usage patterns and growth metrics over the selected time period
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="colorUsage" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usage"
|
||||
stroke="hsl(var(--primary))"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUsage)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export type { UsageChartProps };
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './MetricsCard';
|
||||
export * from './ActivityChart';
|
||||
export * from './RequestPatternChart';
|
||||
export * from './PerformanceMetrics';
|
||||
export * from './TimeRangeSelector';
|
||||
export * from './UsageChart';
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Alert, AlertDescription } from '../../ui/alert';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
|
||||
interface DNSInstructionsProps {
|
||||
domain: string;
|
||||
verificationCode: string;
|
||||
onCopyCode?: () => void;
|
||||
}
|
||||
|
||||
export function DNSInstructions({ domain, verificationCode, onCopyCode }: DNSInstructionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(verificationCode);
|
||||
setCopied(true);
|
||||
onCopyCode?.();
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const txtRecord = `sonr-domain-verification=${verificationCode}`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DNS Verification Instructions</CardTitle>
|
||||
<CardDescription>
|
||||
Add the following TXT record to your DNS settings to verify domain ownership.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Domain</label>
|
||||
<div className="p-3 bg-muted rounded-md font-mono text-sm">{domain}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Record Type</label>
|
||||
<div className="p-3 bg-muted rounded-md font-mono text-sm">TXT</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Record Name/Host</label>
|
||||
<div className="p-3 bg-muted rounded-md font-mono text-sm">
|
||||
@ (or leave empty for root domain)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Record Value</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 p-3 bg-muted rounded-md font-mono text-sm break-all">
|
||||
{txtRecord}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy} className="shrink-0">
|
||||
{copied ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<strong>Note:</strong> DNS propagation can take up to 24 hours, but typically takes 5-15
|
||||
minutes. Once you've added the TXT record, click "Verify Domain" to complete the
|
||||
verification process.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="pt-4 space-y-2">
|
||||
<h4 className="font-medium">Common DNS Providers</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>
|
||||
• <strong>Cloudflare:</strong> DNS → Records → Add record
|
||||
</li>
|
||||
<li>
|
||||
• <strong>GoDaddy:</strong> DNS Management → TXT records
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Namecheap:</strong> Domain List → Manage → Advanced DNS
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Google Domains:</strong> DNS → Custom records
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { Copy, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table';
|
||||
|
||||
export interface DNSRecord {
|
||||
type: 'TXT' | 'A' | 'AAAA' | 'CNAME' | 'MX';
|
||||
name: string;
|
||||
value: string;
|
||||
ttl: number;
|
||||
priority?: number;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
interface DNSRecordDisplayProps {
|
||||
domain: string;
|
||||
records: DNSRecord[];
|
||||
onRefresh: () => void;
|
||||
onCopy: (record: DNSRecord) => void;
|
||||
isRefreshing?: boolean;
|
||||
providerInstructions?: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function DNSRecordDisplay({
|
||||
domain,
|
||||
records,
|
||||
onRefresh,
|
||||
onCopy,
|
||||
isRefreshing = false,
|
||||
providerInstructions,
|
||||
}: DNSRecordDisplayProps) {
|
||||
const [copiedRecord, setCopiedRecord] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = (record: DNSRecord) => {
|
||||
const recordString = `${record.name} ${record.type} ${record.value}`;
|
||||
onCopy(record);
|
||||
setCopiedRecord(recordString);
|
||||
setTimeout(() => setCopiedRecord(null), 2000);
|
||||
};
|
||||
|
||||
const formatTTL = (ttl: number) => {
|
||||
if (ttl < 60) return `${ttl}s`;
|
||||
if (ttl < 3600) return `${Math.floor(ttl / 60)}m`;
|
||||
return `${Math.floor(ttl / 3600)}h`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>DNS Records</CardTitle>
|
||||
<CardDescription>
|
||||
Configure these records in your DNS provider for {domain}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{providerInstructions && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-blue-900">
|
||||
Need help? View instructions for {providerInstructions.name}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(providerInstructions.url, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>TTL</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((record, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{record.type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{record.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm max-w-[300px] truncate">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="truncate">{record.value}</span>
|
||||
{record.priority && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
Priority: {record.priority}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatTTL(record.ttl)}</TableCell>
|
||||
<TableCell>
|
||||
{record.verified ? (
|
||||
<Badge variant="default" className="bg-green-500">
|
||||
Verified
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Pending</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleCopy(record)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{copiedRecord && (
|
||||
<div className="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
Record copied to clipboard!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium mb-2">Quick Setup Guide</h4>
|
||||
<ol className="text-sm text-muted-foreground space-y-1">
|
||||
<li>1. Log in to your DNS provider's control panel</li>
|
||||
<li>2. Navigate to DNS management for {domain}</li>
|
||||
<li>3. Add each record shown above</li>
|
||||
<li>4. Save changes and wait for propagation (up to 48 hours)</li>
|
||||
<li>5. Click "Refresh" to check verification status</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Filter,
|
||||
Globe,
|
||||
Lock,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Progress } from '../../ui/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
|
||||
|
||||
// Types
|
||||
interface Domain {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'pending' | 'expired' | 'failed';
|
||||
verifiedAt?: Date;
|
||||
expiresAt?: Date;
|
||||
dnsRecords: DNSRecord[];
|
||||
ssl: {
|
||||
enabled: boolean;
|
||||
issuer?: string;
|
||||
expiresAt?: Date;
|
||||
};
|
||||
analytics: {
|
||||
requests: number;
|
||||
bandwidth: number;
|
||||
uptime: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DNSRecord {
|
||||
type: 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS';
|
||||
name: string;
|
||||
value: string;
|
||||
ttl: number;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockDomains: Domain[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'app.sonr.io',
|
||||
status: 'active',
|
||||
verifiedAt: new Date('2024-01-15'),
|
||||
expiresAt: new Date('2025-01-15'),
|
||||
dnsRecords: [
|
||||
{ type: 'A', name: '@', value: '192.168.1.1', ttl: 3600 },
|
||||
{ type: 'CNAME', name: 'www', value: 'app.sonr.io', ttl: 3600 },
|
||||
{ type: 'TXT', name: '_verification', value: 'sonr-verify-abc123', ttl: 300 },
|
||||
],
|
||||
ssl: {
|
||||
enabled: true,
|
||||
issuer: "Let's Encrypt",
|
||||
expiresAt: new Date('2024-12-31'),
|
||||
},
|
||||
analytics: {
|
||||
requests: 15234,
|
||||
bandwidth: 2.4,
|
||||
uptime: 99.9,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'api.sonr.io',
|
||||
status: 'active',
|
||||
verifiedAt: new Date('2024-02-01'),
|
||||
expiresAt: new Date('2025-02-01'),
|
||||
dnsRecords: [{ type: 'A', name: '@', value: '192.168.1.2', ttl: 3600 }],
|
||||
ssl: {
|
||||
enabled: true,
|
||||
issuer: "Let's Encrypt",
|
||||
expiresAt: new Date('2024-12-31'),
|
||||
},
|
||||
analytics: {
|
||||
requests: 45678,
|
||||
bandwidth: 8.7,
|
||||
uptime: 99.99,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'docs.sonr.io',
|
||||
status: 'pending',
|
||||
dnsRecords: [{ type: 'TXT', name: '_verification', value: 'sonr-verify-xyz789', ttl: 300 }],
|
||||
ssl: {
|
||||
enabled: false,
|
||||
},
|
||||
analytics: {
|
||||
requests: 0,
|
||||
bandwidth: 0,
|
||||
uptime: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function DomainDashboard() {
|
||||
const [domains] = useState<Domain[]>(mockDomains);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTab, setSelectedTab] = useState('all');
|
||||
|
||||
const filteredDomains = domains.filter((domain) => {
|
||||
if (selectedTab !== 'all' && domain.status !== selectedTab) return false;
|
||||
if (searchQuery && !domain.name.includes(searchQuery)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: domains.length,
|
||||
active: domains.filter((d) => d.status === 'active').length,
|
||||
pending: domains.filter((d) => d.status === 'pending').length,
|
||||
expired: domains.filter((d) => d.status === 'expired').length,
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: Domain['status']) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <CheckCircle className="h-4 w-4 text-emerald-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||
case 'expired':
|
||||
return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Domain['status']) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20';
|
||||
case 'pending':
|
||||
return 'bg-amber-500/10 text-amber-600 border-amber-500/20';
|
||||
case 'expired':
|
||||
return 'bg-red-500/10 text-red-600 border-red-500/20';
|
||||
case 'failed':
|
||||
return 'bg-red-600/10 text-red-700 border-red-600/20';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 p-6 space-y-8">
|
||||
{/* Header with Glassmorphism Effect */}
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 p-[2px]">
|
||||
<div className="relative backdrop-blur-xl bg-white/90 dark:bg-slate-900/90 rounded-2xl p-8">
|
||||
<div className="absolute inset-0 bg-grid-slate-100/50 dark:bg-grid-slate-800/50 [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)]" />
|
||||
<div className="relative">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 dark:from-indigo-400 dark:to-purple-400 bg-clip-text text-transparent">
|
||||
Domain Management
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400 mt-2">
|
||||
Manage your domains, DNS records, and SSL certificates
|
||||
</p>
|
||||
</div>
|
||||
<Button className="bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white shadow-lg shadow-purple-500/25">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Domain
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-8">
|
||||
{[
|
||||
{
|
||||
label: 'Total Domains',
|
||||
value: stats.total,
|
||||
icon: Globe,
|
||||
color: 'from-blue-500 to-indigo-500',
|
||||
},
|
||||
{
|
||||
label: 'Active',
|
||||
value: stats.active,
|
||||
icon: CheckCircle,
|
||||
color: 'from-emerald-500 to-green-500',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: stats.pending,
|
||||
icon: Clock,
|
||||
color: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
{
|
||||
label: 'Expired',
|
||||
value: stats.expired,
|
||||
icon: AlertTriangle,
|
||||
color: 'from-red-500 to-pink-500',
|
||||
},
|
||||
].map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="backdrop-blur-sm bg-white/50 dark:bg-slate-800/50 border-white/20 dark:border-slate-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{stat.label}</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-2 bg-gradient-to-r ${stat.color} bg-clip-text text-transparent`}
|
||||
>
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`rounded-full p-3 bg-gradient-to-r ${stat.color}`}>
|
||||
<stat.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search domains..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-white/70 dark:bg-slate-800/70 backdrop-blur-sm border-slate-200/50 dark:border-slate-700/50"
|
||||
/>
|
||||
</div>
|
||||
<Select defaultValue="all">
|
||||
<SelectTrigger className="w-[180px] bg-white/70 dark:bg-slate-800/70 backdrop-blur-sm border-slate-200/50 dark:border-slate-700/50">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Domains</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-white/70 dark:bg-slate-800/70 backdrop-blur-sm border-slate-200/50 dark:border-slate-700/50"
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
More Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Domains Grid with Tabs */}
|
||||
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="space-y-6">
|
||||
<TabsList className="bg-white/70 dark:bg-slate-800/70 backdrop-blur-sm border-slate-200/50 dark:border-slate-700/50">
|
||||
<TabsTrigger value="all">All Domains</TabsTrigger>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="pending">Pending</TabsTrigger>
|
||||
<TabsTrigger value="expired">Expired</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={selectedTab} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredDomains.map((domain, index) => (
|
||||
<motion.div
|
||||
key={domain.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="group"
|
||||
>
|
||||
<Card className="relative overflow-hidden backdrop-blur-sm bg-white/70 dark:bg-slate-800/70 border-slate-200/50 dark:border-slate-700/50 hover:shadow-xl hover:shadow-purple-500/10 transition-all duration-300 cursor-pointer">
|
||||
{/* Animated Background Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/5 via-purple-500/5 to-pink-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-indigo-500" />
|
||||
<CardTitle className="text-lg font-semibold">{domain.name}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{domain.verifiedAt
|
||||
? `Verified ${domain.verifiedAt.toLocaleDateString()}`
|
||||
: 'Pending verification'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={cn('border', getStatusColor(domain.status))}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getStatusIcon(domain.status)}
|
||||
{domain.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* SSL Status */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-900/50">
|
||||
<div className="flex items-center gap-2">
|
||||
{domain.ssl.enabled ? (
|
||||
<>
|
||||
<Lock className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm text-emerald-600 dark:text-emerald-400">
|
||||
SSL Active
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="h-4 w-4 text-slate-400" />
|
||||
<span className="text-sm text-slate-500">SSL Inactive</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{domain.ssl.issuer && (
|
||||
<span className="text-xs text-slate-500">{domain.ssl.issuer}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DNS Records Count */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-900/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
DNS Records
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{domain.dnsRecords.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Analytics Preview */}
|
||||
{domain.status === 'active' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Uptime</span>
|
||||
<span className="font-medium text-emerald-600">
|
||||
{domain.analytics.uptime}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={domain.analytics.uptime} className="h-1.5" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Activity className="h-3 w-3 text-indigo-500" />
|
||||
<span className="text-xs text-slate-500">
|
||||
{domain.analytics.requests.toLocaleString()} reqs
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-purple-500" />
|
||||
<span className="text-xs text-slate-500">
|
||||
{domain.analytics.bandwidth} GB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Status Message */}
|
||||
{domain.status === 'pending' && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800">
|
||||
<RefreshCw className="h-4 w-4 text-amber-600 animate-spin" />
|
||||
<span className="text-xs text-amber-700 dark:text-amber-400">
|
||||
DNS propagation in progress...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<Button variant="ghost" className="w-full group/button">
|
||||
<span>Manage Domain</span>
|
||||
<ChevronRight className="ml-2 h-4 w-4 transition-transform group-hover/button:translate-x-1" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
{/* Animated Border Gradient */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-[2px] bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-500" />
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredDomains.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-12"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-indigo-500/20 to-purple-500/20 mb-4">
|
||||
<Globe className="h-8 w-8 text-indigo-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
No domains found
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">
|
||||
{searchQuery
|
||||
? 'Try adjusting your search criteria'
|
||||
: 'Get started by adding your first domain'}
|
||||
</p>
|
||||
<Button className="bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Domain
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<Card className="backdrop-blur-sm bg-gradient-to-br from-indigo-500/5 via-purple-500/5 to-pink-500/5 border-purple-200/20 dark:border-purple-800/20">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-purple-500" />
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm"
|
||||
>
|
||||
<Shield className="mr-2 h-4 w-4 text-blue-500" />
|
||||
Verify Domain Ownership
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm"
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4 text-purple-500" />
|
||||
Configure DNS Records
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start bg-white/50 dark:bg-slate-800/50 backdrop-blur-sm"
|
||||
>
|
||||
<BarChart3 className="mr-2 h-4 w-4 text-indigo-500" />
|
||||
View Analytics Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle, Clock, ExternalLink, XCircle } from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table';
|
||||
|
||||
export interface Domain {
|
||||
id: string;
|
||||
domain: string;
|
||||
status: 'verified' | 'pending' | 'failed';
|
||||
verifiedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface DomainListProps {
|
||||
domains: Domain[];
|
||||
onVerifyDomain?: (domainId: string) => void;
|
||||
onDeleteDomain?: (domainId: string) => void;
|
||||
}
|
||||
|
||||
export function DomainList({ domains, onVerifyDomain, onDeleteDomain }: DomainListProps) {
|
||||
const getStatusIcon = (status: Domain['status']) => {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: Domain['status']) => {
|
||||
const variants = {
|
||||
verified: 'default' as const,
|
||||
pending: 'secondary' as const,
|
||||
failed: 'destructive' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status]} className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No domains registered yet.</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Add your first domain to get started with service registration.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Registered Domains</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Domain</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Verified</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(domain.status)}
|
||||
{domain.domain}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(domain.status)}</TableCell>
|
||||
<TableCell>
|
||||
{domain.verifiedAt ? new Date(domain.verifiedAt).toLocaleDateString() : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(domain.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{domain.status === 'failed' && onVerifyDomain && (
|
||||
<Button variant="outline" size="sm" onClick={() => onVerifyDomain(domain.id)}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(`https://${domain.domain}`, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
{onDeleteDomain && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteDomain(domain.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
||||
import type { Domain } from './DomainList';
|
||||
|
||||
interface DomainSelectorProps {
|
||||
domains: Domain[];
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
showStatus?: boolean;
|
||||
}
|
||||
|
||||
export function DomainSelector({
|
||||
domains,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = 'Select a domain',
|
||||
showStatus = true,
|
||||
}: DomainSelectorProps) {
|
||||
const getStatusIcon = (status: Domain['status']) => {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const verifiedDomains = domains.filter((domain) => domain.status === 'verified');
|
||||
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<Select disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No domains available" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{verifiedDomains.length > 0 &&
|
||||
verifiedDomains.map((domain) => (
|
||||
<SelectItem key={domain.id} value={domain.domain}>
|
||||
<div className="flex items-center gap-2">
|
||||
{showStatus && getStatusIcon(domain.status)}
|
||||
<span>{domain.domain}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{domains.filter((d) => d.status !== 'verified').length > 0 &&
|
||||
domains
|
||||
.filter((domain) => domain.status !== 'verified')
|
||||
.map((domain) => (
|
||||
<SelectItem
|
||||
key={domain.id}
|
||||
value={domain.domain}
|
||||
disabled={domain.status !== 'verified'}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showStatus && getStatusIcon(domain.status)}
|
||||
<span className="text-muted-foreground">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground">({domain.status})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { AlertTriangle, CheckCircle, Clock, Globe, XCircle } from 'lucide-react';
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
|
||||
export type DomainStatusType = 'verified' | 'pending' | 'failed' | 'expired';
|
||||
|
||||
// Glass filter for glassmorphism effect
|
||||
const GlassFilter: React.FC = () => (
|
||||
<svg style={{ display: 'none' }}>
|
||||
<filter
|
||||
id="glass-distortion"
|
||||
x="0%"
|
||||
y="0%"
|
||||
width="100%"
|
||||
height="100%"
|
||||
filterUnits="objectBoundingBox"
|
||||
>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.001 0.005"
|
||||
numOctaves="1"
|
||||
seed="17"
|
||||
result="turbulence"
|
||||
/>
|
||||
<feGaussianBlur in="turbulence" stdDeviation="3" result="softMap" />
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="softMap"
|
||||
scale="20"
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</filter>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Animated pulse effect for status indicator
|
||||
const PulseIndicator: React.FC<{ color: string }> = ({ color }) => {
|
||||
return (
|
||||
<span className="relative flex h-3 w-3 mr-2">
|
||||
<span
|
||||
className={`animate-ping absolute inline-flex h-full w-full rounded-full ${color} opacity-75`}
|
||||
/>
|
||||
<span className={`relative inline-flex rounded-full h-3 w-3 ${color}`} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface DomainStatusProps {
|
||||
domain: string;
|
||||
status: DomainStatusType;
|
||||
verifiedAt?: Date;
|
||||
expiresAt?: Date;
|
||||
lastCheckAt?: Date;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function DomainStatus({
|
||||
domain,
|
||||
status,
|
||||
verifiedAt,
|
||||
expiresAt,
|
||||
lastCheckAt,
|
||||
errorMessage,
|
||||
}: DomainStatusProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const statusConfig = {
|
||||
verified: {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-500',
|
||||
pulseColor: 'bg-green-500',
|
||||
bgColor: 'bg-green-50 dark:bg-green-950/30',
|
||||
borderGradient: 'from-green-300 via-green-500 to-emerald-500',
|
||||
label: 'Verified',
|
||||
badgeVariant: 'default' as const,
|
||||
},
|
||||
pending: {
|
||||
icon: Clock,
|
||||
color: 'text-yellow-500',
|
||||
pulseColor: 'bg-yellow-500',
|
||||
bgColor: 'bg-yellow-50 dark:bg-yellow-950/30',
|
||||
borderGradient: 'from-yellow-300 via-yellow-500 to-amber-500',
|
||||
label: 'Pending Verification',
|
||||
badgeVariant: 'secondary' as const,
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-500',
|
||||
pulseColor: 'bg-red-500',
|
||||
bgColor: 'bg-red-50 dark:bg-red-950/30',
|
||||
borderGradient: 'from-red-300 via-red-500 to-rose-500',
|
||||
label: 'Verification Failed',
|
||||
badgeVariant: 'destructive' as const,
|
||||
},
|
||||
expired: {
|
||||
icon: AlertTriangle,
|
||||
color: 'text-orange-500',
|
||||
pulseColor: 'bg-orange-500',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-950/30',
|
||||
borderGradient: 'from-orange-300 via-orange-500 to-amber-500',
|
||||
label: 'Expired',
|
||||
badgeVariant: 'outline' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlassFilter />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="relative overflow-hidden border-0 shadow-lg bg-background/80 backdrop-blur-md">
|
||||
{/* Gradient border effect */}
|
||||
<div
|
||||
className={`absolute inset-0 p-[2px] rounded-lg bg-gradient-to-r ${config.borderGradient} opacity-70`}
|
||||
style={{ filter: 'blur(0.5px)' }}
|
||||
/>
|
||||
|
||||
{/* Glass effect background */}
|
||||
<div
|
||||
className="absolute inset-[2px] rounded-lg bg-background/90 backdrop-blur-sm"
|
||||
style={{ filter: 'url(#glass-distortion)' }}
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Globe className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<CardTitle className="text-lg font-medium">
|
||||
<span className="bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||
{domain}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>Domain verification status</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={config.badgeVariant}
|
||||
className="transition-all duration-300 hover:scale-105"
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className={`${config.bgColor} rounded-lg p-4 space-y-3 backdrop-blur-sm transition-all duration-300 hover:shadow-md`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<PulseIndicator color={config.pulseColor} />
|
||||
<Icon className={`h-5 w-5 ${config.color}`} />
|
||||
<span className={`font-medium ${config.color}`}>{config.label}</span>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorMessage && status === 'failed' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="text-sm text-red-600 bg-red-50 dark:bg-red-950/30 p-3 rounded-md"
|
||||
>
|
||||
<p className="font-medium">Error:</p>
|
||||
<p>{errorMessage}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
{verifiedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span>Verified at:</span>
|
||||
<span>{verifiedAt.toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{expiresAt && (
|
||||
<div className="flex justify-between">
|
||||
<span>Expires at:</span>
|
||||
<span className={status === 'expired' ? `${config.color} font-medium` : ''}>
|
||||
{expiresAt.toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{lastCheckAt && (
|
||||
<div className="flex justify-between">
|
||||
<span>Last checked:</span>
|
||||
<span>{lastCheckAt.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{status === 'pending' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800/50 rounded-lg"
|
||||
>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300">
|
||||
DNS propagation can take up to 48 hours. We'll check periodically and notify
|
||||
you once verification is complete.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{status === 'expired' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-4 p-3 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800/50 rounded-lg"
|
||||
>
|
||||
<p className="text-sm text-orange-800 dark:text-orange-300">
|
||||
Your domain verification has expired. Please re-verify to continue using this
|
||||
domain.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, ArrowRight, CheckCircle, Copy, XCircle } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '../../ui/alert';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Progress } from '../../ui/progress';
|
||||
|
||||
export interface VerificationStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface DomainVerificationFlowProps {
|
||||
domain: string;
|
||||
verificationCode: string;
|
||||
steps: VerificationStep[];
|
||||
currentStep: number;
|
||||
onVerify: () => void;
|
||||
onRetry: () => void;
|
||||
onCopyRecord: (record: string) => void;
|
||||
}
|
||||
|
||||
export function DomainVerificationFlow({
|
||||
domain,
|
||||
verificationCode,
|
||||
steps,
|
||||
currentStep,
|
||||
onVerify,
|
||||
onRetry,
|
||||
onCopyRecord,
|
||||
}: DomainVerificationFlowProps) {
|
||||
const [copiedRecord, setCopiedRecord] = useState<string | null>(null);
|
||||
const progress = (currentStep / steps.length) * 100;
|
||||
|
||||
const handleCopyRecord = (record: string) => {
|
||||
onCopyRecord(record);
|
||||
setCopiedRecord(record);
|
||||
setTimeout(() => setCopiedRecord(null), 2000);
|
||||
};
|
||||
|
||||
const dnsRecord = `_sonr.${domain} TXT "${verificationCode}"`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Domain Ownership</CardTitle>
|
||||
<CardDescription>Follow the steps below to verify ownership of {domain}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Progress Bar */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>Progress</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* DNS Record Information */}
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Add TXT Record</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">Add the following TXT record to your domain's DNS settings:</p>
|
||||
<div className="bg-muted p-3 rounded-md font-mono text-sm relative">
|
||||
<code>{dnsRecord}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={() => handleCopyRecord(dnsRecord)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
{copiedRecord === dnsRecord && (
|
||||
<span className="absolute right-12 top-3 text-xs text-green-600">Copied!</span>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Verification Steps */}
|
||||
<div className="space-y-4">
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{step.status === 'completed' ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
) : step.status === 'failed' ? (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
) : step.status === 'in_progress' ? (
|
||||
<div className="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<div className="h-5 w-5 border-2 border-muted rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
step.status === 'completed'
|
||||
? 'text-green-600'
|
||||
: step.status === 'failed'
|
||||
? 'text-red-600'
|
||||
: step.status === 'in_progress'
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
onClick={onVerify}
|
||||
disabled={steps.some((s) => s.status === 'in_progress')}
|
||||
className="flex-1"
|
||||
>
|
||||
Start Verification
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
{steps.some((s) => s.status === 'failed') && (
|
||||
<Button onClick={onRetry} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Activity, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Progress } from '../../ui/progress';
|
||||
|
||||
export interface VerificationCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'checking' | 'passed' | 'failed';
|
||||
message?: string;
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
interface VerificationProgressProps {
|
||||
domain: string;
|
||||
checks: VerificationCheck[];
|
||||
overallProgress: number;
|
||||
estimatedTime?: string;
|
||||
onRetryCheck?: (checkId: string) => void;
|
||||
}
|
||||
|
||||
export function VerificationProgress({
|
||||
domain,
|
||||
checks,
|
||||
overallProgress,
|
||||
estimatedTime,
|
||||
onRetryCheck,
|
||||
}: VerificationProgressProps) {
|
||||
const passedChecks = checks.filter((c) => c.status === 'passed').length;
|
||||
const totalChecks = checks.length;
|
||||
|
||||
const getStatusIcon = (status: VerificationCheck['status']) => {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'checking':
|
||||
return <Activity className="h-4 w-4 text-blue-500 animate-pulse" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: VerificationCheck['status']) => {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'text-green-600';
|
||||
case 'failed':
|
||||
return 'text-red-600';
|
||||
case 'checking':
|
||||
return 'text-blue-600';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification Progress</CardTitle>
|
||||
<CardDescription>Checking domain ownership for {domain}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Overall Progress */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{passedChecks} of {totalChecks} checks passed
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{Math.round(overallProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={overallProgress} className="h-3" />
|
||||
{estimatedTime && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Estimated time remaining: {estimatedTime}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Individual Checks */}
|
||||
<div className="space-y-3">
|
||||
{checks.map((check) => (
|
||||
<div
|
||||
key={check.id}
|
||||
className="flex items-start justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getStatusIcon(check.status)}
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${getStatusColor(check.status)}`}>
|
||||
{check.name}
|
||||
</p>
|
||||
{check.message && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{check.message}</p>
|
||||
)}
|
||||
{check.timestamp && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{check.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{check.status === 'checking' && (
|
||||
<Badge variant="secondary" className="animate-pulse">
|
||||
Checking...
|
||||
</Badge>
|
||||
)}
|
||||
{check.status === 'failed' && onRetryCheck && (
|
||||
<button
|
||||
onClick={() => onRetryCheck(check.id)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status Summary */}
|
||||
{overallProgress === 100 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-900">Verification Complete</p>
|
||||
<p className="text-xs text-green-700 mt-1">
|
||||
Your domain has been successfully verified and is ready to use.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checks.some((c) => c.status === 'failed') && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-900">Verification Issues</p>
|
||||
<p className="text-xs text-red-700 mt-1">
|
||||
Some checks have failed. Please review the DNS records and try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './DomainVerificationFlow';
|
||||
export * from './DomainStatus';
|
||||
export * from './DNSRecordDisplay';
|
||||
export * from './VerificationProgress';
|
||||
export * from './DomainDashboard';
|
||||
export * from './DomainList';
|
||||
export * from './DomainSelector';
|
||||
export * from './DNSInstructions';
|
||||
|
||||
// Aliases for expected component names
|
||||
export { DomainVerificationFlow as VerificationWizard } from './DomainVerificationFlow';
|
||||
export { DomainStatus as VerificationStatus } from './DomainStatus';
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { Home } from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '../../ui/breadcrumb';
|
||||
|
||||
/**
|
||||
* Breadcrumb item structure
|
||||
*/
|
||||
export interface BreadcrumbNavItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for BreadcrumbNav component
|
||||
*/
|
||||
export interface BreadcrumbNavProps {
|
||||
items: BreadcrumbNavItem[];
|
||||
onNavigate?: (href: string) => void;
|
||||
showHome?: boolean;
|
||||
homeHref?: string;
|
||||
homeLabel?: string;
|
||||
maxItems?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb navigation component for dashboard
|
||||
*/
|
||||
export function BreadcrumbNav({
|
||||
items,
|
||||
onNavigate,
|
||||
showHome = true,
|
||||
homeHref = '/dashboard',
|
||||
homeLabel = 'Dashboard',
|
||||
maxItems = 4,
|
||||
className,
|
||||
}: BreadcrumbNavProps) {
|
||||
// Handle ellipsis for long breadcrumb trails
|
||||
const displayItems =
|
||||
items.length > maxItems
|
||||
? [
|
||||
items[0],
|
||||
{ label: '...', href: undefined, current: false },
|
||||
...items.slice(-(maxItems - 2)),
|
||||
]
|
||||
: items;
|
||||
|
||||
return (
|
||||
<Breadcrumb className={cn('mb-4', className)}>
|
||||
<BreadcrumbList>
|
||||
{showHome && (
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
{items.length > 0 ? (
|
||||
<BreadcrumbLink
|
||||
asChild
|
||||
className="cursor-pointer"
|
||||
onClick={() => onNavigate?.(homeHref)}
|
||||
>
|
||||
<a>
|
||||
<Home className="h-4 w-4" />
|
||||
<span className="ml-2">{homeLabel}</span>
|
||||
</a>
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>
|
||||
<Home className="h-4 w-4" />
|
||||
<span className="ml-2">{homeLabel}</span>
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{items.length > 0 && <BreadcrumbSeparator />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayItems.map((item, index) => {
|
||||
if (!item) return null;
|
||||
const isLast = index === displayItems.length - 1;
|
||||
const isEllipsis = item.label === '...';
|
||||
|
||||
return (
|
||||
<BreadcrumbItem key={index}>
|
||||
{isEllipsis ? (
|
||||
<BreadcrumbEllipsis />
|
||||
) : isLast || item.current ? (
|
||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink
|
||||
asChild
|
||||
className="cursor-pointer"
|
||||
onClick={() => item.href && onNavigate?.(item.href)}
|
||||
>
|
||||
<a>{item.label}</a>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
{!isLast && !isEllipsis && <BreadcrumbSeparator />}
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type React from 'react';
|
||||
|
||||
export interface DashboardContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Content Container
|
||||
* Provides consistent styling and layout for dashboard page content
|
||||
*/
|
||||
export function DashboardContent({ children, className }: DashboardContentProps) {
|
||||
return <main className={`flex-1 space-y-4 p-8 pt-6 ${className || ''}`}>{children}</main>;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
CreditCard,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
Moon,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
User,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../ui/dropdown-menu';
|
||||
import { Input } from '../../ui/input';
|
||||
|
||||
/**
|
||||
* Props for DashboardHeader component
|
||||
*/
|
||||
export interface DashboardHeaderProps {
|
||||
user?: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
};
|
||||
notifications?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
unread?: boolean;
|
||||
timestamp?: Date;
|
||||
}>;
|
||||
onMenuClick?: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
onNotificationClick?: (id: string) => void;
|
||||
onProfileClick?: () => void;
|
||||
onSettingsClick?: () => void;
|
||||
onLogout?: () => void;
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
onThemeChange?: (theme: 'light' | 'dark' | 'system') => void;
|
||||
showSearch?: boolean;
|
||||
showNotifications?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header component for dashboard layout
|
||||
*/
|
||||
export function DashboardHeader({
|
||||
user,
|
||||
notifications = [],
|
||||
onMenuClick,
|
||||
onSearch,
|
||||
onNotificationClick,
|
||||
onProfileClick,
|
||||
onSettingsClick,
|
||||
onLogout,
|
||||
theme = 'system',
|
||||
onThemeChange,
|
||||
showSearch = true,
|
||||
showNotifications = true,
|
||||
className,
|
||||
}: DashboardHeaderProps) {
|
||||
const unreadCount = notifications.filter((n) => n.unread).length;
|
||||
|
||||
const getThemeIcon = () => {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return <Sun className="h-4 w-4" />;
|
||||
case 'dark':
|
||||
return <Moon className="h-4 w-4" />;
|
||||
default:
|
||||
return <Monitor className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn('flex h-14 items-center gap-4 border-b bg-background px-4 lg:px-6', className)}
|
||||
>
|
||||
{/* Mobile Menu Button */}
|
||||
<Button variant="ghost" size="sm" className="lg:hidden" onClick={onMenuClick}>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
|
||||
{/* Search Bar */}
|
||||
{showSearch && (
|
||||
<div className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search services, domains, or users..."
|
||||
className="pl-8 h-9"
|
||||
onChange={(e) => onSearch?.(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Theme Toggle */}
|
||||
{onThemeChange && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
{getThemeIcon()}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onThemeChange('light')}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
Light
|
||||
{theme === 'light' && <Check className="ml-auto h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onThemeChange('dark')}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
Dark
|
||||
{theme === 'dark' && <Check className="ml-auto h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onThemeChange('system')}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
System
|
||||
{theme === 'system' && <Check className="ml-auto h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
{showNotifications && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -right-1 -top-1 h-5 w-5 rounded-full p-0 text-xs"
|
||||
>
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
Notifications
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{unreadCount} new
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No new notifications
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{notifications.slice(0, 5).map((notification) => (
|
||||
<DropdownMenuItem
|
||||
key={notification.id}
|
||||
onClick={() => onNotificationClick?.(notification.id)}
|
||||
className="flex flex-col items-start gap-1 p-4"
|
||||
>
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<p className="text-sm font-medium">{notification.title}</p>
|
||||
{notification.unread && <div className="h-2 w-2 rounded-full bg-primary" />}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
{notification.timestamp && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(notification.timestamp).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{notifications.length > 5 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-center">
|
||||
<span className="text-sm">View all notifications</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Create New Menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Create New</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Service
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite User
|
||||
<DropdownMenuShortcut>⌘I</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
New API Key
|
||||
<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* User Menu */}
|
||||
{user && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.name} className="h-8 w-8 rounded-full" />
|
||||
) : (
|
||||
<User className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
|
||||
{user.role && (
|
||||
<Badge variant="secondary" className="mt-1 w-fit">
|
||||
{user.role}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={onProfileClick}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
<DropdownMenuShortcut>⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Billing
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onSettingsClick}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
<DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<HelpCircle className="mr-2 h-4 w-4" />
|
||||
Help & Support
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Contact Us
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onLogout} className="text-red-600 hover:text-red-700">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
<DropdownMenuShortcut>⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
ChevronRight,
|
||||
Database,
|
||||
FileText,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
Home,
|
||||
LogOut,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../ui/collapsible';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
} from '../../ui/sidebar';
|
||||
|
||||
/**
|
||||
* Navigation item structure
|
||||
*/
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: string | number;
|
||||
disabled?: boolean;
|
||||
external?: boolean;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for DashboardSidebar component
|
||||
*/
|
||||
export interface DashboardSidebarProps {
|
||||
items?: NavItem[];
|
||||
currentPath?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
onLogout?: () => void;
|
||||
user?: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Default navigation items
|
||||
const defaultItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: <Home className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Services',
|
||||
href: '/services',
|
||||
icon: <Server className="h-4 w-4" />,
|
||||
children: [
|
||||
{ title: 'All Services', href: '/services' },
|
||||
{ title: 'Create Service', href: '/services/create' },
|
||||
{ title: 'API Keys', href: '/services/keys' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Domains',
|
||||
href: '/domains',
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
badge: 'New',
|
||||
},
|
||||
{
|
||||
title: 'Permissions',
|
||||
href: '/permissions',
|
||||
icon: <Shield className="h-4 w-4" />,
|
||||
children: [
|
||||
{ title: 'Overview', href: '/permissions' },
|
||||
{ title: 'UCAN Tokens', href: '/permissions/ucan' },
|
||||
{ title: 'Audit Log', href: '/permissions/audit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
href: '/analytics',
|
||||
icon: <BarChart3 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Storage',
|
||||
href: '/storage',
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
href: '/users',
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Activity',
|
||||
href: '/activity',
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: '/docs',
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: '/settings',
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sidebar navigation for dashboard layout
|
||||
*/
|
||||
export function DashboardSidebar({
|
||||
items = defaultItems,
|
||||
currentPath = '/dashboard',
|
||||
onNavigate,
|
||||
onLogout,
|
||||
user,
|
||||
className,
|
||||
}: DashboardSidebarProps) {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpanded = (href: string) => {
|
||||
const newExpanded = new Set(expandedItems);
|
||||
if (newExpanded.has(href)) {
|
||||
newExpanded.delete(href);
|
||||
} else {
|
||||
newExpanded.add(href);
|
||||
}
|
||||
setExpandedItems(newExpanded);
|
||||
};
|
||||
|
||||
const handleNavigate = (href: string, external?: boolean) => {
|
||||
if (external) {
|
||||
window.open(href, '_blank');
|
||||
} else {
|
||||
onNavigate?.(href);
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = (href: string) => {
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
const renderNavItem = (item: NavItem) => {
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const expanded = expandedItems.has(item.href);
|
||||
const active = isActive(item.href);
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Collapsible key={item.href} open={expanded} onOpenChange={() => toggleExpanded(item.href)}>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton isActive={active} disabled={item.disabled}>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.children?.map((child) => (
|
||||
<SidebarMenuSubItem key={child.href}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={isActive(child.href)}
|
||||
disabled={child.disabled}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleNavigate(child.href, child.external)}
|
||||
className="w-full"
|
||||
>
|
||||
{child.icon}
|
||||
<span>{child.title}</span>
|
||||
{child.badge && <SidebarMenuBadge>{child.badge}</SidebarMenuBadge>}
|
||||
</button>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton asChild isActive={active} disabled={item.disabled}>
|
||||
<button onClick={() => handleNavigate(item.href, item.external)} className="w-full">
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar className={className} collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex h-12 items-center px-4">
|
||||
<h2 className="text-lg font-semibold">Sonr Services</h2>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<SidebarSeparator />
|
||||
<SidebarGroup>
|
||||
<div className="flex items-center gap-3 px-4 py-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.name} className="h-8 w-8 rounded-full" />
|
||||
) : (
|
||||
<User className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<p className="text-sm font-medium truncate">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>{items.map((item) => renderNavItem(item))}</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span>Help & Support</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{onLogout && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={onLogout}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../ui/sheet';
|
||||
import type { NavItem } from './DashboardSidebar';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
|
||||
/**
|
||||
* Props for MobileNav component
|
||||
*/
|
||||
export interface MobileNavProps {
|
||||
items?: NavItem[];
|
||||
currentPath?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
user?: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
};
|
||||
onLogout?: () => void;
|
||||
trigger?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile navigation drawer using Sheet component
|
||||
*/
|
||||
export function MobileNav({
|
||||
items,
|
||||
currentPath,
|
||||
onNavigate,
|
||||
user,
|
||||
onLogout,
|
||||
trigger,
|
||||
className,
|
||||
}: MobileNavProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleNavigate = (href: string) => {
|
||||
onNavigate?.(href);
|
||||
setOpen(false); // Close sheet after navigation
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="sm" className={className}>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle navigation menu</span>
|
||||
</Button>
|
||||
)}
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[280px] p-0">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<DashboardSidebar
|
||||
items={items}
|
||||
currentPath={currentPath}
|
||||
onNavigate={handleNavigate}
|
||||
user={user}
|
||||
onLogout={() => {
|
||||
onLogout?.();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="h-full border-r-0"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Button } from '../../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../ui/dropdown-menu';
|
||||
import { Toggle } from '../../ui/toggle';
|
||||
|
||||
/**
|
||||
* Props for ThemeToggle component
|
||||
*/
|
||||
export interface ThemeToggleProps {
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
onThemeChange?: (theme: 'light' | 'dark' | 'system') => void;
|
||||
variant?: 'toggle' | 'dropdown';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme toggle component for dark mode switching
|
||||
*/
|
||||
export function ThemeToggle({
|
||||
theme = 'system',
|
||||
onThemeChange,
|
||||
variant = 'dropdown',
|
||||
className,
|
||||
}: ThemeToggleProps) {
|
||||
if (variant === 'toggle') {
|
||||
return (
|
||||
<Toggle
|
||||
pressed={theme === 'dark'}
|
||||
onPressedChange={(pressed) => onThemeChange?.(pressed ? 'dark' : 'light')}
|
||||
aria-label="Toggle theme"
|
||||
className={className}
|
||||
>
|
||||
{theme === 'dark' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||
</Toggle>
|
||||
);
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return <Sun className="h-4 w-4" />;
|
||||
case 'dark':
|
||||
return <Moon className="h-4 w-4" />;
|
||||
default:
|
||||
return <Monitor className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className={cn('w-9 px-0', className)}>
|
||||
{getIcon()}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onThemeChange?.('light')}
|
||||
className={cn(theme === 'light' && 'bg-accent')}
|
||||
>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onThemeChange?.('dark')}
|
||||
className={cn(theme === 'dark' && 'bg-accent')}
|
||||
>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onThemeChange?.('system')}
|
||||
className={cn(theme === 'system' && 'bg-accent')}
|
||||
>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './DashboardSidebar';
|
||||
export * from './DashboardHeader';
|
||||
export * from './DashboardContent';
|
||||
export * from './MobileNav';
|
||||
export * from './BreadcrumbNav';
|
||||
export * from './ThemeToggle';
|
||||
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
User,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table';
|
||||
|
||||
/**
|
||||
* Audit log entry structure
|
||||
*/
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
actor: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'user' | 'service' | 'system';
|
||||
};
|
||||
action: 'grant' | 'revoke' | 'request' | 'deny' | 'expire' | 'attest';
|
||||
resource: string;
|
||||
permission: string;
|
||||
status: 'success' | 'failed' | 'pending';
|
||||
reason?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PermissionAuditLog component
|
||||
*/
|
||||
export interface PermissionAuditLogProps {
|
||||
entries: AuditLogEntry[];
|
||||
loading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
onExport?: () => void;
|
||||
showFilters?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table display for permission audit trail
|
||||
*/
|
||||
export function PermissionAuditLog({
|
||||
entries,
|
||||
loading = false,
|
||||
onRefresh,
|
||||
onExport,
|
||||
showFilters = true,
|
||||
className,
|
||||
}: PermissionAuditLogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterAction, setFilterAction] = useState<string>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
|
||||
const filteredEntries = entries.filter((entry) => {
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
entry.actor.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
entry.resource.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
entry.permission.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesAction = filterAction === 'all' || entry.action === filterAction;
|
||||
const matchesStatus = filterStatus === 'all' || entry.status === filterStatus;
|
||||
|
||||
return matchesSearch && matchesAction && matchesStatus;
|
||||
});
|
||||
|
||||
const getActionIcon = (action: AuditLogEntry['action']) => {
|
||||
switch (action) {
|
||||
case 'grant':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'revoke':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'request':
|
||||
return <Clock className="h-4 w-4 text-blue-500" />;
|
||||
case 'deny':
|
||||
return <XCircle className="h-4 w-4 text-orange-500" />;
|
||||
case 'expire':
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
|
||||
case 'attest':
|
||||
return <Shield className="h-4 w-4 text-purple-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: AuditLogEntry['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Success
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Failed
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Pending
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getActorIcon = (type: AuditLogEntry['actor']['type']) => {
|
||||
switch (type) {
|
||||
case 'user':
|
||||
return <User className="h-3 w-3" />;
|
||||
case 'service':
|
||||
return <Shield className="h-3 w-3" />;
|
||||
case 'system':
|
||||
return <Clock className="h-3 w-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Permission Audit Log</CardTitle>
|
||||
<CardDescription>Track all permission changes and access requests</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{onRefresh && (
|
||||
<Button size="sm" variant="outline" onClick={onRefresh} disabled={loading}>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
)}
|
||||
{onExport && (
|
||||
<Button size="sm" variant="outline" onClick={onExport}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showFilters && (
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by actor, resource, or permission..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={filterAction} onValueChange={setFilterAction}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Actions</SelectItem>
|
||||
<SelectItem value="grant">Grant</SelectItem>
|
||||
<SelectItem value="revoke">Revoke</SelectItem>
|
||||
<SelectItem value="request">Request</SelectItem>
|
||||
<SelectItem value="deny">Deny</SelectItem>
|
||||
<SelectItem value="expire">Expire</SelectItem>
|
||||
<SelectItem value="attest">Attest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">Timestamp</TableHead>
|
||||
<TableHead>Actor</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Resource</TableHead>
|
||||
<TableHead>Permission</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Reason</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
Loading audit logs...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
No audit log entries found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredEntries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{entry.timestamp.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getActorIcon(entry.actor.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{entry.actor.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{entry.actor.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{getActionIcon(entry.action)}
|
||||
<span className="text-sm capitalize">{entry.action}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{entry.resource}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{entry.permission}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(entry.status)}</TableCell>
|
||||
<TableCell className="text-right text-xs text-muted-foreground">
|
||||
{entry.reason || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import { Label } from '../../ui/label';
|
||||
|
||||
/**
|
||||
* Permission item structure
|
||||
*/
|
||||
export interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
resource?: string;
|
||||
action?: string;
|
||||
enabled: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PermissionGrid component
|
||||
*/
|
||||
export interface PermissionGridProps {
|
||||
permissions: Permission[];
|
||||
onChange?: (permissions: Permission[]) => void;
|
||||
readOnly?: boolean;
|
||||
groupByCategory?: boolean;
|
||||
showDescriptions?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid display for managing multiple permissions
|
||||
*/
|
||||
export function PermissionGrid({
|
||||
permissions,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
groupByCategory = true,
|
||||
showDescriptions = true,
|
||||
className,
|
||||
}: PermissionGridProps) {
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Permission[]>(permissions);
|
||||
|
||||
const handlePermissionChange = (permission: Permission, checked: boolean) => {
|
||||
const updated = selectedPermissions.map((p) =>
|
||||
p.id === permission.id ? { ...p, enabled: checked } : p
|
||||
);
|
||||
setSelectedPermissions(updated);
|
||||
onChange?.(updated);
|
||||
};
|
||||
|
||||
const groupedPermissions = groupByCategory
|
||||
? selectedPermissions.reduce<Record<string, Permission[]>>((acc, permission) => {
|
||||
const category = permission.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category]?.push(permission);
|
||||
return acc;
|
||||
}, {})
|
||||
: { 'All Permissions': selectedPermissions };
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{Object.entries(groupedPermissions).map(([category, perms]) => (
|
||||
<Card key={category}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{category}</CardTitle>
|
||||
<CardDescription>
|
||||
{perms.filter((p) => p.enabled).length} of {perms.length} permissions enabled
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{perms.map((permission) => (
|
||||
<div
|
||||
key={permission.id}
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 rounded-lg border p-3',
|
||||
permission.enabled && 'bg-accent/50',
|
||||
permission.required && 'border-primary'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={permission.id}
|
||||
checked={permission.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePermissionChange(permission, checked as boolean)
|
||||
}
|
||||
disabled={readOnly || permission.required}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label
|
||||
htmlFor={permission.id}
|
||||
className={cn(
|
||||
'text-sm font-medium cursor-pointer',
|
||||
readOnly && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
>
|
||||
{permission.name}
|
||||
{permission.required && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground">{permission.description}</p>
|
||||
)}
|
||||
{(permission.resource || permission.action) && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
{permission.resource && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{permission.resource}
|
||||
</Badge>
|
||||
)}
|
||||
{permission.action && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{permission.action}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, Clock, FileText, Info, Key, Shield, User } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Alert, AlertDescription } from '../../ui/alert';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../../ui/dialog';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
// import { cn } from "../../../lib/utils" // Uncomment if needed
|
||||
|
||||
/**
|
||||
* Permission request data structure
|
||||
*/
|
||||
export interface PermissionRequestData {
|
||||
requester: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'user' | 'service';
|
||||
};
|
||||
permissions: Array<{
|
||||
resource: string;
|
||||
action: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
duration?: {
|
||||
value: number;
|
||||
unit: 'hours' | 'days' | 'weeks' | 'months';
|
||||
};
|
||||
justification: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PermissionRequest component
|
||||
*/
|
||||
export interface PermissionRequestProps {
|
||||
onSubmit?: (request: PermissionRequestData) => Promise<void>;
|
||||
availablePermissions?: Array<{
|
||||
resource: string;
|
||||
actions: string[];
|
||||
}>;
|
||||
requester?: PermissionRequestData['requester'];
|
||||
maxDuration?: number;
|
||||
showJustification?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog workflow for requesting permissions
|
||||
*/
|
||||
export function PermissionRequest({
|
||||
onSubmit,
|
||||
availablePermissions = [],
|
||||
requester,
|
||||
maxDuration = 30,
|
||||
showJustification = true,
|
||||
className,
|
||||
}: PermissionRequestProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [formData, setFormData] = useState<Partial<PermissionRequestData>>({
|
||||
requester: requester || { id: '', name: '', type: 'user' },
|
||||
permissions: [],
|
||||
justification: '',
|
||||
});
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<Set<string>>(new Set());
|
||||
|
||||
const handlePermissionToggle = (resource: string, action: string) => {
|
||||
const key = `${resource}:${action}`;
|
||||
const newSelected = new Set(selectedPermissions);
|
||||
|
||||
if (newSelected.has(key)) {
|
||||
newSelected.delete(key);
|
||||
} else {
|
||||
newSelected.add(key);
|
||||
}
|
||||
|
||||
setSelectedPermissions(newSelected);
|
||||
|
||||
// Update form data
|
||||
const permissions = Array.from(newSelected).map((k) => {
|
||||
const [res, act] = k.split(':');
|
||||
return { resource: res || '', action: act || '' };
|
||||
});
|
||||
setFormData((prev) => ({ ...prev, permissions }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.permissions || formData.permissions.length === 0) {
|
||||
setError('Please select at least one permission');
|
||||
return;
|
||||
}
|
||||
|
||||
if (showJustification && !formData.justification) {
|
||||
setError('Please provide a justification');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
await onSubmit?.(formData as PermissionRequestData);
|
||||
setOpen(false);
|
||||
// Reset form
|
||||
setFormData({
|
||||
requester: requester || { id: '', name: '', type: 'user' },
|
||||
permissions: [],
|
||||
justification: '',
|
||||
});
|
||||
setSelectedPermissions(new Set());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit request');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className={className}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Request Permissions
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Request Permissions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the permissions you need and provide justification for your request
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Requester Information */}
|
||||
{requester && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Requester</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{requester.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{requester.type === 'service' ? 'Service Account' : 'User Account'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Permission Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label>Select Permissions</Label>
|
||||
{availablePermissions.length === 0 ? (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>No permissions available to request</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availablePermissions.map((perm) => (
|
||||
<Card key={perm.resource}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm">{perm.resource}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{perm.actions.map((action) => {
|
||||
const key = `${perm.resource}:${action}`;
|
||||
return (
|
||||
<div key={action} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={key}
|
||||
checked={selectedPermissions.has(key)}
|
||||
onCheckedChange={() =>
|
||||
handlePermissionToggle(perm.resource, action)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={key} className="text-sm cursor-pointer">
|
||||
{action}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Duration Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="duration">Duration (Optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxDuration}
|
||||
placeholder="Duration"
|
||||
value={formData.duration?.value || ''}
|
||||
onChange={(e) => {
|
||||
const value = Number.parseInt(e.target.value);
|
||||
if (value > 0) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
duration: {
|
||||
value,
|
||||
unit: prev.duration?.unit || 'days',
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={formData.duration?.unit || 'days'}
|
||||
onChange={(e) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
duration: {
|
||||
value: prev.duration?.value || 7,
|
||||
unit: e.target.value as any,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="hours">Hours</option>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty for permanent permissions (subject to approval)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Justification */}
|
||||
{showJustification && (
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="justification">
|
||||
Justification <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="justification"
|
||||
placeholder="Explain why you need these permissions..."
|
||||
value={formData.justification}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, justification: e.target.value }))
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a clear business justification for this request
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Selected Permissions Summary */}
|
||||
{selectedPermissions.size > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Request Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formData.duration
|
||||
? `${formData.duration.value} ${formData.duration.unit}`
|
||||
: 'Permanent (subject to approval)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from(selectedPermissions).map((key) => (
|
||||
<Badge key={key} variant="secondary" className="text-xs">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || selectedPermissions.size === 0}>
|
||||
{loading ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../ui/select';
|
||||
|
||||
/**
|
||||
* Permission template structure
|
||||
*/
|
||||
export interface PermissionTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
category: 'basic' | 'standard' | 'advanced' | 'custom';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for PermissionSelector component
|
||||
*/
|
||||
export interface PermissionSelectorProps {
|
||||
templates: PermissionTemplate[];
|
||||
value?: string;
|
||||
onChange?: (templateId: string, template: PermissionTemplate) => void;
|
||||
showDetails?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown selector for permission templates with descriptions
|
||||
*/
|
||||
export function PermissionSelector({
|
||||
templates,
|
||||
value,
|
||||
onChange,
|
||||
showDetails = true,
|
||||
disabled = false,
|
||||
className,
|
||||
}: PermissionSelectorProps) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<PermissionTemplate | undefined>(
|
||||
templates.find((t) => t.id === value)
|
||||
);
|
||||
|
||||
const handleChange = (templateId: string) => {
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (template) {
|
||||
setSelectedTemplate(template);
|
||||
onChange?.(templateId, template);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: PermissionTemplate['category']) => {
|
||||
switch (category) {
|
||||
case 'basic':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400';
|
||||
case 'standard':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400';
|
||||
case 'advanced':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400';
|
||||
case 'custom':
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const groupedTemplates = templates.reduce<Record<string, PermissionTemplate[]>>(
|
||||
(acc, template) => {
|
||||
const category = template.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category]?.push(template);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<Select value={selectedTemplate?.id || ''} onValueChange={handleChange} disabled={disabled}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a permission template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedTemplates).map(([category, temps]) => (
|
||||
<SelectGroup key={category}>
|
||||
<SelectLabel className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
getCategoryColor(category as PermissionTemplate['category'])
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
</SelectLabel>
|
||||
{temps.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id} className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.icon}
|
||||
<div>
|
||||
<div className="font-medium">{template.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{template.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showDetails && selectedTemplate && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{selectedTemplate.name}</CardTitle>
|
||||
<Badge variant="secondary" className={getCategoryColor(selectedTemplate.category)}>
|
||||
{selectedTemplate.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>{selectedTemplate.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4" />
|
||||
<span>
|
||||
This template includes {selectedTemplate.permissions.length} permissions:
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTemplate.permissions.map((permission) => (
|
||||
<Badge key={permission} variant="outline" className="text-xs">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Key,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
|
||||
|
||||
/**
|
||||
* UCAN capability structure
|
||||
*/
|
||||
export interface UCANCapability {
|
||||
with: string; // Resource identifier
|
||||
can: string; // Action/permission
|
||||
nb?: Record<string, any>; // Additional constraints
|
||||
}
|
||||
|
||||
/**
|
||||
* UCAN token structure
|
||||
*/
|
||||
export interface UCANToken {
|
||||
iss: string; // Issuer DID
|
||||
aud: string; // Audience DID
|
||||
exp?: number; // Expiration timestamp
|
||||
nbf?: number; // Not before timestamp
|
||||
att: UCANCapability[]; // Attenuations/capabilities
|
||||
prf?: string[]; // Proof chain
|
||||
fct?: Record<string, any>; // Facts
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for UCANViewer component
|
||||
*/
|
||||
export interface UCANViewerProps {
|
||||
token: UCANToken;
|
||||
showRaw?: boolean;
|
||||
showProofChain?: boolean;
|
||||
onCopyToken?: () => void;
|
||||
onVerify?: () => Promise<boolean>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewer for UCAN token visualization and capability display
|
||||
*/
|
||||
export function UCANViewer({
|
||||
token,
|
||||
showRaw = true,
|
||||
showProofChain = true,
|
||||
onCopyToken,
|
||||
onVerify,
|
||||
className,
|
||||
}: UCANViewerProps) {
|
||||
const [expandedCapabilities, setExpandedCapabilities] = useState<Set<number>>(new Set());
|
||||
const [verificationStatus, setVerificationStatus] = useState<
|
||||
'idle' | 'verifying' | 'valid' | 'invalid'
|
||||
>('idle');
|
||||
|
||||
const toggleCapability = (index: number) => {
|
||||
const newExpanded = new Set(expandedCapabilities);
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index);
|
||||
} else {
|
||||
newExpanded.add(index);
|
||||
}
|
||||
setExpandedCapabilities(newExpanded);
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!onVerify) return;
|
||||
setVerificationStatus('verifying');
|
||||
try {
|
||||
const isValid = await onVerify();
|
||||
setVerificationStatus(isValid ? 'valid' : 'invalid');
|
||||
} catch {
|
||||
setVerificationStatus('invalid');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return 'Never';
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
const isExpired = token.exp && token.exp * 1000 < Date.now();
|
||||
const isNotYetValid = token.nbf && token.nbf * 1000 > Date.now();
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
<CardTitle>UCAN Token</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpired && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Expired
|
||||
</Badge>
|
||||
)}
|
||||
{isNotYetValid && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Not Yet Valid
|
||||
</Badge>
|
||||
)}
|
||||
{verificationStatus === 'valid' && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
{verificationStatus === 'invalid' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Invalid
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
User-Controlled Authorization Network token with delegated capabilities
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="details" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
<TabsTrigger value="capabilities">Capabilities ({token.att.length})</TabsTrigger>
|
||||
{showRaw && <TabsTrigger value="raw">Raw Token</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Key className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Issuer</p>
|
||||
<code className="text-xs text-muted-foreground break-all">{token.iss}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Key className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Audience</p>
|
||||
<code className="text-xs text-muted-foreground break-all">{token.aud}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{token.exp && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Expires</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(token.exp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{token.nbf && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Not Before</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(token.nbf)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProofChain && token.prf && token.prf.length > 0 && (
|
||||
<div className="flex items-start gap-2">
|
||||
<ExternalLink className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Proof Chain</p>
|
||||
<div className="space-y-1 mt-1">
|
||||
{token.prf.map((proof, index) => (
|
||||
<code
|
||||
key={index}
|
||||
className="block text-xs text-muted-foreground break-all"
|
||||
>
|
||||
{proof}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{onVerify && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleVerify}
|
||||
disabled={verificationStatus === 'verifying'}
|
||||
>
|
||||
{verificationStatus === 'verifying' ? 'Verifying...' : 'Verify Token'}
|
||||
</Button>
|
||||
)}
|
||||
{onCopyToken && (
|
||||
<Button size="sm" variant="outline" onClick={onCopyToken}>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
Copy Token
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="capabilities" className="space-y-3">
|
||||
{token.att.map((capability, index) => (
|
||||
<Card key={index} className="border-muted">
|
||||
<CardHeader
|
||||
className="cursor-pointer py-3"
|
||||
onClick={() => toggleCapability(index)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedCapabilities.has(index) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<Badge variant="outline">{capability.can}</Badge>
|
||||
<code className="text-xs text-muted-foreground">{capability.with}</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{expandedCapabilities.has(index) && capability.nb && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Constraints:</p>
|
||||
<pre className="text-xs bg-muted p-2 rounded overflow-auto">
|
||||
{JSON.stringify(capability.nb, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
{token.att.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No capabilities defined
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{showRaw && (
|
||||
<TabsContent value="raw" className="space-y-3">
|
||||
<pre className="text-xs bg-muted p-4 rounded overflow-auto">
|
||||
{JSON.stringify(token, null, 2)}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './PermissionGrid';
|
||||
export * from './PermissionSelector';
|
||||
export * from './UCANViewer';
|
||||
export * from './PermissionAuditLog';
|
||||
export * from './PermissionRequest';
|
||||
@@ -0,0 +1,310 @@
|
||||
// OAuth Components
|
||||
export { SignInWithSonr } from './SignInWithSonr';
|
||||
export { SignInWithSonrModal } from './SignInWithSonrModal';
|
||||
|
||||
// OAuth Hooks
|
||||
export { useSignInWithSonr } from '../hooks/useSignInWithSonr';
|
||||
export type {
|
||||
UseSignInWithSonrOptions,
|
||||
UseSignInWithSonrReturn,
|
||||
UseSignInWithSonrState,
|
||||
} from '../hooks/useSignInWithSonr';
|
||||
|
||||
// OAuth Utilities
|
||||
export * from '../lib/oauth';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from './ui/card';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
} from './ui/table';
|
||||
|
||||
export { Badge, badgeVariants } from './ui/badge';
|
||||
|
||||
export { Button, buttonVariants } from './ui/button';
|
||||
|
||||
export { Input } from './ui/input';
|
||||
|
||||
export {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from './ui/alert';
|
||||
|
||||
export { ErrorAlert } from './ui/error-alert';
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from './ui/sheet';
|
||||
|
||||
export {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from './ui/tabs';
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog';
|
||||
|
||||
export { Skeleton } from './ui/skeleton';
|
||||
|
||||
export { Checkbox } from './ui/checkbox';
|
||||
|
||||
export { Label } from './ui/label';
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
} from './ui/command';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
} from './ui/form';
|
||||
|
||||
// Export Progress component
|
||||
export { Progress } from './ui/progress';
|
||||
|
||||
// Export Calendar component
|
||||
export { Calendar } from './ui/calendar';
|
||||
|
||||
// Export Chart components
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
type ChartConfig,
|
||||
} from './ui/chart';
|
||||
|
||||
// Export Popover components
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from './ui/popover';
|
||||
|
||||
// Export Select components
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select';
|
||||
|
||||
// Export DropdownMenu components
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
} from './ui/dropdown-menu';
|
||||
|
||||
// Export dashboard service components
|
||||
export {
|
||||
ServiceList,
|
||||
ServiceCard,
|
||||
ServiceForm,
|
||||
ServiceDetails,
|
||||
ServiceSearch,
|
||||
ServiceMetrics,
|
||||
type Service,
|
||||
} from './dashboard/services';
|
||||
|
||||
// Export dashboard domain verification components
|
||||
export {
|
||||
DomainVerificationFlow,
|
||||
DomainStatus,
|
||||
DNSRecordDisplay,
|
||||
VerificationProgress,
|
||||
DomainDashboard,
|
||||
DomainList,
|
||||
DomainSelector,
|
||||
DNSInstructions,
|
||||
VerificationStatus,
|
||||
VerificationWizard,
|
||||
type Domain,
|
||||
type VerificationStep,
|
||||
type DomainStatusType,
|
||||
type DNSRecord,
|
||||
type VerificationCheck,
|
||||
} from './dashboard/domain';
|
||||
|
||||
// Export dashboard analytics components
|
||||
export {
|
||||
MetricsCard,
|
||||
ActivityChart,
|
||||
RequestPatternChart,
|
||||
PerformanceMetrics,
|
||||
TimeRangeSelector,
|
||||
type MetricsCardProps,
|
||||
type ActivityData,
|
||||
type ActivityChartProps,
|
||||
type RequestPatternData,
|
||||
type RequestPatternChartProps,
|
||||
type PerformanceMetric,
|
||||
type PerformanceMetricsProps,
|
||||
type TimeRangeSelectorProps,
|
||||
} from './dashboard/analytics';
|
||||
|
||||
// Export dashboard permissions components
|
||||
export {
|
||||
PermissionGrid,
|
||||
PermissionSelector,
|
||||
UCANViewer,
|
||||
PermissionAuditLog,
|
||||
PermissionRequest,
|
||||
type Permission,
|
||||
type PermissionGridProps,
|
||||
type PermissionSelectorProps,
|
||||
type UCANCapability,
|
||||
type UCANToken,
|
||||
type UCANViewerProps,
|
||||
type AuditLogEntry,
|
||||
type PermissionAuditLogProps,
|
||||
type PermissionRequestProps,
|
||||
} from './dashboard/permissions';
|
||||
|
||||
// Export dashboard layout components
|
||||
export {
|
||||
DashboardSidebar,
|
||||
DashboardHeader,
|
||||
DashboardContent,
|
||||
MobileNav,
|
||||
BreadcrumbNav,
|
||||
ThemeToggle,
|
||||
type NavItem,
|
||||
type DashboardSidebarProps,
|
||||
type DashboardHeaderProps,
|
||||
type DashboardContentProps,
|
||||
type MobileNavProps,
|
||||
type BreadcrumbNavItem,
|
||||
type BreadcrumbNavProps,
|
||||
type ThemeToggleProps,
|
||||
} from './dashboard/layout';
|
||||
|
||||
// Export additional UI components
|
||||
export { Textarea } from './ui/textarea';
|
||||
export { Toggle, toggleVariants } from './ui/toggle';
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
} from './ui/breadcrumb';
|
||||
|
||||
// Export Sidebar components
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from './ui/sidebar';
|
||||
|
||||
// Export Collapsible components
|
||||
export {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
} from './ui/collapsible';
|
||||
|
||||
// Export Separator component
|
||||
export { Separator } from './ui/separator';
|
||||
|
||||
// Export Tooltip components
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from './ui/tooltip';
|
||||
|
||||
// Export utility functions
|
||||
export { cn } from '../lib/utils';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const alertVariantsConfig = {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
alertVariantsConfig
|
||||
);
|
||||
|
||||
export type AlertVariantProps = VariantProps<typeof alertVariants>;
|
||||
|
||||
function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & AlertVariantProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const badgeVariantsConfig = {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
badgeVariantsConfig
|
||||
);
|
||||
|
||||
export type BadgeVariantProps = VariantProps<typeof badgeVariants>;
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & BadgeVariantProps & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span';
|
||||
|
||||
return (
|
||||
<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge };
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Breadcrumb = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<'nav'>>(
|
||||
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
|
||||
);
|
||||
Breadcrumb.displayName = 'Breadcrumb';
|
||||
|
||||
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<'ol'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
BreadcrumbList.displayName = 'BreadcrumbList';
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<'li'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn('inline-flex items-center gap-1.5', className)} {...props} />
|
||||
)
|
||||
);
|
||||
BreadcrumbItem.displayName = 'BreadcrumbItem';
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<'a'> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn('transition-colors hover:text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = 'BreadcrumbLink';
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<'span'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('font-normal text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
BreadcrumbPage.displayName = 'BreadcrumbPage';
|
||||
|
||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<'li'>) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const buttonVariantsConfig = {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
buttonVariantsConfig
|
||||
);
|
||||
|
||||
export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
ButtonVariantProps {
|
||||
asChild?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, isLoading, disabled, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import type * as React from 'react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
),
|
||||
nav_button_previous: 'absolute left-1',
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
head_row: 'flex',
|
||||
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
day: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
day_range_end: 'day-range-end',
|
||||
day_selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar };
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,339 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as RechartsPrimitive from 'recharts';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join('\n')}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join('\n'),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
interface ChartTooltipContentProps extends React.ComponentProps<'div'> {
|
||||
active?: boolean;
|
||||
payload?: Array<any>;
|
||||
label?: any;
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: 'line' | 'dot' | 'dashed';
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
labelFormatter?: (value: any, payload?: Array<any>) => React.ReactNode;
|
||||
labelClassName?: string;
|
||||
formatter?: (
|
||||
value: any,
|
||||
name: string,
|
||||
item: any,
|
||||
index: number,
|
||||
payload?: any
|
||||
) => React.ReactNode;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: ChartTooltipContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center'
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
interface ChartLegendContentProps extends React.ComponentProps<'div'> {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
payload?: Array<any>;
|
||||
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||
}
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
nameKey,
|
||||
}: ChartLegendContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog';
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn('overflow-hidden p-0', className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Alert, AlertDescription } from './alert';
|
||||
|
||||
export interface ErrorAlertProps {
|
||||
message: string;
|
||||
className?: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const ErrorAlert = React.forwardRef<HTMLDivElement, ErrorAlertProps>(
|
||||
({ message, className, onDismiss, ...props }, ref) => {
|
||||
return (
|
||||
<Alert ref={ref} className={cn('border-destructive', className)} {...props}>
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>{message}</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="ml-2 text-destructive hover:text-destructive/80 focus:outline-none"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ErrorAlert.displayName = 'ErrorAlert';
|
||||
|
||||
export { ErrorAlert };
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import type * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Controller,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from 'react-hook-form';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Label } from './label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn('grid gap-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? '') : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface InputProps extends React.ComponentProps<'input'> {
|
||||
label?: string;
|
||||
helpText?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, helpText, error, ...props }, ref) => {
|
||||
const inputId = React.useId();
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{helpText && !error && <p className="text-sm text-muted-foreground">{helpText}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,734 @@
|
||||
'use client';
|
||||
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { useIsMobile } from '../../hooks/use-mobile';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { Separator } from './separator';
|
||||
import { Sheet, SheetContent } from './sheet';
|
||||
import { Skeleton } from './skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar:state';
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = '16rem';
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||
const SIDEBAR_WIDTH_ICON = '3rem';
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
||||
|
||||
type SidebarContext = {
|
||||
state: 'expanded' | 'collapsed';
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContext | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === 'function' ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? 'expanded' : 'collapsed';
|
||||
|
||||
const contextValue = React.useMemo<SidebarContext>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
'--sidebar-width-mobile': SIDEBAR_WIDTH_MOBILE,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarProvider.displayName = 'SidebarProvider';
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right';
|
||||
variant?: 'sidebar' | 'floating' | 'inset';
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none';
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === 'none') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width-mobile] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden md:block text-sidebar-foreground"
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
'duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
|
||||
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Sidebar.displayName = 'Sidebar';
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-7 w-7', className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
SidebarTrigger.displayName = 'SidebarTrigger';
|
||||
|
||||
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<'button'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:-translate-x-1/2 after:bg-sidebar-border after:opacity-0 after:transition-opacity after:duration-200 hover:after:opacity-100 group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
|
||||
'[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarRail.displayName = 'SidebarRail';
|
||||
|
||||
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex min-h-svh flex-1 flex-col bg-background',
|
||||
'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarInset.displayName = 'SidebarInset';
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInput.displayName = 'SidebarInput';
|
||||
|
||||
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarHeader.displayName = 'SidebarHeader';
|
||||
|
||||
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarFooter.displayName = 'SidebarFooter';
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn('mx-2 w-auto bg-sidebar-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarSeparator.displayName = 'SidebarSeparator';
|
||||
|
||||
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarContent.displayName = 'SidebarContent';
|
||||
|
||||
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarGroup.displayName = 'SidebarGroup';
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<'button'> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 after:md:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroupAction.displayName = 'SidebarGroupAction';
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn('w-full text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
SidebarGroupContent.displayName = 'SidebarGroupContent';
|
||||
|
||||
const SidebarMenu = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
SidebarMenu.displayName = 'SidebarMenu';
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn('group/menu-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
SidebarMenuItem.displayName = 'SidebarMenuItem';
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
outline:
|
||||
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 text-sm',
|
||||
sm: 'h-7 text-xs',
|
||||
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === 'string') {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
SidebarMenuButton.displayName = 'SidebarMenuButton';
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 after:md:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
showOnHover &&
|
||||
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuAction.displayName = 'SidebarMenuAction';
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'> & {
|
||||
showIcon?: boolean;
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn('rounded-md h-8 flex gap-2 px-2 items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
<Skeleton
|
||||
className="h-4 flex-1 max-w-[--skeleton-width]"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
'--skeleton-width': width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
SidebarMenuSub.displayName = 'SidebarMenuSub';
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
|
||||
({ ...props }, ref) => <div ref={ref} {...props} />
|
||||
);
|
||||
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-foreground/50',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn('bg-accent animate-pulse rounded-md', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,90 @@
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn('flex flex-col gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn('flex-1 outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const toggleVariantsConfig = {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-3',
|
||||
sm: 'h-9 px-2.5',
|
||||
lg: 'h-11 px-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const toggleVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
toggleVariantsConfig
|
||||
);
|
||||
|
||||
export type ToggleVariantProps = VariantProps<typeof toggleVariants>;
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & ToggleVariantProps
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle };
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* React hook for Sonr OAuth2 authentication
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
OAuth2Client,
|
||||
OAuth2ClientError,
|
||||
type OAuth2Config,
|
||||
type OAuth2Token,
|
||||
type OAuth2UserInfo,
|
||||
isTokenExpired,
|
||||
parseCallbackUrl,
|
||||
} from '../lib/oauth';
|
||||
|
||||
/**
|
||||
* Hook state
|
||||
*/
|
||||
export interface UseSignInWithSonrState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
user: OAuth2UserInfo | null;
|
||||
token: OAuth2Token | null;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook options
|
||||
*/
|
||||
export interface UseSignInWithSonrOptions extends Partial<OAuth2Config> {
|
||||
/**
|
||||
* Auto refresh token before expiry
|
||||
*/
|
||||
autoRefresh?: boolean;
|
||||
/**
|
||||
* Auto refresh buffer time in seconds
|
||||
*/
|
||||
refreshBuffer?: number;
|
||||
/**
|
||||
* Callback on successful authentication
|
||||
*/
|
||||
onSuccess?: (user: OAuth2UserInfo, token: OAuth2Token) => void;
|
||||
/**
|
||||
* Callback on authentication error
|
||||
*/
|
||||
onError?: (error: Error) => void;
|
||||
/**
|
||||
* Callback on logout
|
||||
*/
|
||||
onLogout?: () => void;
|
||||
/**
|
||||
* Storage key prefix
|
||||
*/
|
||||
storageKeyPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook return type
|
||||
*/
|
||||
export interface UseSignInWithSonrReturn extends UseSignInWithSonrState {
|
||||
/**
|
||||
* Initiate OAuth2 authorization flow
|
||||
*/
|
||||
signIn: (state?: string) => Promise<void>;
|
||||
/**
|
||||
* Handle OAuth2 callback
|
||||
*/
|
||||
handleCallback: (callbackUrl?: string) => Promise<OAuth2UserInfo | null>;
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
refresh: () => Promise<OAuth2Token | null>;
|
||||
/**
|
||||
* Get user info
|
||||
*/
|
||||
getUser: () => Promise<OAuth2UserInfo | null>;
|
||||
/**
|
||||
* Logout and clear tokens
|
||||
*/
|
||||
signOut: () => Promise<void>;
|
||||
/**
|
||||
* Get access token
|
||||
*/
|
||||
getAccessToken: () => string | null;
|
||||
/**
|
||||
* OAuth2 client instance
|
||||
*/
|
||||
client: OAuth2Client;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for Sonr OAuth2 authentication
|
||||
*/
|
||||
export function useSignInWithSonr(
|
||||
config: OAuth2Config,
|
||||
options: UseSignInWithSonrOptions = {}
|
||||
): UseSignInWithSonrReturn {
|
||||
const {
|
||||
autoRefresh = true,
|
||||
refreshBuffer = 60,
|
||||
onSuccess,
|
||||
onError,
|
||||
onLogout,
|
||||
storageKeyPrefix = 'sonr_oauth',
|
||||
...configOverrides
|
||||
} = options;
|
||||
|
||||
// OAuth2 client
|
||||
const client = useMemo(
|
||||
() => new OAuth2Client({ ...config, ...configOverrides }),
|
||||
[config, configOverrides]
|
||||
);
|
||||
|
||||
// State
|
||||
const [state, setState] = useState<UseSignInWithSonrState>({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Refs for callbacks
|
||||
const refreshTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const isRefreshingRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Load stored token and user on mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
const loadStoredAuth = async () => {
|
||||
try {
|
||||
const storedToken = localStorage.getItem(`${storageKeyPrefix}_token`);
|
||||
const storedUser = localStorage.getItem(`${storageKeyPrefix}_user`);
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
const token = JSON.parse(storedToken) as OAuth2Token;
|
||||
const user = JSON.parse(storedUser) as OAuth2UserInfo;
|
||||
|
||||
// Check if token is expired
|
||||
if (!isTokenExpired(token)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isAuthenticated: true,
|
||||
token,
|
||||
user,
|
||||
}));
|
||||
|
||||
// Setup auto refresh
|
||||
if (autoRefresh && token.expires_in) {
|
||||
scheduleTokenRefresh(token.expires_in);
|
||||
}
|
||||
} else {
|
||||
// Try to refresh if we have a refresh token
|
||||
if (token.refresh_token) {
|
||||
await refresh();
|
||||
} else {
|
||||
// Clear expired auth
|
||||
await signOut();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stored auth:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadStoredAuth();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Schedule token refresh
|
||||
*/
|
||||
const scheduleTokenRefresh = useCallback(
|
||||
(expiresIn: number) => {
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Refresh token before it expires
|
||||
const refreshIn = Math.max(0, (expiresIn - refreshBuffer) * 1000);
|
||||
|
||||
refreshTimeoutRef.current = setTimeout(async () => {
|
||||
if (!isRefreshingRef.current) {
|
||||
await refresh();
|
||||
}
|
||||
}, refreshIn);
|
||||
},
|
||||
[refreshBuffer]
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear refresh timeout
|
||||
*/
|
||||
const clearRefreshTimeout = useCallback(() => {
|
||||
if (refreshTimeoutRef.current) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
refreshTimeoutRef.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sign in - initiate OAuth2 flow
|
||||
*/
|
||||
const signIn = useCallback(
|
||||
async (authState?: string) => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const authUrl = await client.getAuthorizationUrl(authState);
|
||||
window.location.href = authUrl;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
setState((prev) => ({ ...prev, isLoading: false, error: err }));
|
||||
onError?.(err);
|
||||
}
|
||||
},
|
||||
[client, onError]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle OAuth2 callback
|
||||
*/
|
||||
const handleCallback = useCallback(
|
||||
async (callbackUrl?: string): Promise<OAuth2UserInfo | null> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Parse callback URL
|
||||
const url = callbackUrl || window.location.href;
|
||||
const params = parseCallbackUrl(url);
|
||||
|
||||
// Check for errors
|
||||
if (params.error) {
|
||||
throw new OAuth2ClientError(params.error, params.error_description);
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
if (!params.code) {
|
||||
throw new OAuth2ClientError('invalid_request', 'No authorization code in callback');
|
||||
}
|
||||
|
||||
const token = await client.exchangeCode(params.code, params.state);
|
||||
|
||||
// Get user info
|
||||
const user = await client.getUserInfo(token.access_token);
|
||||
|
||||
// Store auth data
|
||||
localStorage.setItem(`${storageKeyPrefix}_token`, JSON.stringify(token));
|
||||
localStorage.setItem(`${storageKeyPrefix}_user`, JSON.stringify(user));
|
||||
|
||||
// Update state
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
token,
|
||||
user,
|
||||
}));
|
||||
|
||||
// Setup auto refresh
|
||||
if (autoRefresh && token.expires_in) {
|
||||
scheduleTokenRefresh(token.expires_in);
|
||||
}
|
||||
|
||||
// Call success callback
|
||||
onSuccess?.(user, token);
|
||||
|
||||
// Clear callback params from URL
|
||||
if (!callbackUrl) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
error: err,
|
||||
}));
|
||||
onError?.(err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[client, storageKeyPrefix, autoRefresh, scheduleTokenRefresh, onSuccess, onError]
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
const refresh = useCallback(async (): Promise<OAuth2Token | null> => {
|
||||
if (isRefreshingRef.current) {
|
||||
return state.token;
|
||||
}
|
||||
|
||||
isRefreshingRef.current = true;
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const token = await client.refreshToken();
|
||||
|
||||
// Get updated user info
|
||||
const user = await client.getUserInfo(token.access_token);
|
||||
|
||||
// Store updated auth data
|
||||
localStorage.setItem(`${storageKeyPrefix}_token`, JSON.stringify(token));
|
||||
localStorage.setItem(`${storageKeyPrefix}_user`, JSON.stringify(user));
|
||||
|
||||
// Update state
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
token,
|
||||
user,
|
||||
}));
|
||||
|
||||
// Reschedule auto refresh
|
||||
if (autoRefresh && token.expires_in) {
|
||||
scheduleTokenRefresh(token.expires_in);
|
||||
}
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err,
|
||||
}));
|
||||
|
||||
// If refresh fails, sign out
|
||||
await signOut();
|
||||
|
||||
onError?.(err);
|
||||
return null;
|
||||
} finally {
|
||||
isRefreshingRef.current = false;
|
||||
}
|
||||
}, [client, state.token, storageKeyPrefix, autoRefresh, scheduleTokenRefresh, onError]);
|
||||
|
||||
/**
|
||||
* Get user info
|
||||
*/
|
||||
const getUser = useCallback(async (): Promise<OAuth2UserInfo | null> => {
|
||||
if (!state.isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const user = await client.getUserInfo();
|
||||
|
||||
// Update stored user
|
||||
localStorage.setItem(`${storageKeyPrefix}_user`, JSON.stringify(user));
|
||||
|
||||
// Update state
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
user,
|
||||
}));
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
setState((prev) => ({ ...prev, isLoading: false, error: err }));
|
||||
onError?.(err);
|
||||
return null;
|
||||
}
|
||||
}, [client, state.isAuthenticated, storageKeyPrefix, onError]);
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
const signOut = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Revoke tokens
|
||||
await client.logout();
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke tokens:', error);
|
||||
} finally {
|
||||
// Clear refresh timeout
|
||||
clearRefreshTimeout();
|
||||
|
||||
// Clear stored auth data
|
||||
localStorage.removeItem(`${storageKeyPrefix}_token`);
|
||||
localStorage.removeItem(`${storageKeyPrefix}_user`);
|
||||
sessionStorage.removeItem(`${storageKeyPrefix}_code_verifier`);
|
||||
|
||||
// Reset state
|
||||
setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Call logout callback
|
||||
onLogout?.();
|
||||
}
|
||||
}, [client, storageKeyPrefix, clearRefreshTimeout, onLogout]);
|
||||
|
||||
/**
|
||||
* Get access token
|
||||
*/
|
||||
const getAccessToken = useCallback((): string | null => {
|
||||
return state.token?.access_token || null;
|
||||
}, [state.token]);
|
||||
|
||||
/**
|
||||
* Cleanup on unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearRefreshTimeout();
|
||||
};
|
||||
}, [clearRefreshTimeout]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
signIn,
|
||||
handleCallback,
|
||||
refresh,
|
||||
getUser,
|
||||
signOut,
|
||||
getAccessToken,
|
||||
client,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export
|
||||
*/
|
||||
export default useSignInWithSonr;
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* OAuth 2.0 client utilities for Sonr authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuth2 configuration
|
||||
*/
|
||||
export interface OAuth2Config {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
userInfoUrl?: string;
|
||||
scopes?: string[];
|
||||
responseType?: string;
|
||||
grantType?: string;
|
||||
pkce?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 token response
|
||||
*/
|
||||
export interface OAuth2Token {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
id_token?: string;
|
||||
ucan_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 error response
|
||||
*/
|
||||
export interface OAuth2Error {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User info from OAuth2 provider
|
||||
*/
|
||||
export interface OAuth2UserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
picture?: string;
|
||||
did?: string;
|
||||
vault_address?: string;
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 client class
|
||||
*/
|
||||
export class OAuth2Client {
|
||||
private config: OAuth2Config;
|
||||
private codeVerifier?: string;
|
||||
|
||||
constructor(config: OAuth2Config) {
|
||||
this.config = {
|
||||
authorizationUrl: '/oauth2/authorize',
|
||||
tokenUrl: '/oauth2/token',
|
||||
userInfoUrl: '/oauth2/userinfo',
|
||||
responseType: 'code',
|
||||
grantType: 'authorization_code',
|
||||
pkce: true,
|
||||
scopes: ['openid', 'profile'],
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authorization URL
|
||||
*/
|
||||
async getAuthorizationUrl(state?: string): Promise<string> {
|
||||
const params = new URLSearchParams({
|
||||
response_type: this.config.responseType || 'code',
|
||||
client_id: this.config.clientId,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
scope: (this.config.scopes || []).join(' '),
|
||||
state: state || this.generateState(),
|
||||
});
|
||||
|
||||
// Add PKCE challenge if enabled
|
||||
if (this.config.pkce) {
|
||||
const { verifier, challenge } = await this.generatePKCE();
|
||||
this.codeVerifier = verifier;
|
||||
|
||||
// Store verifier in session storage for later use
|
||||
sessionStorage.setItem('sonr_oauth_code_verifier', verifier);
|
||||
|
||||
params.append('code_challenge', challenge);
|
||||
params.append('code_challenge_method', 'S256');
|
||||
}
|
||||
|
||||
return `${this.config.authorizationUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
async exchangeCode(code: string, state?: string): Promise<OAuth2Token> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: this.config.grantType || 'authorization_code',
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
});
|
||||
|
||||
// Add PKCE verifier if available
|
||||
if (this.config.pkce) {
|
||||
const verifier = sessionStorage.getItem('sonr_oauth_code_verifier');
|
||||
if (verifier) {
|
||||
params.append('code_verifier', verifier);
|
||||
sessionStorage.removeItem('sonr_oauth_code_verifier');
|
||||
}
|
||||
}
|
||||
|
||||
// Add state if provided
|
||||
if (state) {
|
||||
params.append('state', state);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.tokenUrl!, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new OAuth2ClientError(error.error, error.error_description);
|
||||
}
|
||||
|
||||
const token = await response.json();
|
||||
|
||||
// Store tokens
|
||||
this.storeTokens(token);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
async refreshToken(refreshToken?: string): Promise<OAuth2Token> {
|
||||
const storedRefreshToken = refreshToken || this.getStoredToken()?.refresh_token;
|
||||
|
||||
if (!storedRefreshToken) {
|
||||
throw new OAuth2ClientError('invalid_grant', 'No refresh token available');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: storedRefreshToken,
|
||||
client_id: this.config.clientId,
|
||||
});
|
||||
|
||||
const response = await fetch(this.config.tokenUrl!, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new OAuth2ClientError(error.error, error.error_description);
|
||||
}
|
||||
|
||||
const token = await response.json();
|
||||
|
||||
// Store new tokens
|
||||
this.storeTokens(token);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info
|
||||
*/
|
||||
async getUserInfo(accessToken?: string): Promise<OAuth2UserInfo> {
|
||||
const token = accessToken || this.getStoredToken()?.access_token;
|
||||
|
||||
if (!token) {
|
||||
throw new OAuth2ClientError('invalid_request', 'No access token available');
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.userInfoUrl!, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new OAuth2ClientError(error.error, error.error_description);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke token
|
||||
*/
|
||||
async revokeToken(
|
||||
token: string,
|
||||
tokenType: 'access_token' | 'refresh_token' = 'access_token'
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
token_type_hint: tokenType,
|
||||
client_id: this.config.clientId,
|
||||
});
|
||||
|
||||
const response = await fetch('/oauth2/revoke', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 200) {
|
||||
const error = await response.json();
|
||||
throw new OAuth2ClientError(error.error, error.error_description);
|
||||
}
|
||||
|
||||
// Clear stored tokens if revoking refresh token
|
||||
if (tokenType === 'refresh_token') {
|
||||
this.clearStoredTokens();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
const token = this.getStoredToken();
|
||||
|
||||
if (token?.access_token) {
|
||||
try {
|
||||
await this.revokeToken(token.access_token, 'access_token');
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke access token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (token?.refresh_token) {
|
||||
try {
|
||||
await this.revokeToken(token.refresh_token, 'refresh_token');
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke refresh token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.clearStoredTokens();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
const token = this.getStoredToken();
|
||||
if (!token?.access_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
const expiresAt = localStorage.getItem('sonr_oauth_expires_at');
|
||||
if (expiresAt && Date.now() > Number.parseInt(expiresAt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored access token
|
||||
*/
|
||||
getAccessToken(): string | null {
|
||||
return this.getStoredToken()?.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored refresh token
|
||||
*/
|
||||
getRefreshToken(): string | null {
|
||||
return this.getStoredToken()?.refresh_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current code verifier (for testing/debugging)
|
||||
*/
|
||||
getCodeVerifier(): string | undefined {
|
||||
return this.codeVerifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private methods
|
||||
*/
|
||||
|
||||
private generateState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return this.base64UrlEncode(array);
|
||||
}
|
||||
|
||||
private async generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
||||
const verifier = this.generateCodeVerifier();
|
||||
const challenge = await this.generateCodeChallenge(verifier);
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
private generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return this.base64UrlEncode(array);
|
||||
}
|
||||
|
||||
private async generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return this.base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
private base64UrlEncode(array: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
private storeTokens(token: OAuth2Token): void {
|
||||
localStorage.setItem('sonr_oauth_token', JSON.stringify(token));
|
||||
|
||||
// Calculate and store expiration time
|
||||
if (token.expires_in) {
|
||||
const expiresAt = Date.now() + token.expires_in * 1000;
|
||||
localStorage.setItem('sonr_oauth_expires_at', expiresAt.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredToken(): OAuth2Token | null {
|
||||
const tokenString = localStorage.getItem('sonr_oauth_token');
|
||||
if (!tokenString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(tokenString);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearStoredTokens(): void {
|
||||
localStorage.removeItem('sonr_oauth_token');
|
||||
localStorage.removeItem('sonr_oauth_expires_at');
|
||||
sessionStorage.removeItem('sonr_oauth_code_verifier');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 client error
|
||||
*/
|
||||
export class OAuth2ClientError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message?: string,
|
||||
public uri?: string
|
||||
) {
|
||||
super(message || code);
|
||||
this.name = 'OAuth2ClientError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OAuth2 callback URL
|
||||
*/
|
||||
export function parseCallbackUrl(url: string): {
|
||||
code?: string;
|
||||
state?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
} {
|
||||
const urlObj = new URL(url);
|
||||
const params = new URLSearchParams(urlObj.search);
|
||||
|
||||
return {
|
||||
code: params.get('code') || undefined,
|
||||
state: params.get('state') || undefined,
|
||||
error: params.get('error') || undefined,
|
||||
error_description: params.get('error_description') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
export function isTokenExpired(_token: OAuth2Token): boolean {
|
||||
const expiresAt = localStorage.getItem('sonr_oauth_expires_at');
|
||||
if (!expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() > Number.parseInt(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate token expiration time
|
||||
*/
|
||||
export function calculateTokenExpiry(expiresIn: number): Date {
|
||||
return new Date(Date.now() + expiresIn * 1000);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility function for merging Tailwind CSS classes with proper conflict resolution.
|
||||
*
|
||||
* This function combines clsx for conditional class names with tailwind-merge
|
||||
* for handling Tailwind CSS class conflicts. It ensures that conflicting
|
||||
* utility classes are properly resolved (e.g., "px-4 px-6" becomes "px-6").
|
||||
*
|
||||
* @param inputs - Class values that can be strings, objects, arrays, or conditional expressions
|
||||
* @returns A string of merged and deduplicated CSS classes
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* cn("px-4 py-2", "bg-blue-500", { "text-white": true, "text-black": false })
|
||||
* // Returns: "px-4 py-2 bg-blue-500 text-white"
|
||||
*
|
||||
* cn("px-4", "px-6") // Conflicting classes - returns: "px-6"
|
||||
* cn("bg-red-500", condition && "bg-blue-500") // Conditional classes
|
||||
* ```
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 195 100% 54%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 195 100% 54%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 195 100% 54%;
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 195 100% 54%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user