* clear

* feat: Add everything

* fix: Commenht
This commit is contained in:
Prad Nukala
2025-10-03 14:45:52 -04:00
committed by GitHub
parent 43b4a11c06
commit 13e6c3e84d
1935 changed files with 655061 additions and 40058 deletions
+16
View File
@@ -0,0 +1,16 @@
[tool.commitizen]
name = "cz_customize"
tag_format = "pkg-ui/v$version"
ignored_tag_formats = ["*/v${version}", "v${version}"]
version_scheme = "semver"
version_provider = "npm"
update_changelog_on_bump = true
major_version_zero = true
pre_bump_hooks = ["bash ../../scripts/hook-bump-pre.sh"]
post_bump_hooks = ["pnpm --filter '@sonr.io/ui' publish --no-git-checks"]
[tool.commitizen.customize]
bump_pattern = "^(feat|fix|refactor|perf|BREAKING CHANGE)"
bump_map = { "BREAKING CHANGE" = "MAJOR", "feat" = "MINOR", "fix" = "PATCH", "refactor" = "PATCH", "perf" = "PATCH" }
default_bump = "PATCH"
changelog_pattern = "^(feat|fix|refactor|docs|build)\\(pkg-ui\\)(!)?:"
View File
+168
View File
@@ -0,0 +1,168 @@
# @sonr.io/ui
Centralized UI component library for the Sonr ecosystem using shadcn/ui.
## 🎨 Overview
This package provides a fully centralized component library following shadcn/ui patterns. All UI components, styles, and utilities are managed exclusively in this package and imported by web applications. No components are duplicated in individual apps.
## 📦 Installation
The package is already configured in the monorepo. Web applications import components directly:
```tsx
import { Button, Alert, Input } from "@sonr.io/ui";
import { cn } from "@sonr.io/ui";
```
## 🚀 Adding New Components
**All components are managed centrally in this package.** To add new shadcn components:
```bash
# Navigate to the UI package (REQUIRED)
cd packages/ui
# Add a new component using shadcn CLI
npx shadcn@latest add dialog
# The component will be added to src/components/ui/
```
⚠️ **Important**: Never run `npx shadcn add` from web application directories. All components must be added here.
## 📁 Structure
```
packages/ui/
├── src/
│ ├── components/
│ │ ├── ui/ # shadcn components (managed by CLI)
│ │ │ ├── button.tsx
│ │ │ ├── input.tsx
│ │ │ └── alert.tsx
│ │ └── index.ts # Main exports
│ ├── lib/
│ │ └── utils.ts # cn utility function
│ └── styles/
│ └── globals.css # Theme and CSS variables
├── components.json # shadcn CLI configuration
├── tailwind.config.js # Tailwind configuration
└── README.md # This file
```
## 🎨 Theming
The primary brand color is `#17c2ff` (cyan). All theme variables are defined in `src/styles/globals.css`:
- **Light mode**: CSS variables under `:root`
- **Dark mode**: CSS variables under `.dark`
- **Primary color**: HSL(195, 100%, 54%)
## 💻 Usage in Applications
### 1. Import Global Styles
In your app's `globals.css`:
```css
@import "@sonr.io/ui/styles/globals.css";
/* App-specific styles below if needed */
```
### 2. Import Components
```tsx
import { Button, Input, Alert, AlertTitle, AlertDescription } from "@sonr.io/ui";
import { cn } from "@sonr.io/ui";
export function MyComponent() {
return (
<div className="space-y-4">
<Input placeholder="Enter text..." />
<Button variant="default" size="lg">
Click me
</Button>
<Alert>
<AlertTitle>Success!</AlertTitle>
<AlertDescription>Your action was completed.</AlertDescription>
</Alert>
</div>
);
}
```
## 🧩 Available Components
### Core Components (shadcn/ui)
- **Button**: Multiple variants (default, destructive, outline, secondary, ghost, link)
- **Input**: Styled form input with full accessibility
- **Alert**: Alert messages with title and description support
- **Card**: Container with header, content, and footer sections
### Utility Functions
- **cn()**: Class name utility for merging Tailwind classes
## 🛠️ Development
```bash
# Install dependencies
pnpm install
# Lint the package
pnpm lint
# Type check
pnpm exec tsc --noEmit
# Add new shadcn component
npx shadcn@latest add [component-name]
```
## 📋 Centralized Workflow
This monorepo follows a **fully centralized** UI component strategy:
1.**Single Source of Truth**: All UI components live only in `packages/ui`
2.**No Duplication**: Web apps do not have their own UI components
3.**Consistent Theming**: All apps share the exact same theme
4.**Simplified Maintenance**: Update once, affects all apps
5.**shadcn CLI Management**: Run all shadcn commands from `packages/ui` only
## ⚠️ Important Guidelines
- **Never** create components.json in web applications
- **Never** run `npx shadcn add` from app directories
- **Always** add new components from the `packages/ui` directory
- **Always** export new components from `src/components/index.ts`
## 🔄 Migration from Distributed to Centralized
If migrating from a distributed component structure:
1. Remove any `components.json` files from web apps
2. Delete any shadcn components from app directories
3. Update imports to use `@sonr.io/ui`
4. Import global styles from the UI package
## 📝 Component Addition Checklist
When adding a new shadcn component:
- [ ] Navigate to `packages/ui` directory
- [ ] Run `npx shadcn@latest add [component]`
- [ ] Export component from `src/components/index.ts`
- [ ] Test import in a web application
- [ ] Update this README with the new component
## 🚀 Future Enhancements
- [ ] Storybook for component documentation
- [ ] Unit tests for all components
- [ ] Additional shadcn components as needed
- [ ] Custom Sonr-specific components
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@sonr.io/ui/components",
"utils": "@sonr.io/ui/lib/utils",
"ui": "@sonr.io/ui/components",
"hooks": "@sonr.io/ui/hooks",
"lib": "@sonr.io/ui/lib"
},
"iconLibrary": "lucide-react"
}
+81
View File
@@ -0,0 +1,81 @@
{
"name": "@sonr.io/ui",
"version": "0.0.10",
"private": true,
"main": "src/components/index.ts",
"types": "src/components/index.ts",
"exports": {
".": "./src/components/index.ts",
"./components": "./src/components/index.ts",
"./components/*": "./src/components/*.tsx",
"./components/ui/*": "./src/components/ui/*.tsx",
"./components/dashboard/*": "./src/components/dashboard/*.tsx",
"./lib/utils": "./src/lib/utils.ts",
"./lib/*": "./src/lib/*.ts",
"./hooks": "./src/hooks/index.ts",
"./hooks/*": "./src/hooks/*.ts",
"./styles/*": "./src/styles/*"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc --noEmit",
"build:ui": "tsc --noEmit",
"dev": "tsc --noEmit --watch",
"clean": "rm -rf dist",
"test": "echo 'No tests defined for @sonr.io/ui'",
"lint": "biome lint .",
"format": "biome format . --write",
"check": "biome check .",
"typecheck": "tsc --noEmit",
"release": "cz --no-raise 6,21 bump --yes --increment PATCH"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/fiber": "^9.3.0",
"@sonr.io/com": "workspace:*",
"@types/three": "^0.180.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.12",
"lucide-react": "^0.263.1",
"next": "14.2.32",
"react-day-picker": "^9.9.0",
"react-hook-form": "^7.62.0",
"recharts": "^3.1.2",
"tailwind-merge": "^2.5.4",
"three": "^0.180.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@biomejs/biome": "^2.1.2",
"@types/react": "^18.3.23",
"@types/react-dom": "^19.1.9",
"concurrently": "^8.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.6",
"typescript": "^5.3.0"
}
}
@@ -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';
+310
View File
@@ -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';
+59
View File
@@ -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 };
+43
View File
@@ -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,
};
+79
View File
@@ -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 };
+55
View File
@@ -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 };
+339
View File
@@ -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 };
+155
View File
@@ -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,
};
+129
View File
@@ -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 };
+152
View File
@@ -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,
};
+44
View File
@@ -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 };
+21
View File
@@ -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 };
+31
View File
@@ -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 };
+151
View File
@@ -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 };
+130
View File
@@ -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,
};
+734
View File
@@ -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 };
+90
View File
@@ -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 };
+54
View File
@@ -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 };
+47
View File
@@ -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 };
+30
View File
@@ -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 };
+19
View File
@@ -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;
}
+442
View File
@@ -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;
+424
View File
@@ -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);
}
+25
View File
@@ -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));
}
+75
View File
@@ -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;
}
}
+71
View File
@@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
+32
View File
@@ -0,0 +1,32 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"isolatedModules": true,
"composite": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components": ["./src/components"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/styles/*": ["./src/styles/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}