add ui code

This commit is contained in:
9qeklajc
2025-10-21 22:07:47 +02:00
parent 0da08fb945
commit 4d28586d13
118 changed files with 35878 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"extends": [
"next",
"next/core-web-vitals",
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"plugins": ["react", "@typescript-eslint"]
}
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+8
View File
@@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"jsxSingleQuote": true,
"plugins": ["prettier-plugin-tailwindcss"]
}
+38
View File
@@ -0,0 +1,38 @@
FROM node:23-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN npm i
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+172
View File
@@ -0,0 +1,172 @@
'use client';
import { useAuth } from '@/lib/auth/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import Link from 'next/link';
import { ArrowUpCircleIcon } from 'lucide-react';
import { registerUser, SchemaRegisterProps } from '@/lib/api/services/auth';
export default function NostrRegisterPage() {
const router = useRouter();
const { connectNostr } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState<SchemaRegisterProps>({
npub: '',
name: '',
});
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleNostrConnect = async () => {
setIsLoading(true);
try {
const publicKey = await connectNostr();
if (publicKey) {
setFormData((prev) => ({ ...prev, npub: publicKey }));
toast.success(
'Nostr connected. Please enter your name to complete registration.'
);
} else {
toast.error(
'Failed to connect. Please make sure your Nostr extension is installed and enabled.'
);
}
} catch (error) {
console.error('Nostr connection error:', error);
toast.error('Failed to connect to Nostr. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.npub || formData.npub.length < 10) {
toast.error('Please enter a valid Nostr public key');
return;
}
if (!formData.name) {
toast.error('Please enter your name');
return;
}
setIsLoading(true);
try {
// Register the user
const result = await registerUser(formData);
console.log('Registration successful:', result);
toast.success('Account created successfully');
router.push('/login');
} catch (error) {
console.error('Registration error:', error);
toast.error('Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className='flex min-h-screen items-center justify-center p-4'>
<div className='w-full max-w-md'>
<div className='flex flex-col gap-6'>
<form onSubmit={handleRegister}>
<div className='flex flex-col gap-6'>
<div className='flex flex-col items-center gap-2'>
<Link
href='/'
className='flex flex-col items-center gap-2 font-medium'
>
<div className='flex h-8 w-8 items-center justify-center rounded-md'>
<ArrowUpCircleIcon className='size-6' />
</div>
<span className='sr-only'>Routstr</span>
</Link>
<h1 className='text-xl font-bold'>Create an Account</h1>
<div className='text-center text-sm'>
Already have an account?{' '}
<Link href='/login' className='underline underline-offset-4'>
Sign in
</Link>
</div>
</div>
<div className='flex flex-col gap-6'>
<div className='grid gap-2'>
<Label htmlFor='npub'>Nostr Public Key (npub)</Label>
<Input
id='npub'
name='npub'
type='text'
placeholder='npub1...'
value={formData.npub}
onChange={handleInputChange}
required
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='name'>Name</Label>
<Input
id='name'
name='name'
type='text'
placeholder='John Doe'
value={formData.name}
onChange={handleInputChange}
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create Account'}
</Button>
</div>
<div className='after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t'>
<span className='bg-background text-muted-foreground relative z-10 px-2'>
Or
</span>
</div>
<div className='grid gap-4'>
<Button
variant='outline'
className='w-full'
onClick={handleNostrConnect}
disabled={isLoading}
type='button'
>
<svg
className='mr-2 h-4 w-4'
viewBox='0 0 256 256'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M158.4 28.4c-31.8-31.8-83.1-31.8-114.9 0s-31.8 83.1 0 114.9l57.4 57.4 57.4-57.4c31.8-31.8 31.8-83.1 0-114.9z'
fill='currentColor'
/>
<path
d='M215.8 199.3c-31.8-31.8-83.1-31.8-114.9 0L43.6 256l57.4-57.4c31.8-31.8 31.8-83.1 0-114.9L158.4 28.4 101 85.8c-31.8 31.8-31.8 83.1 0 114.9l114.8-1.4z'
fill='currentColor'
/>
</svg>
Connect with Nostr Extension
</Button>
</div>
</div>
</form>
<div className='text-muted-foreground hover:[&_a]:text-primary text-center text-xs text-balance [&_a]:underline [&_a]:underline-offset-4'>
By clicking create account, you agree to our{' '}
<Link href='#'>Terms of Service</Link> and{' '}
<Link href='#'>Privacy Policy</Link>.
</div>
</div>
</div>
</div>
);
}
+614
View File
@@ -0,0 +1,614 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+136
View File
@@ -0,0 +1,136 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.923 0.003 48.717);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.553 0.013 58.071);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom animations */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Routstr',
description: 'Routstr model management',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang='en'>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>{children}</Providers>
</body>
</html>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { adminLogin } from '@/lib/api/services/auth';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { toast } from 'sonner';
export default function AdminLoginPage() {
const router = useRouter();
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (ConfigurationService.isTokenValid()) {
router.push('/');
}
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error('Please enter your password');
return;
}
setIsLoading(true);
try {
await adminLogin(password);
toast.success('Successfully logged in');
router.push('/');
} catch (error) {
console.error('Login error:', error);
toast.error('Invalid password. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className='flex min-h-screen items-center justify-center bg-gray-50 p-4'>
<Card className='w-full max-w-md'>
<CardHeader className='space-y-1'>
<CardTitle className='text-center text-2xl font-bold'>
Admin Login
</CardTitle>
<CardDescription className='text-center'>
Enter your admin password to access the dashboard
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Input
type='password'
placeholder='Admin Password'
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
autoFocus
required
/>
</div>
<Button type='submit' className='w-full' disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
'use client';
import { ModelSelector } from '@/components/ModelSelector';
import { ModelTester } from '@/components/ModelTester';
import { ApiEndpointTester } from '@/components/ApiEndpointTester';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { AdminService } from '@/lib/api/services/admin';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle, Users, Globe } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { useMemo } from 'react';
export default function ModelsPage() {
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useQuery({
queryKey: ['admin-models-with-providers'],
queryFn: () => AdminService.getModelsWithProviders(),
refetchOnWindowFocus: false,
});
const { models = [], groups = [] } = modelsData || {};
const groupedModels = useMemo(() => {
if (!models) return {};
return models.reduce<Record<string, typeof models>>((acc, model) => {
const provider = model.provider;
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(model);
return acc;
}, {});
}, [models]);
const groupDataMap = useMemo(() => {
return new Map(groups.map((group) => [group.provider, group]));
}, [groups]);
const providerInfo = useMemo(() => {
return Object.entries(groupedModels).map(([provider, providerModels]) => {
const groupData = groupDataMap.get(provider);
const activeModels = providerModels.filter((m) => !m.soft_deleted).length;
const totalModels = providerModels.length;
return {
provider,
activeModels,
totalModels,
groupData,
hasGroupUrl: !!groupData?.group_url,
hasGroupApiKey: !!groupData?.group_api_key,
};
});
}, [groupedModels, groupDataMap]);
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<h1 className='text-2xl font-bold tracking-tight'>
Model Management & API Testing
</h1>
</div>
<Tabs defaultValue='manage' className='w-full'>
<TabsList className='grid w-full grid-cols-3'>
<TabsTrigger value='manage'>Manage Models</TabsTrigger>
{/*<TabsTrigger value='test-basic'>Basic Testing</TabsTrigger>
<TabsTrigger value='test-api'>API Endpoints</TabsTrigger> */}
</TabsList>
<TabsContent value='manage' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Manage your AI models organized by provider groups. Configure
API keys, and organize models by provider groups.
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[60px] w-full' />
<Skeleton className='h-[400px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models. Please try refreshing the page.
</AlertDescription>
</Alert>
) : (
<Tabs defaultValue='all' className='w-full'>
<div className='space-y-4'>
{/* Provider Tabs Navigation */}
<div className='rounded-lg border p-1'>
<TabsList className='grid w-full max-w-full auto-cols-fr grid-flow-col overflow-x-auto'>
<TabsTrigger
value='all'
className='flex items-center gap-2'
>
<Globe className='h-4 w-4' />
All Models
<Badge variant='secondary' className='ml-1'>
{models.length}
</Badge>
</TabsTrigger>
{providerInfo.map(
({ provider, activeModels, totalModels }) => (
<TabsTrigger
key={provider}
value={provider}
className='flex min-w-fit items-center gap-2'
>
<Users className='h-4 w-4' />
{provider}
<div className='flex items-center gap-1'>
<Badge variant='secondary' className='ml-1'>
{activeModels}/{totalModels}
</Badge>
</div>
</TabsTrigger>
)
)}
</TabsList>
</div>
{/* All Models Tab */}
<TabsContent value='all'>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Overview of all models across all provider groups.
</div>
<ModelSelector />
</div>
</TabsContent>
{Object.entries(groupedModels).map(
([provider, providerModels]) => {
const groupData = groupDataMap.get(provider);
return (
<TabsContent key={provider} value={provider}>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h3 className='flex items-center gap-2 text-lg font-semibold'>
<Users className='h-5 w-5' />
{provider}
</h3>
<div className='text-muted-foreground flex items-center gap-4 text-sm'>
{providerModels.filter(
(m) => m.soft_deleted
).length > 0 && (
<span className='text-orange-600'>
{
providerModels.filter(
(m) => m.soft_deleted
).length
}{' '}
soft deleted
</span>
)}
{groupData?.group_url && (
<span className='flex items-center gap-1'>
<Globe className='h-3 w-3' />
{groupData.group_url}
</span>
)}
</div>
</div>
</div>
<ModelSelector
filterProvider={provider}
groupData={groupData}
showProviderActions={true}
/>
</div>
</TabsContent>
);
}
)}
</div>
</Tabs>
)}
</TabsContent>
<TabsContent value='test-basic' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Test model credentials and connectivity with basic chat
completion requests through the secure proxy (resolves CORS
and Docker network issues). Models can be tested even without
API keys configured (useful for free models or when
authentication is handled elsewhere).
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[200px] w-full' />
<Skeleton className='h-[100px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for testing. Please try refreshing
the page.
</AlertDescription>
</Alert>
) : (
<ModelTester models={models} />
)}
</TabsContent>
<TabsContent value='test-api' className='space-y-4'>
<div className='text-muted-foreground text-sm'>
Comprehensive testing of all OpenAI API endpoints including
chat completions, embeddings, image generation, audio
synthesis, and model listing through the secure proxy
(resolves CORS and Docker network issues). Models can be
tested with or without API keys configured.
</div>
{isLoadingModels ? (
<div className='space-y-4'>
<Skeleton className='h-[300px] w-full' />
<Skeleton className='h-[200px] w-full' />
</div>
) : modelsError ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load models for API testing. Please try
refreshing the page.
</AlertDescription>
</Alert>
) : (
<ApiEndpointTester models={models} />
)}
</TabsContent>
</Tabs>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { DetailedWalletBalance } from '@/components/detailed-wallet-balance';
export default function Page() {
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='p-0'>
<SiteHeader />
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8'>
<h1 className='text-3xl font-bold tracking-tight'>
Admin Dashboard
</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
</div>
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance refreshInterval={5000} />
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
import { Toaster } from 'sonner';
import { AuthProvider } from '@/lib/auth/AuthContext';
import { ProtectedRoute } from '@/lib/auth/ProtectedRoute';
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
refetchOnWindowFocus: false,
retry: 2,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</AuthProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
+690
View File
@@ -0,0 +1,690 @@
'use client';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
AdminService,
UpstreamProvider,
CreateUpstreamProvider,
UpdateUpstreamProvider,
} from '@/lib/api/services/admin';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
Plus,
Pencil,
Trash2,
Server,
Eye,
EyeOff,
Database,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState } from 'react';
import { toast } from 'sonner';
export default function ProvidersPage() {
const queryClient = useQueryClient();
const [editingProvider, setEditingProvider] =
useState<UpstreamProvider | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [showApiKey, setShowApiKey] = useState<number | null>(null);
const [expandedProviders, setExpandedProviders] = useState<Set<number>>(
new Set()
);
const [viewingModels, setViewingModels] = useState<number | null>(null);
const [formData, setFormData] = useState<CreateUpstreamProvider>({
provider_type: 'openrouter',
base_url: '',
api_key: '',
api_version: null,
enabled: true,
});
const {
data: providers = [],
isLoading,
error,
} = useQuery({
queryKey: ['upstream-providers'],
queryFn: () => AdminService.getUpstreamProviders(),
refetchOnWindowFocus: false,
});
const { data: providerModels, isLoading: isLoadingModels } = useQuery({
queryKey: ['provider-models', viewingModels],
queryFn: () =>
viewingModels
? AdminService.getProviderModels(viewingModels)
: Promise.resolve(null),
enabled: !!viewingModels,
refetchOnWindowFocus: false,
});
const createMutation = useMutation({
mutationFn: (data: CreateUpstreamProvider) =>
AdminService.createUpstreamProvider(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
setIsCreateDialogOpen(false);
toast.success('Provider created successfully');
resetForm();
},
onError: (error: Error) => {
toast.error(`Failed to create provider: ${error.message}`);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateUpstreamProvider }) =>
AdminService.updateUpstreamProvider(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
setIsEditDialogOpen(false);
setEditingProvider(null);
toast.success('Provider updated successfully');
},
onError: (error: Error) => {
toast.error(`Failed to update provider: ${error.message}`);
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => AdminService.deleteUpstreamProvider(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
toast.success('Provider deleted successfully');
},
onError: (error: Error) => {
toast.error(`Failed to delete provider: ${error.message}`);
},
});
const resetForm = () => {
setFormData({
provider_type: 'openrouter',
base_url: '',
api_key: '',
api_version: null,
enabled: true,
});
};
const handleCreate = () => {
createMutation.mutate(formData);
};
const handleEdit = (provider: UpstreamProvider) => {
setEditingProvider(provider);
setFormData({
provider_type: provider.provider_type,
base_url: provider.base_url,
api_key: '',
api_version: provider.api_version || null,
enabled: provider.enabled,
});
setIsEditDialogOpen(true);
};
const handleUpdate = () => {
if (!editingProvider) return;
const updateData: UpdateUpstreamProvider = {
provider_type: formData.provider_type,
base_url: formData.base_url,
api_version: formData.api_version,
enabled: formData.enabled,
};
if (formData.api_key) {
updateData.api_key = formData.api_key;
}
updateMutation.mutate({ id: editingProvider.id, data: updateData });
};
const handleDelete = (id: number) => {
if (confirm('Are you sure you want to delete this provider?')) {
deleteMutation.mutate(id);
}
};
const getDefaultBaseUrl = (type: string) => {
const defaults: Record<string, string> = {
openrouter: 'https://openrouter.ai/api/v1',
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com/v1',
azure: '',
generic: '',
};
return defaults[type] || '';
};
const toggleProviderExpansion = (providerId: number) => {
const newExpanded = new Set(expandedProviders);
if (newExpanded.has(providerId)) {
newExpanded.delete(providerId);
} else {
newExpanded.add(providerId);
}
setExpandedProviders(newExpanded);
if (!newExpanded.has(providerId)) {
setViewingModels(null);
} else {
setViewingModels(providerId);
}
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<div>
<h1 className='text-2xl font-bold tracking-tight'>
Upstream Providers
</h1>
<p className='text-muted-foreground mt-2 text-sm'>
Manage your AI provider connections and credentials
</p>
</div>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button className='flex items-center gap-2'>
<Plus className='h-4 w-4' />
Add Provider
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>Add Upstream Provider</DialogTitle>
<DialogDescription>
Configure a new AI provider connection
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='provider_type'>Provider Type</Label>
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData({
...formData,
provider_type: value,
base_url: getDefaultBaseUrl(value),
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='openrouter'>OpenRouter</SelectItem>
<SelectItem value='openai'>OpenAI</SelectItem>
<SelectItem value='anthropic'>Anthropic</SelectItem>
<SelectItem value='azure'>Azure OpenAI</SelectItem>
<SelectItem value='generic'>Generic</SelectItem>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='base_url'>Base URL</Label>
<Input
id='base_url'
value={formData.base_url}
onChange={(e) =>
setFormData({ ...formData, base_url: e.target.value })
}
placeholder='https://api.example.com/v1'
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='api_key'>API Key</Label>
<Input
id='api_key'
type='password'
value={formData.api_key}
onChange={(e) =>
setFormData({ ...formData, api_key: e.target.value })
}
placeholder='sk-...'
/>
</div>
{formData.provider_type === 'azure' && (
<div className='grid gap-2'>
<Label htmlFor='api_version'>API Version</Label>
<Input
id='api_version'
value={formData.api_version || ''}
onChange={(e) =>
setFormData({
...formData,
api_version: e.target.value || null,
})
}
placeholder='2024-02-15-preview'
/>
</div>
)}
<div className='flex items-center space-x-2'>
<Switch
id='enabled'
checked={formData.enabled}
onCheckedChange={(checked) =>
setFormData({ ...formData, enabled: checked })
}
/>
<Label htmlFor='enabled'>Enabled</Label>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setIsCreateDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className='space-y-4'>
<Skeleton className='h-[100px] w-full' />
<Skeleton className='h-[100px] w-full' />
</div>
) : error ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load providers. Please try refreshing the page.
</AlertDescription>
</Alert>
) : providers.length === 0 ? (
<Card>
<CardContent className='flex flex-col items-center justify-center py-12'>
<Server className='text-muted-foreground mb-4 h-12 w-12' />
<h3 className='mb-2 text-lg font-semibold'>
No providers configured
</h3>
<p className='text-muted-foreground mb-4 text-sm'>
Get started by adding your first upstream provider
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className='mr-2 h-4 w-4' />
Add Provider
</Button>
</CardContent>
</Card>
) : (
<div className='grid gap-4'>
{providers.map((provider) => (
<Card key={provider.id}>
<CardHeader>
<div className='flex items-start justify-between'>
<div className='flex-1'>
<div className='flex items-center gap-2'>
<CardTitle className='text-lg'>
{provider.provider_type}
</CardTitle>
<Badge
variant={
provider.enabled ? 'default' : 'secondary'
}
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<CardDescription className='mt-1'>
{provider.base_url}
</CardDescription>
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => toggleProviderExpansion(provider.id)}
>
<Database className='mr-1 h-4 w-4' />
Models
{expandedProviders.has(provider.id) ? (
<ChevronUp className='ml-1 h-4 w-4' />
) : (
<ChevronDown className='ml-1 h-4 w-4' />
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleEdit(provider)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handleDelete(provider.id)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
<div className='flex items-center justify-between text-sm'>
<span className='text-muted-foreground'>
Provider ID:
</span>
<span className='font-mono'>{provider.id}</span>
</div>
{provider.api_version && (
<div className='flex items-center justify-between text-sm'>
<span className='text-muted-foreground'>
API Version:
</span>
<span className='font-mono'>
{provider.api_version}
</span>
</div>
)}
<div className='flex items-center justify-between text-sm'>
<span className='text-muted-foreground'>
API Key:
</span>
<div className='flex items-center gap-2'>
<span className='font-mono'>
{showApiKey === provider.id
? provider.api_key || '[REDACTED]'
: '••••••••'}
</span>
<Button
variant='ghost'
size='sm'
onClick={() =>
setShowApiKey(
showApiKey === provider.id
? null
: provider.id
)
}
>
{showApiKey === provider.id ? (
<EyeOff className='h-4 w-4' />
) : (
<Eye className='h-4 w-4' />
)}
</Button>
</div>
</div>
</div>
{expandedProviders.has(provider.id) && (
<div className='mt-4 border-t pt-4'>
{isLoadingModels &&
viewingModels === provider.id ? (
<div className='space-y-2'>
<Skeleton className='h-[40px] w-full' />
<Skeleton className='h-[40px] w-full' />
</div>
) : providerModels &&
viewingModels === provider.id ? (
<Tabs defaultValue='db' className='w-full'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='db'>
Database Models
<Badge variant='secondary' className='ml-2'>
{providerModels.db_models.length}
</Badge>
</TabsTrigger>
<TabsTrigger value='remote'>
Remote Models
<Badge variant='secondary' className='ml-2'>
{providerModels.remote_models.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent
value='db'
className='mt-4 space-y-2'
>
{providerModels.db_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No database models configured
</div>
) : (
<div className='space-y-2'>
{providerModels.db_models.map((model) => (
<div
key={model.id}
className='hover:bg-accent flex items-center justify-between rounded-lg border p-3 transition-colors'
>
<div className='flex-1'>
<div className='flex items-center gap-2'>
<span className='font-mono text-sm font-medium'>
{model.id}
</span>
<Badge
variant={
model.enabled
? 'default'
: 'secondary'
}
className='text-xs'
>
{model.enabled
? 'Enabled'
: 'Disabled'}
</Badge>
</div>
<div className='text-muted-foreground mt-1 text-xs'>
{model.description || model.name}
</div>
</div>
<div className='text-muted-foreground text-xs'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent
value='remote'
className='mt-4 space-y-2'
>
{providerModels.remote_models.length === 0 ? (
<div className='text-muted-foreground py-4 text-center text-sm'>
No remote models available
</div>
) : (
<div className='space-y-2'>
{providerModels.remote_models.map(
(model) => (
<div
key={model.id}
className='hover:bg-accent flex items-center justify-between rounded-lg border p-3 transition-colors'
>
<div className='flex-1'>
<div className='font-mono text-sm font-medium'>
{model.id}
</div>
<div className='text-muted-foreground mt-1 text-xs'>
{model.description ||
model.name}
</div>
</div>
<div className='text-muted-foreground text-xs'>
{model.context_length?.toLocaleString()}{' '}
tokens
</div>
</div>
)
)}
</div>
)}
</TabsContent>
</Tabs>
) : null}
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>Edit Upstream Provider</DialogTitle>
<DialogDescription>
Update provider configuration
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='edit_provider_type'>Provider Type</Label>
<Select
value={formData.provider_type}
onValueChange={(value) => {
setFormData({
...formData,
provider_type: value,
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='openrouter'>OpenRouter</SelectItem>
<SelectItem value='openai'>OpenAI</SelectItem>
<SelectItem value='anthropic'>Anthropic</SelectItem>
<SelectItem value='azure'>Azure OpenAI</SelectItem>
<SelectItem value='generic'>Generic</SelectItem>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit_base_url'>Base URL</Label>
<Input
id='edit_base_url'
value={formData.base_url}
onChange={(e) =>
setFormData({ ...formData, base_url: e.target.value })
}
placeholder='https://api.example.com/v1'
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit_api_key'>
API Key (leave blank to keep current)
</Label>
<Input
id='edit_api_key'
type='password'
value={formData.api_key}
onChange={(e) =>
setFormData({ ...formData, api_key: e.target.value })
}
placeholder='Leave blank to keep current'
/>
</div>
{formData.provider_type === 'azure' && (
<div className='grid gap-2'>
<Label htmlFor='edit_api_version'>API Version</Label>
<Input
id='edit_api_version'
value={formData.api_version || ''}
onChange={(e) =>
setFormData({
...formData,
api_version: e.target.value || null,
})
}
placeholder='2024-02-15-preview'
/>
</div>
)}
<div className='flex items-center space-x-2'>
<Switch
id='edit_enabled'
checked={formData.enabled}
onCheckedChange={(checked) =>
setFormData({ ...formData, enabled: checked })
}
/>
<Label htmlFor='edit_enabled'>Enabled</Label>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setIsEditDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleUpdate}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? 'Updating...' : 'Update'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SidebarInset>
</SidebarProvider>
);
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ServerConfigSettings } from '@/components/settings/server-config-settings';
import { SiteHeader } from '@/components/site-header';
import { AppSidebar } from '@/components/app-sidebar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { Toaster } from 'sonner';
export default function SettingsPage() {
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='flex items-center'>
<h1 className='text-2xl font-bold tracking-tight'>Settings</h1>
</div>
<Tabs defaultValue='server' className='w-full'>
<TabsList className='mb-4'>
<TabsTrigger value='server'>Server Configuration</TabsTrigger>
</TabsList>
<TabsContent value='server'>
<ServerConfigSettings />
</TabsContent>
</Tabs>
</div>
</div>
</SidebarInset>
<Toaster />
</SidebarProvider>
);
}
+353
View File
@@ -0,0 +1,353 @@
'use client';
import { useState } from 'react';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useQuery } from '@tanstack/react-query';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertCircle,
Copy,
RefreshCw,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client';
interface Transaction {
id: string;
created_at: string;
token: string;
amount: string;
}
interface PaginatedTransactionsResponse {
transactions: Transaction[];
total: number;
page: number;
per_page: number;
total_pages: number;
}
const TransactionService = {
getAllTransactions: async (): Promise<Transaction[]> => {
try {
const response = await apiClient.get<Transaction[]>('/api/transactions');
return response || [];
} catch (error) {
console.error('Failed to fetch transactions:', error);
throw new Error('Failed to fetch transactions');
}
},
getPaginatedTransactions: async (
page: number,
perPage: number
): Promise<PaginatedTransactionsResponse> => {
try {
const response = await apiClient.get<PaginatedTransactionsResponse>(
`/api/transactions/paginated/${page}/${perPage}`
);
return response;
} catch (error) {
console.error('Failed to fetch paginated transactions:', error);
throw new Error('Failed to fetch paginated transactions');
}
},
getRecentTransactions: async (limit: number): Promise<Transaction[]> => {
try {
const response = await apiClient.get<Transaction[]>(
`/api/transactions/recent/${limit}`
);
return response || [];
} catch (error) {
console.error('Failed to fetch recent transactions:', error);
throw new Error('Failed to fetch recent transactions');
}
},
};
export default function TransactionsPage() {
const [currentPage, setCurrentPage] = useState(1);
const perPage = 20;
// Fetch paginated transactions data
const {
data: paginationData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ['transactions', currentPage, perPage],
queryFn: () =>
TransactionService.getPaginatedTransactions(currentPage, perPage),
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30000, // 30 seconds
});
const transactions = paginationData?.transactions || [];
const totalPages = paginationData?.total_pages || 0;
const total = paginationData?.total || 0;
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const formatAmount = (amount: string) => {
return `${parseInt(amount).toLocaleString()} msats`;
};
const truncateToken = (token: string) => {
if (token.length <= 20) return token;
return `${token.slice(0, 10)}...${token.slice(-10)}`;
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success('Token copied to clipboard!');
} catch (error) {
console.error('Failed to copy to clipboard:', error);
toast.error('Failed to copy token');
}
};
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
const goToPrevious = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const goToNext = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
return (
<TooltipProvider>
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset>
<SiteHeader />
<div className='flex flex-1 flex-col'>
<div className='@container/main flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8'>
<div className='mb-6 flex items-center justify-between'>
<div>
<h1 className='text-2xl font-bold tracking-tight'>
Transaction History
</h1>
<p className='text-muted-foreground text-sm'>
View all Cashu token transactions processed by the system
</p>
</div>
<Button
onClick={() => refetch()}
variant='outline'
size='sm'
disabled={isLoading}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
{isLoading ? (
<div className='space-y-4'>
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className='h-[120px] w-full' />
))}
</div>
) : error ? (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
Failed to load transactions.{' '}
{error instanceof Error
? error.message
: 'Please check if the server is running and try refreshing the page.'}
</AlertDescription>
</Alert>
) : transactions.length === 0 ? (
<div className='py-8 text-center'>
<p className='text-muted-foreground'>
No transactions found.
</p>
</div>
) : (
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
Showing {(currentPage - 1) * perPage + 1} to{' '}
{Math.min(currentPage * perPage, total)} of {total}{' '}
transactions
</div>
<div className='text-muted-foreground text-sm'>
Page {currentPage} of {totalPages}
</div>
</div>
<div className='rounded-md border'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[100px]'>ID</TableHead>
<TableHead>Date & Time</TableHead>
<TableHead>Amount</TableHead>
<TableHead className='w-[400px]'>
Cashu Token
</TableHead>
<TableHead className='w-[60px]'>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((transaction) => (
<TableRow key={transaction.id}>
<TableCell className='font-mono text-xs'>
{transaction.id.slice(0, 8)}
</TableCell>
<TableCell>
<div className='text-sm'>
{formatDate(transaction.created_at)}
</div>
</TableCell>
<TableCell>
<Badge variant='secondary'>
{formatAmount(transaction.amount)}
</Badge>
</TableCell>
<TableCell>
<div className='flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<p className='max-w-[300px] cursor-pointer truncate rounded px-1 py-0.5 font-mono text-xs hover:bg-gray-100'>
{truncateToken(transaction.token)}
</p>
</TooltipTrigger>
<TooltipContent className='max-w-md break-all'>
<p className='font-mono text-xs'>
{transaction.token}
</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell>
<Button
variant='ghost'
size='sm'
onClick={() =>
copyToClipboard(transaction.token)
}
className='h-8 w-8 p-0'
>
<Copy className='h-4 w-4' />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-sm'>
Page {currentPage} of {totalPages}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
size='sm'
onClick={goToPrevious}
disabled={currentPage === 1}
>
<ChevronLeft className='mr-1 h-4 w-4' />
Previous
</Button>
{/* Page Numbers */}
<div className='flex items-center space-x-1'>
{Array.from(
{ length: Math.min(5, totalPages) },
(_, i) => {
const pageNumber =
currentPage <= 3
? i + 1
: currentPage >= totalPages - 2
? totalPages - 4 + i
: currentPage - 2 + i;
if (pageNumber < 1 || pageNumber > totalPages)
return null;
return (
<Button
key={pageNumber}
variant={
currentPage === pageNumber
? 'default'
: 'outline'
}
size='sm'
onClick={() => goToPage(pageNumber)}
className='h-9 w-9 p-0'
>
{pageNumber}
</Button>
);
}
)}
</div>
<Button
variant='outline'
size='sm'
onClick={goToNext}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className='ml-1 h-4 w-4' />
</Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { Button } from '@/components/ui/button';
import { useRouter } from 'next/navigation';
import { ShieldAlertIcon } from 'lucide-react';
export default function UnauthorizedPage() {
const router = useRouter();
return (
<div className='bg-background flex min-h-screen flex-col items-center justify-center p-4'>
<div className='flex max-w-md flex-col items-center space-y-6 text-center'>
<ShieldAlertIcon className='text-destructive h-24 w-24' />
<h1 className='text-4xl font-bold'>Access Denied</h1>
<p className='text-muted-foreground text-lg'>
You don&apos;t have permission to access this page. Please contact
your administrator if you believe this is an error.
</p>
<div className='flex gap-4'>
<Button onClick={() => router.push('/')}>Go to Dashboard</Button>
<Button variant='outline' onClick={() => router.back()}>
Go Back
</Button>
</div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+319
View File
@@ -0,0 +1,319 @@
'use client';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { ManualModelSchema, type ManualModel } from '@/lib/api/schemas/models';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Plus, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
interface AddModelFormProps {
onModelAdd: (model: ManualModel) => void;
onCancel?: () => void;
isOpen: boolean;
}
export function AddModelForm({
onModelAdd,
onCancel,
isOpen,
}: AddModelFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<ManualModel>({
resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
defaultValues: {
name: '',
full_name: '',
input_cost: 0,
output_cost: 0,
provider: '',
modelType: 'text' as const,
description: '',
contextLength: undefined,
},
});
const onSubmit = async (data: ManualModel) => {
setIsSubmitting(true);
try {
await onModelAdd(data);
toast.success('Model added successfully!');
form.reset();
onCancel?.();
} catch (error) {
toast.error('Failed to add model. Please try again.');
console.error('Error adding model:', error);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
form.reset();
onCancel?.();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='h-5 w-5' />
Add New Model
</DialogTitle>
<DialogDescription>
Manually add a new AI model to your collection
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Model Name *</FormLabel>
<FormControl>
<Input
placeholder='e.g., GPT-4o'
{...field}
className='w-full'
/>
</FormControl>
<FormDescription>
Display name for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='provider'
render={({ field }) => (
<FormItem>
<FormLabel>Provider *</FormLabel>
<FormControl>
<Input
placeholder='e.g., OpenAI, Anthropic'
{...field}
className='w-full'
/>
</FormControl>
<FormDescription>
AI model provider or company
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Input Cost (per 1M tokens) *</FormLabel>
<FormControl>
<Input
type='number'
step='0.001'
min='0'
placeholder='5.00'
{...field}
value={
field.value ? parseFloat(field.value.toFixed(3)) : ''
}
onChange={(e) => {
const value = parseFloat(e.target.value) || 0;
const rounded = Math.round(value * 1000) / 1000;
field.onChange(rounded);
}}
className='w-full'
/>
</FormControl>
<FormDescription>
Cost in USD per 1,000,000 input tokens (max 3 decimals)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Output Cost (per 1M tokens) *</FormLabel>
<FormControl>
<Input
type='number'
step='0.001'
min='0'
placeholder='15.00'
{...field}
value={
field.value ? parseFloat(field.value.toFixed(3)) : ''
}
onChange={(e) => {
const value = parseFloat(e.target.value) || 0;
const rounded = Math.round(value * 1000) / 1000;
field.onChange(rounded);
}}
className='w-full'
/>
</FormControl>
<FormDescription>
Cost in USD per 1,000,000 output tokens (max 3 decimals)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='modelType'
render={({ field }) => (
<FormItem>
<FormLabel>Model Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select model type' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='text'>Text/Chat</SelectItem>
<SelectItem value='embedding'>Embedding</SelectItem>
<SelectItem value='image'>Image Generation</SelectItem>
<SelectItem value='audio'>Audio</SelectItem>
<SelectItem value='multimodal'>Multimodal</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Type of AI model functionality
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='contextLength'
render={({ field }) => (
<FormItem>
<FormLabel>Context Length</FormLabel>
<FormControl>
<Input
type='number'
min='0'
placeholder='8192'
value={field.value || ''}
onChange={(e) => {
const val = parseInt(e.target.value);
field.onChange(
isNaN(val) || val === 0 ? undefined : val
);
}}
className='w-full'
/>
</FormControl>
<FormDescription>
Maximum context length in tokens (optional, leave empty
for default)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Brief description of the model...'
{...field}
rows={3}
className='w-full'
/>
</FormControl>
<FormDescription>
Optional description or notes about the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end gap-2 pt-4'>
<Button
type='button'
variant='outline'
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Adding...
</>
) : (
'Add Model'
)}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+358
View File
@@ -0,0 +1,358 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AdminService, AdminModel } from '@/lib/api/services/admin';
import { toast } from 'sonner';
import { Download, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
interface CollectModelsDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export function CollectModelsDialog({
isOpen,
onClose,
onSuccess,
}: CollectModelsDialogProps) {
const [selectedProvider, setSelectedProvider] = useState<number | null>(null);
const [providers, setProviders] = useState<
Array<{ id: number; provider_type: string; base_url: string }>
>([]);
const [remoteModels, setRemoteModels] = useState<AdminModel[]>([]);
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [result, setResult] = useState<{
added: number;
skipped: number;
errors: string[];
} | null>(null);
const loadRemoteModels = useCallback(async () => {
if (!selectedProvider) return;
setIsLoadingModels(true);
setRemoteModels([]);
setSelectedModels(new Set());
try {
const data = await AdminService.getProviderModels(selectedProvider);
const dbModelIds = new Set(data.db_models.map((m) => m.id));
const availableRemoteModels = data.remote_models.filter(
(m: AdminModel) => !dbModelIds.has(m.id)
);
setRemoteModels(availableRemoteModels);
} catch {
toast.error('Failed to fetch models from provider');
} finally {
setIsLoadingModels(false);
}
}, [selectedProvider]);
useEffect(() => {
if (isOpen) {
loadProviders();
}
}, [isOpen]);
useEffect(() => {
if (selectedProvider) {
loadRemoteModels();
}
}, [selectedProvider, loadRemoteModels]);
const loadProviders = async () => {
setIsLoadingProviders(true);
try {
const data = await AdminService.getUpstreamProviders();
setProviders(data);
} catch {
toast.error('Failed to load providers');
} finally {
setIsLoadingProviders(false);
}
};
const toggleModel = (modelId: string) => {
const newSelected = new Set(selectedModels);
if (newSelected.has(modelId)) {
newSelected.delete(modelId);
} else {
newSelected.add(modelId);
}
setSelectedModels(newSelected);
};
const selectAll = () => {
setSelectedModels(new Set(remoteModels.map((m) => m.id)));
};
const deselectAll = () => {
setSelectedModels(new Set());
};
const handleCollect = async () => {
if (selectedModels.size === 0) {
toast.error('Please select at least one model');
return;
}
setIsSubmitting(true);
let added = 0;
let skipped = 0;
const errors: string[] = [];
try {
for (const modelId of Array.from(selectedModels)) {
const model = remoteModels.find((m) => m.id === modelId);
if (!model) continue;
try {
await AdminService.createAdminModel({
...model,
upstream_provider_id: selectedProvider,
enabled: true,
created: Math.floor(Date.now() / 1000),
});
added++;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Unknown error';
if (errorMessage.includes('already exists')) {
skipped++;
} else {
errors.push(`${modelId}: ${errorMessage}`);
}
}
}
setResult({ added, skipped, errors });
if (added > 0) {
toast.success(`Successfully added ${added} models`);
onSuccess?.();
}
if (errors.length > 0) {
toast.warning(`Completed with ${errors.length} errors`);
}
} catch {
toast.error('Failed to collect models');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
setSelectedProvider(null);
setRemoteModels([]);
setSelectedModels(new Set());
setResult(null);
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className='max-h-[80vh] sm:max-w-[700px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Download className='h-5 w-5' />
Collect Models from Provider
</DialogTitle>
<DialogDescription>
Fetch models from an upstream provider and add them to your database
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<label className='text-sm font-medium'>Select Provider</label>
<Select
value={selectedProvider?.toString()}
onValueChange={(value) => setSelectedProvider(parseInt(value))}
disabled={isLoadingProviders}
>
<SelectTrigger>
<SelectValue placeholder='Choose an upstream provider' />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id.toString()}>
{provider.provider_type} - {provider.base_url}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoadingModels && (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-8 w-8 animate-spin' />
<span className='ml-2'>Loading models from provider...</span>
</div>
)}
{!isLoadingModels && selectedProvider && remoteModels.length > 0 && (
<>
<div className='flex items-center justify-between'>
<div className='text-sm font-medium'>
{remoteModels.length} models available
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={selectAll}
disabled={isSubmitting}
>
Select All
</Button>
<Button
variant='outline'
size='sm'
onClick={deselectAll}
disabled={isSubmitting}
>
Deselect All
</Button>
</div>
</div>
<ScrollArea className='h-[300px] rounded-md border p-4'>
<div className='space-y-2'>
{remoteModels.map((model) => (
<div
key={model.id}
className='hover:bg-accent flex items-start space-x-3 rounded-lg border p-3'
>
<Checkbox
checked={selectedModels.has(model.id)}
onCheckedChange={() => toggleModel(model.id)}
disabled={isSubmitting}
/>
<div className='flex-1 space-y-1'>
<div className='font-mono text-sm font-medium'>
{model.id}
</div>
<div className='text-muted-foreground text-xs'>
{model.description || model.name}
</div>
{model.context_length && (
<Badge variant='secondary' className='text-xs'>
{model.context_length.toLocaleString()} tokens
</Badge>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</>
)}
{!isLoadingModels &&
selectedProvider &&
remoteModels.length === 0 && (
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
No new models available from this provider. All models may
already be in your database.
</AlertDescription>
</Alert>
)}
{result && (
<div className='space-y-2'>
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<strong>Collection Results:</strong>
<br /> {result.added} models added
<br /> {result.skipped} models skipped
{result.errors.length > 0 && (
<>
<br /> {result.errors.length} errors occurred
</>
)}
</AlertDescription>
</Alert>
{result.errors.length > 0 && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
<strong>Errors:</strong>
<ul className='mt-1 list-inside list-disc text-sm'>
{result.errors.slice(0, 3).map((error, index) => (
<li key={index}>{error}</li>
))}
{result.errors.length > 3 && (
<li>... and {result.errors.length - 3} more errors</li>
)}
</ul>
</AlertDescription>
</Alert>
)}
</div>
)}
<div className='flex justify-end gap-2 pt-4'>
<Button
variant='outline'
onClick={handleClose}
disabled={isSubmitting}
>
{result ? 'Close' : 'Cancel'}
</Button>
<Button
onClick={handleCollect}
disabled={
isSubmitting ||
!selectedProvider ||
selectedModels.size === 0 ||
isLoadingModels
}
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Adding Models...
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
Add {selectedModels.size} Models
</>
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+226
View File
@@ -0,0 +1,226 @@
'use client';
import React, { useState, useMemo } from 'react';
import { type Model } from '@/lib/api/schemas/models';
import {
calculateRequestCost,
estimateMinimumTokensForCost,
formatCost,
} from '@/lib/services/costValidation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Info, AlertTriangle, CheckCircle } from 'lucide-react';
interface CostCalculatorProps {
model: Model;
testInput?: string;
onTestInputChange?: (value: string) => void;
}
export function CostCalculator({ model }: CostCalculatorProps) {
const [inputTokens, setInputTokens] = useState<number>(100);
const [outputTokens, setOutputTokens] = useState<number>(100);
// Calculate costs based on current input
const costCalculation = useMemo(() => {
return calculateRequestCost({
inputTokens,
outputTokens,
model,
});
}, [inputTokens, outputTokens, model]);
// Get minimum token estimates
const tokenEstimates = useMemo(() => {
return estimateMinimumTokensForCost(model);
}, [model]);
const hasMinimumCost = model.min_cost_per_request > 0;
return (
<div className='space-y-6'>
{/* Input Controls */}
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='input-tokens'>Input Tokens</Label>
<Input
id='input-tokens'
type='number'
min='0'
value={inputTokens}
onChange={(e) => setInputTokens(parseInt(e.target.value) || 0)}
placeholder='100'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='output-tokens'>Output Tokens</Label>
<Input
id='output-tokens'
type='number'
min='0'
value={outputTokens}
onChange={(e) => setOutputTokens(parseInt(e.target.value) || 0)}
placeholder='100'
/>
</div>
</div>
{/* Cost Breakdown */}
<div className='rounded-md border p-4'>
<h4 className='mb-3 text-sm font-medium'>Cost Breakdown</h4>
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Input Cost ({inputTokens.toLocaleString()} tokens):</span>
<span className='font-mono'>
{formatCost(costCalculation.inputCost)}
</span>
</div>
<div className='flex justify-between'>
<span>Output Cost ({outputTokens.toLocaleString()} tokens):</span>
<span className='font-mono'>
{formatCost(costCalculation.outputCost)}
</span>
</div>
<hr className='my-2' />
<div className='flex justify-between'>
<span>Base Cost:</span>
<span className='font-mono'>
{formatCost(costCalculation.baseCost)}
</span>
</div>
<div className='flex justify-between'>
<span>Minimum Cost per Request:</span>
<span className='font-mono'>
{formatCost(costCalculation.minCostPerRequest)}
</span>
</div>
<hr className='my-2' />
<div className='flex justify-between font-medium'>
<span>Final Cost:</span>
<span className='font-mono text-lg'>
{formatCost(costCalculation.finalCost)}
</span>
</div>
</div>
</div>
{/* Minimum Cost Alert */}
{hasMinimumCost && (
<Alert
className={
costCalculation.isMinimumApplied
? 'border-amber-200 bg-amber-50'
: 'border-green-200 bg-green-50'
}
>
{costCalculation.isMinimumApplied ? (
<AlertTriangle className='h-4 w-4 text-amber-600' />
) : (
<CheckCircle className='h-4 w-4 text-green-600' />
)}
<AlertTitle>
{costCalculation.isMinimumApplied
? 'Minimum Cost Applied'
: 'Above Minimum Cost'}
</AlertTitle>
<AlertDescription>
{costCalculation.isMinimumApplied
? `The calculated cost (${formatCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatCost(costCalculation.minCostPerRequest)} is applied.`
: `The calculated cost (${formatCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatCost(costCalculation.minCostPerRequest)}.`}
</AlertDescription>
</Alert>
)}
{/* Token Recommendations */}
{hasMinimumCost && tokenEstimates.inputTokensOnly > 0 && (
<div className='rounded-md border p-4'>
<h4 className='mb-3 flex items-center gap-2 text-sm font-medium'>
<Info className='h-4 w-4' />
Token Recommendations to Meet Minimum Cost
</h4>
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Input tokens only:</span>
<span className='font-mono'>
{tokenEstimates.inputTokensOnly.toLocaleString()}
</span>
</div>
{tokenEstimates.outputTokensOnly > 0 && (
<div className='flex justify-between'>
<span>Output tokens only:</span>
<span className='font-mono'>
{tokenEstimates.outputTokensOnly.toLocaleString()}
</span>
</div>
)}
<div className='flex justify-between'>
<span>Balanced (50/50):</span>
<span className='font-mono'>
{tokenEstimates.balancedTokens.input.toLocaleString()} in +{' '}
{tokenEstimates.balancedTokens.output.toLocaleString()} out
</span>
</div>
</div>
<div className='mt-3 flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => {
setInputTokens(tokenEstimates.inputTokensOnly);
setOutputTokens(0);
}}
>
Use Input Only
</Button>
{tokenEstimates.outputTokensOnly > 0 && (
<Button
variant='outline'
size='sm'
onClick={() => {
setInputTokens(0);
setOutputTokens(tokenEstimates.outputTokensOnly);
}}
>
Use Output Only
</Button>
)}
<Button
variant='outline'
size='sm'
onClick={() => {
setInputTokens(tokenEstimates.balancedTokens.input);
setOutputTokens(tokenEstimates.balancedTokens.output);
}}
>
Use Balanced
</Button>
</div>
</div>
)}
{/* Model Pricing Info */}
<div className='rounded-md border p-4'>
<h4 className='mb-3 text-sm font-medium'>Model Pricing</h4>
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Input cost per 1M tokens:</span>
<span className='font-mono'>{formatCost(model.input_cost)}</span>
</div>
<div className='flex justify-between'>
<span>Output cost per 1M tokens:</span>
<span className='font-mono'>{formatCost(model.output_cost)}</span>
</div>
<div className='flex justify-between'>
<span>Minimum cost per request:</span>
<span className='font-mono'>
{formatCost(model.min_cost_per_request)}
</span>
</div>
</div>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
'use client';
import React from 'react';
import { type Model } from '@/lib/api/schemas/models';
import { CostCalculator } from '@/components/CostCalculator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Calculator } from 'lucide-react';
interface CostCalculatorDialogProps {
model: Model;
isOpen: boolean;
onClose: () => void;
}
export function CostCalculatorDialog({
model,
isOpen,
onClose,
}: CostCalculatorDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[700px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Calculator className='h-5 w-5' />
Cost Calculator - {model.name}
</DialogTitle>
<DialogDescription>
Calculate costs and estimate token usage for &quot;{model.name}
&quot;
</DialogDescription>
</DialogHeader>
<div className='mt-4'>
<CostCalculator model={model} />
</div>
</DialogContent>
</Dialog>
);
}
+364
View File
@@ -0,0 +1,364 @@
'use client';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
GroupSettingsSchema,
type GroupSettings,
type Model,
} from '@/lib/api/schemas/models';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Users, Key, Loader2, Globe, AlertTriangle, Info } from 'lucide-react';
import { toast } from 'sonner';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface EditGroupFormProps {
provider: string;
models: Model[];
groupSettings?: { group_api_key?: string; group_url?: string };
onGroupUpdate: (oldProvider: string, updatedData: GroupSettings) => void;
onCancel?: () => void;
isOpen: boolean;
}
export function EditGroupForm({
provider,
models,
groupSettings,
onGroupUpdate,
onCancel,
isOpen,
}: EditGroupFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [useGroupUrl, setUseGroupUrl] = useState(!!groupSettings?.group_url);
const form = useForm<GroupSettings>({
resolver: zodResolver(GroupSettingsSchema),
defaultValues: {
provider: provider,
group_api_key: groupSettings?.group_api_key || '',
group_url: groupSettings?.group_url || '',
},
});
const onSubmit = async (data: GroupSettings) => {
setIsSubmitting(true);
try {
// Clean up empty strings and handle URL removal
const cleanData = {
...data,
group_api_key: data.group_api_key?.trim() || undefined,
group_url: useGroupUrl
? data.group_url?.trim() || undefined
: undefined,
};
await onGroupUpdate(provider, cleanData);
toast.success(`Group "${provider}" updated successfully!`);
onCancel?.();
} catch (error) {
toast.error('Failed to update group. Please try again.');
console.error('Error updating group:', error);
} finally {
setIsSubmitting(false);
}
};
// Count models that have individual API keys
const modelsWithIndividualKeys = models.filter(
(model) => model.api_key
).length;
const modelsWithoutKeys = models.filter((model) => !model.api_key).length;
// Count models that would be affected by URL changes
const modelsUsingGroupUrl = models.filter(
(model) => !model.api_key && (!model.url || model.url.startsWith('/'))
).length;
const handleClose = () => {
if (!isSubmitting) {
onCancel?.();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[700px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Users className='h-5 w-5' />
Edit Provider Group: {provider}
</DialogTitle>
<DialogDescription>
Update settings for all {models.length} models in this group. Group
settings provide defaults for models without individual
configurations.
</DialogDescription>
</DialogHeader>
<div className='mb-6 space-y-4'>
{/* API Key Status */}
<div className='rounded-md border p-4'>
<h4 className='mb-2 flex items-center gap-2 text-sm font-medium'>
<Key className='h-4 w-4' />
API Key Configuration
</h4>
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Models using group API key:</span>
<span className='font-medium text-blue-600'>
{modelsWithoutKeys}
</span>
</div>
</div>
</div>
{/* URL Configuration Status */}
<div className='rounded-md border p-4'>
<h4 className='mb-2 flex items-center gap-2 text-sm font-medium'>
<Globe className='h-4 w-4' />
URL Configuration
</h4>
<div className='space-y-2 text-sm'>
<div className='flex justify-between'>
<span>Models using group URL:</span>
<span className='font-medium text-blue-600'>
{modelsUsingGroupUrl}
</span>
</div>
<div className='flex justify-between'>
<span>Models with individual URLs:</span>
<span className='font-medium text-green-600'>
{models.length - modelsUsingGroupUrl}
</span>
</div>
{groupSettings?.group_url && (
<div className='mt-2 rounded bg-blue-50 p-2 text-xs'>
<span className='font-medium'>Current group URL:</span>{' '}
{groupSettings.group_url}
</div>
)}
</div>
</div>
{/* Models in this group */}
<div className='rounded-md border p-4'>
<h4 className='mb-2 text-sm font-medium'>Models in this group:</h4>
<div className='max-h-32 overflow-y-auto'>
<div className='flex flex-wrap gap-2'>
{models.map((model) => (
<span
key={model.id}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
model.api_key
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}
title={
model.api_key
? 'Has individual API key and URL'
: 'Uses group API key and URL'
}
>
{model.name}
{model.api_key && ' 🔑'}
{model.url &&
!model.url.startsWith('/') &&
model.api_key &&
' 🌐'}
</span>
))}
</div>
</div>
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<FormField
control={form.control}
name='provider'
render={({ field }) => (
<FormItem>
<FormLabel>Provider Name *</FormLabel>
<FormControl>
<Input
placeholder='e.g., OpenAI, Anthropic'
{...field}
className='w-full'
/>
</FormControl>
<FormDescription>
This will update the provider name for all models in this
group
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Group URL Toggle */}
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Group Base URL</FormLabel>
<FormDescription>
Provide a custom base URL for this provider group
</FormDescription>
</div>
<Switch
checked={useGroupUrl}
onCheckedChange={setUseGroupUrl}
/>
</div>
{useGroupUrl && (
<FormField
control={form.control}
name='group_url'
render={({ field }) => (
<FormItem>
<FormLabel>Base URL</FormLabel>
<FormControl>
<Input
placeholder='https://api.openai.com/v1 or https://your-proxy.com/v1'
{...field}
className='w-full'
/>
</FormControl>
<FormDescription>
This base URL will be used for models without individual
URLs. Models will append their endpoint path (e.g.,
/chat/completions) to this base.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{!useGroupUrl && groupSettings?.group_url && (
<Alert>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
Removing the group URL will make models in this group fall
back to the default system endpoint. Models with individual
URLs will be unaffected.
</AlertDescription>
</Alert>
)}
{useGroupUrl && !groupSettings?.group_url && (
<Alert>
<Info className='h-4 w-4' />
<AlertDescription>
Adding a group URL will allow models in this group to use a
custom endpoint instead of the default system endpoint.
</AlertDescription>
</Alert>
)}
</div>
<FormField
control={form.control}
name='group_api_key'
render={({ field }) => (
<FormItem>
<FormLabel>Group API Key</FormLabel>
<div className='relative'>
<FormControl>
<Input
type={showApiKey ? 'text' : 'password'}
placeholder='sk-... (leave empty to remove)'
{...field}
className='w-full pr-24'
/>
</FormControl>
<Button
type='button'
variant='ghost'
size='sm'
className='absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent'
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? 'Hide' : 'Show'}
</Button>
</div>
<FormDescription>
This API key will be used for models that don&apos;t have
individual API keys ({modelsWithoutKeys} models). Leave
empty to remove the group API key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='rounded-md border border-amber-200 bg-amber-50 p-4'>
<p className='text-sm text-amber-800'>
<strong>How group settings work:</strong>
</p>
<ul className='mt-2 list-inside list-disc space-y-1 text-sm text-amber-800'>
<li>
Models with individual API keys and URLs will keep their
specific settings
</li>
<li>
Models without individual settings will use the group defaults
</li>
<li>
Removing the group URL makes models fall back to the system
default endpoint
</li>
<li>
You can use &quot;Apply Group Settings&quot; to force models
to use group configurations
</li>
</ul>
</div>
<div className='flex justify-end gap-2 pt-4'>
<Button
type='button'
variant='outline'
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Updating...
</>
) : (
'Update Group'
)}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+289
View File
@@ -0,0 +1,289 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
ManualModelSchema,
type ManualModel,
type Model,
} from '@/lib/api/schemas/models';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Edit3, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
interface EditModelFormProps {
model: Model;
onModelUpdate: (modelId: string, updatedModel: ManualModel) => void;
onCancel?: () => void;
isOpen: boolean;
}
export function EditModelForm({
model,
onModelUpdate,
onCancel,
isOpen,
}: EditModelFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<ManualModel>({
resolver: zodResolver(ManualModelSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
defaultValues: {
name: model.name,
full_name: model.full_name,
input_cost: model.input_cost,
output_cost: model.output_cost,
provider: model.provider,
modelType: model.modelType as ManualModel['modelType'],
description: model.description || '',
contextLength: model.contextLength || 0,
},
});
// Reset form when model changes
useEffect(() => {
form.reset({
name: model.name,
full_name: model.full_name,
input_cost: model.input_cost,
output_cost: model.output_cost,
provider: model.provider,
modelType: model.modelType as ManualModel['modelType'],
description: model.description || '',
contextLength: model.contextLength || 0,
});
}, [model, form]);
const onSubmit = async (data: ManualModel) => {
setIsSubmitting(true);
try {
// Ensure we're sending the correct API key value
const updatedData = {
...data,
};
await onModelUpdate(model.id, updatedData);
toast.success('Model updated successfully!');
onCancel?.();
} catch (error) {
toast.error('Failed to update model. Please try again.');
console.error('Error updating model:', error);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
onCancel?.();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Edit3 className='h-5 w-5' />
Edit Model
</DialogTitle>
<DialogDescription>
Update the details for &quot;{model.name}&quot;
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='full_name'
render={({ field }) => (
<FormItem>
<FormLabel>Original Model Name</FormLabel>
<FormControl>
<Input {...field} className='bg-muted w-full' disabled />
</FormControl>
<FormDescription>
Original name from the provider (cannot be changed)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Display Name *</FormLabel>
<FormControl>
<Input
placeholder='e.g., GPT-4'
{...field}
className='w-full'
/>
</FormControl>
<FormDescription>
Custom display name for the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='provider'
render={({ field }) => (
<FormItem>
<FormLabel>Provider *</FormLabel>
<FormControl>
<Input
placeholder='e.g., OpenAI, Anthropic'
{...field}
className='w-full'
/>
</FormControl>
<FormDescription>
AI model provider or company
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='input_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Input Cost (per 1M tokens) *</FormLabel>
<FormControl>
<Input
type='number'
step='0.001'
min='0'
placeholder='5.00'
{...field}
value={
field.value ? parseFloat(field.value.toFixed(3)) : ''
}
onChange={(e) => {
const value = parseFloat(e.target.value) || 0;
const rounded = Math.round(value * 1000) / 1000;
field.onChange(rounded);
}}
className='w-full'
/>
</FormControl>
<FormDescription>
Cost in USD per 1,000,000 input tokens (max 3 decimals)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='output_cost'
render={({ field }) => (
<FormItem>
<FormLabel>Output Cost (per 1M tokens) *</FormLabel>
<FormControl>
<Input
type='number'
step='0.001'
min='0'
placeholder='15.00'
{...field}
value={
field.value ? parseFloat(field.value.toFixed(3)) : ''
}
onChange={(e) => {
const value = parseFloat(e.target.value) || 0;
const rounded = Math.round(value * 1000) / 1000;
field.onChange(rounded);
}}
className='w-full'
/>
</FormControl>
<FormDescription>
Cost in USD per 1,000,000 output tokens (max 3 decimals)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder='Brief description of the model...'
{...field}
rows={3}
className='w-full'
/>
</FormControl>
<FormDescription>
Optional description or notes about the model
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end gap-2 pt-4'>
<Button
type='button'
variant='outline'
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Updating...
</>
) : (
'Update Model'
)}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+451
View File
@@ -0,0 +1,451 @@
'use client';
import React, { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { type Model } from '@/lib/api/schemas/models';
import { ModelService } from '@/lib/api/services/models';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Loader2,
Send,
CheckCircle,
XCircle,
Info,
Key,
Globe,
} from 'lucide-react';
import { toast } from 'sonner';
interface ModelTesterProps {
models: Model[];
}
interface ChatCompletionRequest {
model: string;
messages: {
role: 'system' | 'user' | 'assistant';
content: string;
}[];
max_tokens?: number;
temperature?: number;
}
interface ChatCompletionResponse {
id: string;
object: string;
created: number;
model: string;
choices: {
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
const DEFAULT_SYSTEM_MESSAGE =
'You are a helpful assistant. Please respond concisely.';
const DEFAULT_USER_MESSAGE =
'Hello! Can you tell me what model you are and confirm that you are working correctly?';
export function ModelTester({ models }: ModelTesterProps) {
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [systemMessage, setSystemMessage] = useState(DEFAULT_SYSTEM_MESSAGE);
const [userMessage, setUserMessage] = useState(DEFAULT_USER_MESSAGE);
const [maxTokens, setMaxTokens] = useState<number>(150);
const [temperature, setTemperature] = useState<number>(0.7);
const [response, setResponse] = useState<ChatCompletionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch model groups for API key resolution
const { data: groups = [] } = useQuery({
queryKey: ['model-groups'],
queryFn: () => ModelService.getModelGroups(),
refetchOnWindowFocus: false,
});
const selectedModel = models.find((model) => model.id === selectedModelId);
// Get effective API key and endpoint URL for the selected model
const getModelCredentials = (model: Model) => {
const group = groups.find((g) => g.provider === model.provider);
// Determine API key (individual takes precedence over group)
const apiKey = model.api_key || group?.group_api_key;
// Determine endpoint URL
let endpointUrl = model.url;
// If model URL is relative and group has a base URL, combine them
if (model.url.startsWith('/') && group?.group_url) {
endpointUrl = `${group.group_url.replace(/\/$/, '')}${model.url}`;
}
// Ensure the URL ends with /chat/completions for chat models
if (
model.modelType === 'text' &&
!endpointUrl.includes('/chat/completions')
) {
endpointUrl = endpointUrl.replace(/\/$/, '') + '/chat/completions';
}
return {
apiKey,
endpointUrl,
group,
};
};
const testModelMutation = useMutation({
mutationFn: async (request: ChatCompletionRequest) => {
if (!selectedModel) {
throw new Error('No model selected');
}
setError(null);
setResponse(null);
try {
console.log(`Testing model via proxy: ${selectedModel.name}`);
console.log('Request payload:', request);
const response = await ModelService.testModel(
selectedModel.id,
'chat-completions',
request as unknown as Record<string, unknown>
);
if (!response.success) {
throw new Error(response.error || 'Test failed');
}
return response.data as ChatCompletionResponse;
} catch (err: unknown) {
console.error('Model test error via proxy:', err);
const errorMessage =
err instanceof Error ? err.message : 'Failed to test model via proxy';
throw new Error(errorMessage);
}
},
onSuccess: (data) => {
setResponse(data);
toast.success('Model test completed successfully!');
},
onError: (err: Error) => {
const errorMessage = err?.message || 'Unknown error occurred';
setError(errorMessage);
toast.error(`Model test failed: ${errorMessage}`);
},
});
const handleTest = async () => {
if (!selectedModel) {
toast.error('Please select a model to test');
return;
}
if (!userMessage.trim()) {
toast.error('Please enter a test message');
return;
}
const messages = [];
if (systemMessage.trim()) {
messages.push({
role: 'system' as const,
content: systemMessage.trim(),
});
}
messages.push({
role: 'user' as const,
content: userMessage.trim(),
});
const request: ChatCompletionRequest = {
model: selectedModel.name,
messages,
max_tokens: maxTokens,
temperature: temperature,
};
testModelMutation.mutate(request);
};
const enabledModels = models.filter((model) => model.isEnabled);
const credentials = selectedModel ? getModelCredentials(selectedModel) : null;
return (
<Card className='w-full'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Send className='h-5 w-5' />
Model Credential Tester
</CardTitle>
<CardDescription>
Test model functionality by sending chat completion requests through
the secure proxy (resolves CORS and network issues)
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
{/* Model Selection */}
<div className='space-y-2'>
<Label htmlFor='model-select'>Select Model</Label>
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger id='model-select'>
<SelectValue placeholder='Choose a model to test...' />
</SelectTrigger>
<SelectContent>
{enabledModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className='flex items-center gap-2'>
<span>{model.name}</span>
<Badge variant='outline' className='text-xs'>
{model.provider}
</Badge>
{model.is_free && (
<Badge variant='secondary' className='text-xs'>
Free
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedModel && credentials && (
<div className='text-muted-foreground bg-muted space-y-2 rounded-md p-3 text-sm'>
<div className='flex items-center gap-2'>
<Globe className='h-4 w-4' />
<span>
<strong>Endpoint:</strong> {credentials.endpointUrl}
</span>
</div>
<div className='flex items-center gap-2'>
<Key className='h-4 w-4' />
<span>
<strong>API Key:</strong>{' '}
{credentials.apiKey
? `${credentials.apiKey.substring(0, 8)}...`
: 'Not configured'}
</span>
<Badge
variant={credentials.apiKey ? 'default' : 'destructive'}
className='text-xs'
>
{selectedModel.api_key_type || 'Unknown'}
</Badge>
</div>
<div>
<span>
<strong>Provider:</strong> {selectedModel.provider}
</span>
</div>
<div>
<span>
<strong>Type:</strong> {selectedModel.modelType}
</span>
</div>
{selectedModel.contextLength && (
<div>
<span>
<strong>Context Length:</strong>{' '}
{selectedModel.contextLength.toLocaleString()}
</span>
</div>
)}
{!credentials.apiKey && (
<Alert variant='default' className='mt-2'>
<Info className='h-4 w-4' />
<AlertDescription>
No API key configured for this model. Testing may still work
if the model is free or if authentication is handled
elsewhere. For models requiring authentication, please add
an API key to the model or its provider group.
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
{/* Test Parameters */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='max-tokens'>Max Tokens</Label>
<input
id='max-tokens'
type='number'
min={1}
max={4000}
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 150)}
className='border-input bg-background w-full rounded-md border px-3 py-2 text-sm'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='temperature'>Temperature</Label>
<input
id='temperature'
type='number'
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) =>
setTemperature(parseFloat(e.target.value) || 0.7)
}
className='border-input bg-background w-full rounded-md border px-3 py-2 text-sm'
/>
</div>
</div>
{/* System Message */}
<div className='space-y-2'>
<Label htmlFor='system-message'>System Message (Optional)</Label>
<Textarea
id='system-message'
placeholder='Enter system message...'
value={systemMessage}
onChange={(e) => setSystemMessage(e.target.value)}
rows={2}
/>
</div>
{/* User Message */}
<div className='space-y-2'>
<Label htmlFor='user-message'>Test Message</Label>
<Textarea
id='user-message'
placeholder='Enter your test message...'
value={userMessage}
onChange={(e) => setUserMessage(e.target.value)}
rows={3}
/>
</div>
{/* Test Button */}
<Button
onClick={handleTest}
disabled={!selectedModelId || testModelMutation.isPending}
className='w-full'
>
{testModelMutation.isPending ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Testing Model...
</>
) : (
<>
<Send className='mr-2 h-4 w-4' />
Test Model (via Proxy)
</>
)}
</Button>
{/* Results */}
{error && (
<Alert variant='destructive'>
<XCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Failed:</strong> {error}
</AlertDescription>
</Alert>
)}
{response && (
<Alert>
<CheckCircle className='h-4 w-4' />
<AlertDescription>
<strong>Test Successful!</strong> Model responded correctly via
secure proxy.
</AlertDescription>
</Alert>
)}
{response && (
<div className='space-y-4'>
<div className='space-y-2'>
<Label>Model Response</Label>
<div className='bg-muted rounded-md p-4'>
<p className='text-sm whitespace-pre-wrap'>
{response.choices[0]?.message?.content ||
'No content in response'}
</p>
</div>
</div>
{response.usage && (
<div className='space-y-2'>
<Label>Usage Statistics</Label>
<div className='grid grid-cols-3 gap-4 text-sm'>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.prompt_tokens}
</div>
<div className='text-muted-foreground'>Prompt Tokens</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.completion_tokens}
</div>
<div className='text-muted-foreground'>
Completion Tokens
</div>
</div>
<div className='bg-muted rounded p-2 text-center'>
<div className='font-semibold'>
{response.usage.total_tokens}
</div>
<div className='text-muted-foreground'>Total Tokens</div>
</div>
</div>
</div>
)}
<div className='space-y-2'>
<Label>Raw Response</Label>
<details className='group'>
<summary className='text-muted-foreground hover:text-foreground cursor-pointer text-sm'>
<Info className='mr-1 inline h-4 w-4' />
Show detailed response data
</summary>
<pre className='bg-muted mt-2 max-h-60 overflow-auto rounded-md p-4 text-xs'>
{JSON.stringify(response, null, 2)}
</pre>
</details>
</div>
</div>
)}
</CardContent>
</Card>
);
}
+95
View File
@@ -0,0 +1,95 @@
'use client';
import * as React from 'react';
import { DatabaseIcon, LayoutDashboardIcon, ServerIcon } from 'lucide-react';
import { NavSecondary } from '@/components/nav-secondary';
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
} from '@/components/ui/sidebar';
const data = {
navMain: [
{
title: 'Dashboard',
url: '/',
icon: LayoutDashboardIcon,
},
],
navClouds: [],
navSecondary: [
{
title: 'Dashboard',
url: '/',
icon: LayoutDashboardIcon,
},
{
title: 'Models',
url: '/models',
icon: DatabaseIcon,
},
{
title: 'Providers',
url: '/providers',
icon: ServerIcon,
},
// {
// title: 'Transactions',
// url: '/transactions',
// icon: ReceiptIcon,
// },
// {
// title: 'Settings',
// url: '/settings',
// icon: SettingsIcon,
// },
// {
// title: 'Credit',
// url: '/credits',
// icon: FolderIcon,
// },
// {
// title: 'Users',
// url: '/users',
// icon: UsersIcon,
// },
// {
// title: 'Organizations',
// url: '/organizations',
// icon: FolderIcon,
// },
],
documents: [],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible='offcanvas' {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className='data-[slot=sidebar-menu-button]:!p-1.5'
>
<span className='text-base font-semibold'>Routstr</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavSecondary items={data.navSecondary} className='mt-auto' />
</SidebarContent>
{/*
<SidebarFooter>
<NavUser />
</SidebarFooter>
*/}
</Sidebar>
);
}
+289
View File
@@ -0,0 +1,289 @@
'use client';
import * as React from 'react';
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
import { useIsMobile } from '@/hooks/use-mobile';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
const chartData = [
{ date: '2024-04-01', desktop: 222, mobile: 150 },
{ date: '2024-04-02', desktop: 97, mobile: 180 },
{ date: '2024-04-03', desktop: 167, mobile: 120 },
{ date: '2024-04-04', desktop: 242, mobile: 260 },
{ date: '2024-04-05', desktop: 373, mobile: 290 },
{ date: '2024-04-06', desktop: 301, mobile: 340 },
{ date: '2024-04-07', desktop: 245, mobile: 180 },
{ date: '2024-04-08', desktop: 409, mobile: 320 },
{ date: '2024-04-09', desktop: 59, mobile: 110 },
{ date: '2024-04-10', desktop: 261, mobile: 190 },
{ date: '2024-04-11', desktop: 327, mobile: 350 },
{ date: '2024-04-12', desktop: 292, mobile: 210 },
{ date: '2024-04-13', desktop: 342, mobile: 380 },
{ date: '2024-04-14', desktop: 137, mobile: 220 },
{ date: '2024-04-15', desktop: 120, mobile: 170 },
{ date: '2024-04-16', desktop: 138, mobile: 190 },
{ date: '2024-04-17', desktop: 446, mobile: 360 },
{ date: '2024-04-18', desktop: 364, mobile: 410 },
{ date: '2024-04-19', desktop: 243, mobile: 180 },
{ date: '2024-04-20', desktop: 89, mobile: 150 },
{ date: '2024-04-21', desktop: 137, mobile: 200 },
{ date: '2024-04-22', desktop: 224, mobile: 170 },
{ date: '2024-04-23', desktop: 138, mobile: 230 },
{ date: '2024-04-24', desktop: 387, mobile: 290 },
{ date: '2024-04-25', desktop: 215, mobile: 250 },
{ date: '2024-04-26', desktop: 75, mobile: 130 },
{ date: '2024-04-27', desktop: 383, mobile: 420 },
{ date: '2024-04-28', desktop: 122, mobile: 180 },
{ date: '2024-04-29', desktop: 315, mobile: 240 },
{ date: '2024-04-30', desktop: 454, mobile: 380 },
{ date: '2024-05-01', desktop: 165, mobile: 220 },
{ date: '2024-05-02', desktop: 293, mobile: 310 },
{ date: '2024-05-03', desktop: 247, mobile: 190 },
{ date: '2024-05-04', desktop: 385, mobile: 420 },
{ date: '2024-05-05', desktop: 481, mobile: 390 },
{ date: '2024-05-06', desktop: 498, mobile: 520 },
{ date: '2024-05-07', desktop: 388, mobile: 300 },
{ date: '2024-05-08', desktop: 149, mobile: 210 },
{ date: '2024-05-09', desktop: 227, mobile: 180 },
{ date: '2024-05-10', desktop: 293, mobile: 330 },
{ date: '2024-05-11', desktop: 335, mobile: 270 },
{ date: '2024-05-12', desktop: 197, mobile: 240 },
{ date: '2024-05-13', desktop: 197, mobile: 160 },
{ date: '2024-05-14', desktop: 448, mobile: 490 },
{ date: '2024-05-15', desktop: 473, mobile: 380 },
{ date: '2024-05-16', desktop: 338, mobile: 400 },
{ date: '2024-05-17', desktop: 499, mobile: 420 },
{ date: '2024-05-18', desktop: 315, mobile: 350 },
{ date: '2024-05-19', desktop: 235, mobile: 180 },
{ date: '2024-05-20', desktop: 177, mobile: 230 },
{ date: '2024-05-21', desktop: 82, mobile: 140 },
{ date: '2024-05-22', desktop: 81, mobile: 120 },
{ date: '2024-05-23', desktop: 252, mobile: 290 },
{ date: '2024-05-24', desktop: 294, mobile: 220 },
{ date: '2024-05-25', desktop: 201, mobile: 250 },
{ date: '2024-05-26', desktop: 213, mobile: 170 },
{ date: '2024-05-27', desktop: 420, mobile: 460 },
{ date: '2024-05-28', desktop: 233, mobile: 190 },
{ date: '2024-05-29', desktop: 78, mobile: 130 },
{ date: '2024-05-30', desktop: 340, mobile: 280 },
{ date: '2024-05-31', desktop: 178, mobile: 230 },
{ date: '2024-06-01', desktop: 178, mobile: 200 },
{ date: '2024-06-02', desktop: 470, mobile: 410 },
{ date: '2024-06-03', desktop: 103, mobile: 160 },
{ date: '2024-06-04', desktop: 439, mobile: 380 },
{ date: '2024-06-05', desktop: 88, mobile: 140 },
{ date: '2024-06-06', desktop: 294, mobile: 250 },
{ date: '2024-06-07', desktop: 323, mobile: 370 },
{ date: '2024-06-08', desktop: 385, mobile: 320 },
{ date: '2024-06-09', desktop: 438, mobile: 480 },
{ date: '2024-06-10', desktop: 155, mobile: 200 },
{ date: '2024-06-11', desktop: 92, mobile: 150 },
{ date: '2024-06-12', desktop: 492, mobile: 420 },
{ date: '2024-06-13', desktop: 81, mobile: 130 },
{ date: '2024-06-14', desktop: 426, mobile: 380 },
{ date: '2024-06-15', desktop: 307, mobile: 350 },
{ date: '2024-06-16', desktop: 371, mobile: 310 },
{ date: '2024-06-17', desktop: 475, mobile: 520 },
{ date: '2024-06-18', desktop: 107, mobile: 170 },
{ date: '2024-06-19', desktop: 341, mobile: 290 },
{ date: '2024-06-20', desktop: 408, mobile: 450 },
{ date: '2024-06-21', desktop: 169, mobile: 210 },
{ date: '2024-06-22', desktop: 317, mobile: 270 },
{ date: '2024-06-23', desktop: 480, mobile: 530 },
{ date: '2024-06-24', desktop: 132, mobile: 180 },
{ date: '2024-06-25', desktop: 141, mobile: 190 },
{ date: '2024-06-26', desktop: 434, mobile: 380 },
{ date: '2024-06-27', desktop: 448, mobile: 490 },
{ date: '2024-06-28', desktop: 149, mobile: 200 },
{ date: '2024-06-29', desktop: 103, mobile: 160 },
{ date: '2024-06-30', desktop: 446, mobile: 400 },
];
const chartConfig = {
visitors: {
label: 'Visitors',
},
desktop: {
label: 'Desktop',
color: 'hsl(var(--chart-1))',
},
mobile: {
label: 'Mobile',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
export function ChartAreaInteractive() {
const isMobile = useIsMobile();
const [timeRange, setTimeRange] = React.useState('30d');
React.useEffect(() => {
if (isMobile) {
setTimeRange('7d');
}
}, [isMobile]);
const filteredData = chartData.filter((item) => {
const date = new Date(item.date);
const referenceDate = new Date('2024-06-30');
let daysToSubtract = 90;
if (timeRange === '30d') {
daysToSubtract = 30;
} else if (timeRange === '7d') {
daysToSubtract = 7;
}
const startDate = new Date(referenceDate);
startDate.setDate(startDate.getDate() - daysToSubtract);
return date >= startDate;
});
return (
<Card className='@container/card'>
<CardHeader className='relative'>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className='hidden @[540px]/card:block'>
Total for the last 3 months
</span>
<span className='@[540px]/card:hidden'>Last 3 months</span>
</CardDescription>
<div className='absolute top-4 right-4'>
<ToggleGroup
type='single'
value={timeRange}
onValueChange={setTimeRange}
variant='outline'
className='hidden @[767px]/card:flex'
>
<ToggleGroupItem value='90d' className='h-8 px-2.5'>
Last 3 months
</ToggleGroupItem>
<ToggleGroupItem value='30d' className='h-8 px-2.5'>
Last 30 days
</ToggleGroupItem>
<ToggleGroupItem value='7d' className='h-8 px-2.5'>
Last 7 days
</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className='flex w-40 @[767px]/card:hidden'
aria-label='Select a value'
>
<SelectValue placeholder='Last 3 months' />
</SelectTrigger>
<SelectContent className='rounded-xl'>
<SelectItem value='90d' className='rounded-lg'>
Last 3 months
</SelectItem>
<SelectItem value='30d' className='rounded-lg'>
Last 30 days
</SelectItem>
<SelectItem value='7d' className='rounded-lg'>
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className='px-2 pt-4 sm:px-6 sm:pt-6'>
<ChartContainer
config={chartConfig}
className='aspect-auto h-[250px] w-full'
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id='fillDesktop' x1='0' y1='0' x2='0' y2='1'>
<stop
offset='5%'
stopColor='var(--color-desktop)'
stopOpacity={1.0}
/>
<stop
offset='95%'
stopColor='var(--color-desktop)'
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id='fillMobile' x1='0' y1='0' x2='0' y2='1'>
<stop
offset='5%'
stopColor='var(--color-mobile)'
stopOpacity={0.8}
/>
<stop
offset='95%'
stopColor='var(--color-mobile)'
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey='date'
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}}
indicator='dot'
/>
}
/>
<Area
dataKey='mobile'
type='natural'
fill='url(#fillMobile)'
stroke='var(--color-mobile)'
stackId='a'
/>
<Area
dataKey='desktop'
type='natural'
fill='url(#fillDesktop)'
stroke='var(--color-desktop)'
stackId='a'
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}
+817
View File
@@ -0,0 +1,817 @@
'use client';
import * as React from 'react';
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
ColumnDef,
ColumnFiltersState,
Row,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
ColumnsIcon,
GripVerticalIcon,
LoaderIcon,
MoreVerticalIcon,
PlusIcon,
TrendingUpIcon,
} from 'lucide-react';
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
import { toast } from 'sonner';
import { z } from 'zod';
import { useIsMobile } from '@/hooks/use-mobile';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
status: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
});
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
});
return (
<Button
{...attributes}
{...listeners}
variant='ghost'
size='icon'
className='text-muted-foreground size-7 hover:bg-transparent'
>
<GripVerticalIcon className='text-muted-foreground size-3' />
<span className='sr-only'>Drag to reorder</span>
</Button>
);
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: 'drag',
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: 'select',
header: ({ table }) => (
<div className='flex items-center justify-center'>
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
/>
</div>
),
cell: ({ row }) => (
<div className='flex items-center justify-center'>
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'header',
header: 'Header',
cell: ({ row }) => {
return <TableCellViewer item={row.original} />;
},
enableHiding: false,
},
{
accessorKey: 'type',
header: 'Section Type',
cell: ({ row }) => (
<div className='w-32'>
<Badge variant='outline' className='text-muted-foreground px-1.5'>
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge
variant='outline'
className='text-muted-foreground flex gap-1 px-1.5 [&_svg]:size-3'
>
{row.original.status === 'Done' ? (
<CheckCircle2Icon className='text-green-500 dark:text-green-400' />
) : (
<LoaderIcon />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: 'target',
header: () => <div className='w-full text-right'>Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault();
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: 'Done',
error: 'Error',
});
}}
>
<Label htmlFor={`${row.original.id}-target`} className='sr-only'>
Target
</Label>
<Input
className='hover:bg-input/30 focus-visible:bg-background h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border'
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: 'limit',
header: () => <div className='w-full text-right'>Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault();
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: 'Done',
error: 'Error',
});
}}
>
<Label htmlFor={`${row.original.id}-limit`} className='sr-only'>
Limit
</Label>
<Input
className='hover:bg-input/30 focus-visible:bg-background h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border'
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: 'reviewer',
header: 'Reviewer',
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== 'Assign reviewer';
if (isAssigned) {
return row.original.reviewer;
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className='sr-only'>
Reviewer
</Label>
<Select>
<SelectTrigger
className='h-8 w-40'
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder='Assign reviewer' />
</SelectTrigger>
<SelectContent align='end'>
<SelectItem value='Eddie Lake'>Eddie Lake</SelectItem>
<SelectItem value='Jamik Tashpulatov'>
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
);
},
},
{
id: 'actions',
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='text-muted-foreground data-[state=open]:bg-muted flex size-8'
size='icon'
>
<MoreVerticalIcon />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-32'>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
});
return (
<TableRow
data-state={row.getIsSelected() && 'selected'}
data-dragging={isDragging}
ref={setNodeRef}
className='relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80'
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[];
}) {
const [data, setData] = React.useState(() => initialData);
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
});
const sortableId = React.useId();
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
const dataIds = React.useMemo<UniqueIdentifier[]>(
() => data?.map(({ id }) => id) || [],
[data]
);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id);
const newIndex = dataIds.indexOf(over.id);
return arrayMove(data, oldIndex, newIndex);
});
}
}
return (
<Tabs
defaultValue='outline'
className='flex w-full flex-col justify-start gap-6'
>
<div className='flex items-center justify-between px-4 lg:px-6'>
<Label htmlFor='view-selector' className='sr-only'>
View
</Label>
<Select defaultValue='outline'>
<SelectTrigger
className='flex w-fit @4xl/main:hidden'
id='view-selector'
>
<SelectValue placeholder='Select a view' />
</SelectTrigger>
<SelectContent>
<SelectItem value='outline'>Outline</SelectItem>
<SelectItem value='past-performance'>Past Performance</SelectItem>
<SelectItem value='key-personnel'>Key Personnel</SelectItem>
<SelectItem value='focus-documents'>Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className='hidden @4xl/main:flex'>
<TabsTrigger value='outline'>Outline</TabsTrigger>
<TabsTrigger value='past-performance' className='gap-1'>
Past Performance{' '}
<Badge
variant='secondary'
className='bg-muted-foreground/30 flex h-5 w-5 items-center justify-center rounded-full'
>
3
</Badge>
</TabsTrigger>
<TabsTrigger value='key-personnel' className='gap-1'>
Key Personnel{' '}
<Badge
variant='secondary'
className='bg-muted-foreground/30 flex h-5 w-5 items-center justify-center rounded-full'
>
2
</Badge>
</TabsTrigger>
<TabsTrigger value='focus-documents'>Focus Documents</TabsTrigger>
</TabsList>
<div className='flex items-center gap-2'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm'>
<ColumnsIcon />
<span className='hidden lg:inline'>Customize Columns</span>
<span className='lg:hidden'>Columns</span>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant='outline' size='sm'>
<PlusIcon />
<span className='hidden lg:inline'>Add Section</span>
</Button>
</div>
</div>
<TabsContent
value='outline'
className='relative flex flex-col gap-4 overflow-auto px-4 lg:px-6'
>
<div className='overflow-hidden rounded-lg border'>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className='bg-muted sticky top-0 z-10'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className='**:data-[slot=table-cell]:first:w-8'>
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className='flex items-center justify-between px-4'>
<div className='text-muted-foreground hidden flex-1 text-sm lg:flex'>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className='flex w-full items-center gap-8 lg:w-fit'>
<div className='hidden items-center gap-2 lg:flex'>
<Label htmlFor='rows-per-page' className='text-sm font-medium'>
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className='w-20' id='rows-per-page'>
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='flex w-fit items-center justify-center text-sm font-medium'>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</div>
<div className='ml-auto flex items-center gap-2 lg:ml-0'>
<Button
variant='outline'
className='hidden h-8 w-8 p-0 lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<ChevronsLeftIcon />
</Button>
<Button
variant='outline'
className='size-8'
size='icon'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<ChevronLeftIcon />
</Button>
<Button
variant='outline'
className='size-8'
size='icon'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<ChevronRightIcon />
</Button>
<Button
variant='outline'
className='hidden size-8 lg:flex'
size='icon'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<ChevronsRightIcon />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value='past-performance'
className='flex flex-col px-4 lg:px-6'
>
<div className='aspect-video w-full flex-1 rounded-lg border border-dashed'></div>
</TabsContent>
<TabsContent value='key-personnel' className='flex flex-col px-4 lg:px-6'>
<div className='aspect-video w-full flex-1 rounded-lg border border-dashed'></div>
</TabsContent>
<TabsContent
value='focus-documents'
className='flex flex-col px-4 lg:px-6'
>
<div className='aspect-video w-full flex-1 rounded-lg border border-dashed'></div>
</TabsContent>
</Tabs>
);
}
const chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
const chartConfig = {
desktop: {
label: 'Desktop',
color: 'var(--primary)',
},
mobile: {
label: 'Mobile',
color: 'var(--primary)',
},
} satisfies ChartConfig;
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
const isMobile = useIsMobile();
return (
<Sheet>
<SheetTrigger asChild>
<Button variant='link' className='text-foreground w-fit px-0 text-left'>
{item.header}
</Button>
</SheetTrigger>
<SheetContent side='right' className='flex flex-col'>
<SheetHeader className='gap-1'>
<SheetTitle>{item.header}</SheetTitle>
<SheetDescription>
Showing total visitors for the last 6 months
</SheetDescription>
</SheetHeader>
<div className='flex flex-1 flex-col gap-4 overflow-y-auto py-4 text-sm'>
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey='month'
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator='dot' />}
/>
<Area
dataKey='mobile'
type='natural'
fill='var(--color-mobile)'
fillOpacity={0.6}
stroke='var(--color-mobile)'
stackId='a'
/>
<Area
dataKey='desktop'
type='natural'
fill='var(--color-desktop)'
fillOpacity={0.4}
stroke='var(--color-desktop)'
stackId='a'
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className='grid gap-2'>
<div className='flex gap-2 leading-none font-medium'>
Trending up by 5.2% this month{' '}
<TrendingUpIcon className='size-4' />
</div>
<div className='text-muted-foreground'>
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
</div>
</div>
<Separator />
</>
)}
<form className='flex flex-col gap-4'>
<div className='flex flex-col gap-3'>
<Label htmlFor='header'>Header</Label>
<Input id='header' defaultValue={item.header} />
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='flex flex-col gap-3'>
<Label htmlFor='type'>Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id='type' className='w-full'>
<SelectValue placeholder='Select a type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='Table of Contents'>
Table of Contents
</SelectItem>
<SelectItem value='Executive Summary'>
Executive Summary
</SelectItem>
<SelectItem value='Technical Approach'>
Technical Approach
</SelectItem>
<SelectItem value='Design'>Design</SelectItem>
<SelectItem value='Capabilities'>Capabilities</SelectItem>
<SelectItem value='Focus Documents'>
Focus Documents
</SelectItem>
<SelectItem value='Narrative'>Narrative</SelectItem>
<SelectItem value='Cover Page'>Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex flex-col gap-3'>
<Label htmlFor='status'>Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id='status' className='w-full'>
<SelectValue placeholder='Select a status' />
</SelectTrigger>
<SelectContent>
<SelectItem value='Done'>Done</SelectItem>
<SelectItem value='In Progress'>In Progress</SelectItem>
<SelectItem value='Not Started'>Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='flex flex-col gap-3'>
<Label htmlFor='target'>Target</Label>
<Input id='target' defaultValue={item.target} />
</div>
<div className='flex flex-col gap-3'>
<Label htmlFor='limit'>Limit</Label>
<Input id='limit' defaultValue={item.limit} />
</div>
</div>
<div className='flex flex-col gap-3'>
<Label htmlFor='reviewer'>Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id='reviewer' className='w-full'>
<SelectValue placeholder='Select a reviewer' />
</SelectTrigger>
<SelectContent>
<SelectItem value='Eddie Lake'>Eddie Lake</SelectItem>
<SelectItem value='Jamik Tashpulatov'>
Jamik Tashpulatov
</SelectItem>
<SelectItem value='Emily Whalen'>Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<SheetFooter className='mt-auto flex gap-2 sm:flex-col sm:space-x-0'>
<Button className='w-full'>Submit</Button>
<SheetClose asChild>
<Button variant='outline' className='w-full'>
Done
</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+207
View File
@@ -0,0 +1,207 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, RefreshCw, AlertCircle, Wallet } from 'lucide-react';
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { WithdrawModal } from '@/components/withdraw-modal';
import { cn } from '@/lib/utils';
export function DetailedWalletBalance({
refreshInterval = 10000,
}: {
refreshInterval?: number;
}) {
const [withdrawModalOpen, setWithdrawModalOpen] = useState(false);
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
queryKey: ['detailed-wallet-balance'],
queryFn: async () => {
return WalletService.getDetailedBalances();
},
refetchInterval: refreshInterval,
});
const calculateTotals = (balances: BalanceDetail[]) => {
let totalWallet = 0;
let totalUser = 0;
let totalOwner = 0;
balances.forEach((detail) => {
if (!detail.error) {
totalWallet += detail.wallet_balance || 0;
totalUser += detail.user_balance || 0;
totalOwner += detail.owner_balance || 0;
}
});
return { totalWallet, totalUser, totalOwner };
};
const totals = data
? calculateTotals(data)
: { totalWallet: 0, totalUser: 0, totalOwner: 0 };
return (
<>
<Card className='h-full w-full shadow-sm'>
<CardHeader className='pb-4'>
<div className='flex items-center justify-between'>
<CardTitle className='text-xl'>Cashu Wallet Balance</CardTitle>
<div className='flex gap-2'>
<Button
variant='default'
size='sm'
onClick={() => setWithdrawModalOpen(true)}
disabled={isLoading || !data || data.length === 0}
>
<Wallet className='mr-2 h-4 w-4' />
Withdraw
</Button>
<Button
variant='ghost'
size='icon'
onClick={() => refetch()}
disabled={isLoading || isFetching}
className='h-8 w-8'
>
<RefreshCw
className={cn(
'h-4 w-4',
(isFetching || isLoading) && 'animate-spin'
)}
/>
<span className='sr-only'>Refresh balance</span>
</Button>
</div>
</div>
<CardDescription>
Detailed balance breakdown by mint and currency
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-primary h-8 w-8 animate-spin' />
</div>
) : isError ? (
<div className='bg-destructive/10 text-destructive flex items-center space-x-2 rounded-md p-4'>
<AlertCircle className='h-5 w-5' />
<span>Error loading balance: {(error as Error).message}</span>
</div>
) : (
<div className='space-y-6'>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Your Balance (Total)
</span>
<span className='text-2xl font-bold text-green-600'>
{totals.totalOwner.toLocaleString()} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Wallet
</span>
<span className='text-lg font-semibold'>
{totals.totalWallet.toLocaleString()} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
User Balance
</span>
<span className='text-lg font-semibold'>
{totals.totalUser.toLocaleString()} sats
</span>
</div>
</div>
<p className='text-muted-foreground text-xs'>
Your balance = Total wallet - User balance
</p>
<div className='overflow-hidden rounded-lg border'>
<div className='bg-muted grid grid-cols-4 gap-2 p-3 text-sm font-semibold'>
<div>Mint / Unit</div>
<div className='text-right'>Wallet</div>
<div className='text-right'>Users</div>
<div className='text-right'>Owner</div>
</div>
{data && data.length > 0 ? (
data
.filter(
(detail) =>
(detail.wallet_balance && detail.wallet_balance > 0) ||
detail.error
)
.map((detail, index) => (
<div
key={index}
className={cn(
'grid grid-cols-4 gap-2 border-t p-3 text-sm',
detail.error && 'bg-destructive/10 text-destructive'
)}
>
<div className='text-xs break-all'>
{detail.mint_url
.replace('https://', '')
.replace('http://', '')}{' '}
{detail.unit.toUpperCase()}
</div>
<div className='text-right font-mono'>
{detail.error
? 'error'
: detail.wallet_balance.toLocaleString()}
</div>
<div className='text-right font-mono'>
{detail.error
? '-'
: detail.user_balance.toLocaleString()}
</div>
<div
className={cn(
'text-right font-mono',
!detail.error &&
detail.owner_balance > 0 &&
'font-semibold text-green-600'
)}
>
{detail.error
? '-'
: detail.owner_balance.toLocaleString()}
</div>
</div>
))
) : (
<div className='text-muted-foreground p-4 text-center text-sm'>
No balances to display
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
<WithdrawModal
open={withdrawModalOpen}
onOpenChange={setWithdrawModalOpen}
balances={data || []}
onSuccess={() => {
refetch();
}}
/>
</>
);
}
+202
View File
@@ -0,0 +1,202 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Copy, Check, SendIcon } from 'lucide-react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { QRCodeSVG } from 'qrcode.react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { WalletService } from '@/lib/api/services/wallet';
const formSchema = z.object({
amount: z
.string()
.min(1, { message: 'Amount is required' })
.refine((val) => !isNaN(Number(val)), {
message: 'Amount must be a valid number',
})
.refine((val) => Number(val) > 0, {
message: 'Amount must be greater than 0',
}),
});
type FormValues = z.infer<typeof formSchema>;
export function EcashRedeem() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const queryClient = useQueryClient();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
amount: '',
},
});
async function onSubmit(values: FormValues) {
setIsSubmitting(true);
try {
// Use the wallet service to generate a token
const result = await WalletService.sendToken(Number(values.amount));
if (result.token) {
setGeneratedToken(result.token);
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] });
} else {
toast.error('Failed to generate token. Please try again.');
}
} catch (error) {
console.error('Error generating token:', error);
toast.error(
'An error occurred while generating the token. Please try again.'
);
} finally {
setIsSubmitting(false);
}
}
const copyToClipboard = async () => {
if (generatedToken) {
try {
await navigator.clipboard.writeText(generatedToken);
setCopied(true);
toast.success('Token copied to clipboard');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy token');
}
}
};
const handleReset = () => {
setGeneratedToken(null);
form.reset();
};
return (
<Card className='h-full w-full shadow-sm'>
<CardHeader>
<div className='flex items-center space-x-2'>
<SendIcon className='text-primary h-5 w-5' />
<CardTitle>Send eCash</CardTitle>
</div>
<CardDescription>
Generate a token to send eCash to someone
</CardDescription>
</CardHeader>
<CardContent>
{!generatedToken ? (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='amount'
render={({ field }) => (
<FormItem>
<FormLabel>Amount (sats)</FormLabel>
<FormControl>
<Input
type='number'
placeholder='Enter amount to send'
{...field}
/>
</FormControl>
<FormDescription>
Enter the amount of satoshis you want to send
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type='submit'
className='w-full'
disabled={isSubmitting}
size='lg'
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Generating...
</>
) : (
'Generate Token'
)}
</Button>
</form>
</Form>
) : (
<div className='space-y-6'>
<Tabs defaultValue='text' className='w-full'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='text'>Text</TabsTrigger>
<TabsTrigger value='qr'>QR Code</TabsTrigger>
</TabsList>
<TabsContent value='text' className='py-4'>
<div className='bg-muted/50 overflow-hidden rounded-md p-4'>
<div className='flex items-start justify-between gap-2'>
<pre className='font-mono text-sm break-all whitespace-pre-wrap'>
{generatedToken}
</pre>
<Button
variant='ghost'
size='icon'
onClick={copyToClipboard}
className='flex-shrink-0'
>
{copied ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</div>
</div>
</TabsContent>
<TabsContent value='qr' className='flex justify-center py-4'>
<div className='rounded-lg bg-white p-4'>
{generatedToken && (
<QRCodeSVG value={generatedToken} className='mx-auto' />
)}
</div>
</TabsContent>
</Tabs>
<Button variant='outline' onClick={handleReset} className='w-full'>
Generate Another Token
</Button>
</div>
)}
</CardContent>
<CardFooter className='text-muted-foreground flex justify-center border-t pt-4 text-sm'>
{!generatedToken
? 'Tokens can only be redeemed once'
: 'Share this token with the recipient to transfer funds'}
</CardFooter>
</Card>
);
}
+85
View File
@@ -0,0 +1,85 @@
'use client';
import {
FolderIcon,
MoreHorizontalIcon,
ShareIcon,
type LucideIcon,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
export function NavDocuments({
items,
}: {
items: {
name: string;
url: string;
icon: LucideIcon;
}[];
}) {
const { isMobile } = useSidebar();
return (
<SidebarGroup className='group-data-[collapsible=icon]:hidden'>
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className='data-[state=open]:bg-accent rounded-sm'
>
<MoreHorizontalIcon />
<span className='sr-only'>More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-24 rounded-lg'
side={isMobile ? 'bottom' : 'right'}
align={isMobile ? 'end' : 'start'}
>
<DropdownMenuItem>
<FolderIcon />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<ShareIcon />
<span>Share</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className='text-sidebar-foreground/70'>
<MoreHorizontalIcon className='text-sidebar-foreground/70' />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { type LucideIcon } from 'lucide-react';
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
}) {
return (
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import * as React from 'react';
import { LucideIcon } from 'lucide-react';
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
+142
View File
@@ -0,0 +1,142 @@
'use client';
import {
BellIcon,
CreditCardIcon,
LogOutIcon,
MoreVerticalIcon,
UserCircleIcon,
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useAuth } from '@/lib/auth/AuthContext';
import { useRouter } from 'next/navigation';
type NavUserProps = {
user?: {
name: string;
email: string;
avatar: string;
};
};
export function NavUser({ user: propUser }: NavUserProps) {
const { isMobile } = useSidebar();
const { user: authUser, signout } = useAuth();
const router = useRouter();
// Use authenticated user if available, otherwise fall back to prop user or default
const userData = authUser
? {
name: authUser.name,
email: authUser.email,
avatar: authUser.avatar_url || '/avatars/default.jpg',
}
: propUser || {
name: 'Guest User',
email: 'guest@example.com',
avatar: '/avatars/default.jpg',
};
const handleLogout = () => {
signout();
router.push('/');
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size='lg'
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
>
<Avatar className='h-8 w-8 rounded-lg grayscale'>
<AvatarImage src={userData.avatar} alt={userData.name} />
<AvatarFallback className='rounded-lg'>
{userData.name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-medium'>{userData.name}</span>
<span className='text-muted-foreground truncate text-xs'>
{userData.email}
</span>
</div>
<MoreVerticalIcon className='ml-auto size-4' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className='w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg'
side={isMobile ? 'bottom' : 'right'}
align='end'
sideOffset={4}
>
<DropdownMenuLabel className='p-0 font-normal'>
<div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>
<Avatar className='h-8 w-8 rounded-lg'>
<AvatarImage src={userData.avatar} alt={userData.name} />
<AvatarFallback className='rounded-lg'>
{userData.name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase()
.slice(0, 2)}
</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-left text-sm leading-tight'>
<span className='truncate font-medium'>{userData.name}</span>
<span className='text-muted-foreground truncate text-xs'>
{userData.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/profile')}>
<UserCircleIcon className='mr-2 h-4 w-4' />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon className='mr-2 h-4 w-4' />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<BellIcon className='mr-2 h-4 w-4' />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOutIcon className='mr-2 h-4 w-4' />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export function SectionCards() {
return (
<div className='*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4'>
<Card className='@container/card'>
<CardHeader className='relative'>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
$1,250.00
</CardTitle>
<div className='absolute top-4 right-4'>
<Badge variant='outline' className='flex gap-1 rounded-lg text-xs'>
<TrendingUpIcon className='size-3' />
+12.5%
</Badge>
</div>
</CardHeader>
<CardFooter className='flex-col items-start gap-1 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Trending up this month <TrendingUpIcon className='size-4' />
</div>
<div className='text-muted-foreground'>
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader className='relative'>
<CardDescription>New Customers</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
1,234
</CardTitle>
<div className='absolute top-4 right-4'>
<Badge variant='outline' className='flex gap-1 rounded-lg text-xs'>
<TrendingDownIcon className='size-3' />
-20%
</Badge>
</div>
</CardHeader>
<CardFooter className='flex-col items-start gap-1 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Down 20% this period <TrendingDownIcon className='size-4' />
</div>
<div className='text-muted-foreground'>
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader className='relative'>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
45,678
</CardTitle>
<div className='absolute top-4 right-4'>
<Badge variant='outline' className='flex gap-1 rounded-lg text-xs'>
<TrendingUpIcon className='size-3' />
+12.5%
</Badge>
</div>
</CardHeader>
<CardFooter className='flex-col items-start gap-1 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Strong user retention <TrendingUpIcon className='size-4' />
</div>
<div className='text-muted-foreground'>Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader className='relative'>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
4.5%
</CardTitle>
<div className='absolute top-4 right-4'>
<Badge variant='outline' className='flex gap-1 rounded-lg text-xs'>
<TrendingUpIcon className='size-3' />
+4.5%
</Badge>
</div>
</CardHeader>
<CardFooter className='flex-col items-start gap-1 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Steady performance <TrendingUpIcon className='size-4' />
</div>
<div className='text-muted-foreground'>Meets growth projections</div>
</CardFooter>
</Card>
</div>
);
}
@@ -0,0 +1,167 @@
'use client';
import * as React from 'react';
import { useState } from 'react';
import { ConfigurationService } from '@/lib/api/services/configuration';
import { useConfiguration } from '@/lib/hooks/useConfiguration';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
export function ServerConfigSettings() {
const { config, updateField, saveConfig, isSyncing } = useConfiguration();
const [connectionStatus, setConnectionStatus] = useState<
'idle' | 'testing' | 'success' | 'error'
>('idle');
const handleConfigChange = (
field: 'endpoint' | 'apiKey' | 'enabled',
value: string | boolean
) => {
updateField(field, value);
setConnectionStatus('idle');
};
const saveConfiguration = () => {
try {
saveConfig(config);
toast.success('Server configuration saved successfully');
} catch (error) {
toast.error('Failed to save configuration');
console.error('Configuration save error:', error);
}
};
const testConnection = async () => {
if (!config.endpoint) {
return;
}
setConnectionStatus('testing');
try {
// Test connection using the ConfigurationService
const isConnected = await ConfigurationService.testConnection(config);
if (isConnected) {
setConnectionStatus('success');
toast.success('Connection successful!');
} else {
setConnectionStatus('error');
toast.error('Connection failed. Please check your settings.');
}
} catch (error) {
console.error('Connection test error:', error);
setConnectionStatus('error');
toast.error('Connection failed. Please check your settings.');
}
};
const renderStatusBadge = () => {
if (connectionStatus === 'idle') {
return (
<Badge variant='outline' className='flex items-center gap-1'>
<AlertCircle className='h-4 w-4 text-yellow-500' />
Not tested
</Badge>
);
} else if (connectionStatus === 'testing') {
return (
<Badge variant='outline' className='flex items-center gap-1'>
<AlertCircle className='h-4 w-4 animate-pulse text-blue-500' />
Testing...
</Badge>
);
} else if (connectionStatus === 'success') {
return (
<Badge variant='outline' className='flex items-center gap-1'>
<CheckCircle className='h-4 w-4 text-green-500' />
Connected
</Badge>
);
} else {
return (
<Badge variant='outline' className='flex items-center gap-1'>
<XCircle className='h-4 w-4 text-red-500' />
Failed
</Badge>
);
}
};
return (
<>
<div className='mb-6 flex items-center justify-between'>
<h2 className='text-xl font-semibold tracking-tight'>
External Server Configuration
</h2>
{isSyncing && (
<Badge variant='outline' className='flex items-center gap-1'>
<AlertCircle className='h-4 w-4 animate-spin text-blue-500' />
Syncing...
</Badge>
)}
</div>
<Card>
<CardHeader>
<div className='flex items-center justify-between'>
<div>
<CardTitle>Request Forwarding Settings</CardTitle>
<CardDescription>
Configure external server endpoint and authentication for API
request forwarding
</CardDescription>
</div>
{config.enabled && renderStatusBadge()}
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='server-endpoint'>Server Endpoint URL</Label>
<div className='relative'>
<Input
id='server-endpoint'
placeholder='https://ecash.routstr.info'
value={config.endpoint}
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
className='pr-24'
/>
<Button
type='button'
variant='ghost'
size='sm'
className='absolute top-0 right-0 h-full px-3 py-2 text-xs'
onClick={() =>
handleConfigChange('endpoint', 'https://ecash.routstr.info')
}
>
Use Default
</Button>
</div>
</div>
</CardContent>
<CardFooter className='flex justify-between'>
<Button
variant='outline'
onClick={testConnection}
disabled={!config.endpoint || connectionStatus === 'testing'}
>
Test Connection
</Button>
<Button onClick={saveConfiguration}>Save Configuration</Button>
</CardFooter>
</Card>
</>
);
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import { LogOut } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { adminLogout } from '@/lib/api/services/auth';
import { toast } from 'sonner';
export function SiteHeader() {
const router = useRouter();
const handleLogout = async () => {
try {
await adminLogout();
toast.success('Logged out successfully');
router.push('/login');
} catch (error) {
console.error('Logout error:', error);
toast.error('Failed to logout');
}
};
return (
<header className='flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12'>
<div className='flex w-full items-center justify-between gap-1 px-4 lg:gap-2 lg:px-6'>
<div className='flex items-center gap-1 lg:gap-2'>
<SidebarTrigger className='-ml-1' />
<Separator
orientation='vertical'
className='mx-2 data-[orientation=vertical]:h-4'
/>
<h1 className='text-base font-medium'>Routstr</h1>
</div>
<Button
variant='ghost'
size='sm'
onClick={handleLogout}
className='gap-2'
>
<LogOut className='h-4 w-4' />
<span className='hidden sm:inline'>Logout</span>
</Button>
</div>
</header>
);
}
+157
View File
@@ -0,0 +1,157 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot='alert-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 AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot='alert-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}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot='alert-dialog-title'
className={cn('text-lg font-semibold', className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot='alert-dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
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',
{
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',
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
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 };
+53
View File
@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
+41
View File
@@ -0,0 +1,41 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success:
'border-transparent bg-green-500 text-white hover:bg-green-500/80',
warning:
'border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80',
error: 'border-transparent bg-red-500 text-white hover:bg-red-500/80',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+59
View File
@@ -0,0 +1,59 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card'
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-header'
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-title'
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-action'
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-content'
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+241
View File
@@ -0,0 +1,241 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
function Carousel({
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<'div'> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role='region'
aria-roledescription='carousel'
data-slot='carousel'
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className='overflow-hidden'
data-slot='carousel-content'
>
<div
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel();
return (
<div
role='group'
aria-roledescription='slide'
data-slot='carousel-item'
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot='carousel-previous'
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className='sr-only'>Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot='carousel-next'
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className='sr-only'>Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+353
View File
@@ -0,0 +1,353 @@
'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;
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}) {
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;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}) {
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,
};
+32
View File
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-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 };
+33
View File
@@ -0,0 +1,33 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot='collapsible' {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot='collapsible-trigger'
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot='collapsible-content'
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+177
View File
@@ -0,0 +1,177 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/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,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className='overflow-hidden p-0'>
<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,
};
+252
View File
@@ -0,0 +1,252 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot='context-menu-radio-group'
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot='context-menu-sub-trigger'
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto' />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot='context-menu-sub-content'
className={cn(
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot='context-menu-content'
className={cn(
'bg-popover text-popover-foreground 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot='context-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot='context-menu-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot='context-menu-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot='context-menu-label'
data-inset={inset}
className={cn(
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot='context-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='context-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+135
View File
@@ -0,0 +1,135 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-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,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
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}
<DialogPrimitive.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,
};
+257
View File
@@ -0,0 +1,257 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto size-4' />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
className={cn(
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+168
View File
@@ -0,0 +1,168 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/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 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot='hover-card' {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot='hover-card-trigger' {...props} />
);
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot='hover-card-portal'>
<HoverCardPrimitive.Content
data-slot='hover-card-content'
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
+77
View File
@@ -0,0 +1,77 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { MinusIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot='input-otp'
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-otp-group'
className={cn('flex items-center', className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot='input-otp-slot'
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot='input-otp-separator' role='separator' {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'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',
className
)}
{...props}
/>
);
}
export { Input };
+24
View File
@@ -0,0 +1,24 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
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 };
+276
View File
@@ -0,0 +1,276 @@
'use client';
import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot='menubar'
className={cn(
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
className
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot='menubar-group' {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot='menubar-trigger'
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
className
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = 'start',
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot='menubar-content'
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<MenubarPrimitive.Item
data-slot='menubar-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot='menubar-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot='menubar-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot='menubar-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot='menubar-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='menubar-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot='menubar-sub-trigger'
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto h-4 w-4' />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot='menubar-sub-content'
className={cn(
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};
+168
View File
@@ -0,0 +1,168 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot='navigation-menu'
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot='navigation-menu-list'
className={cn(
'group flex flex-1 list-none items-center justify-center gap-1',
className
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot='navigation-menu-item'
className={cn('relative', className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1'
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot='navigation-menu-trigger'
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className='relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180'
aria-hidden='true'
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot='navigation-menu-content'
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
className
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
'absolute top-full left-0 isolate z-50 flex justify-center'
)}
>
<NavigationMenuPrimitive.Viewport
data-slot='navigation-menu-viewport'
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot='navigation-menu-link'
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot='navigation-menu-indicator'
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className
)}
{...props}
>
<div className='bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md' />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};
+48
View File
@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+31
View File
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
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 };
+45
View File
@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot='radio-group'
className={cn('grid gap-3', className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot='radio-group-item'
className={cn(
'border-input text-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 dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot='radio-group-indicator'
className='relative flex items-center justify-center'
>
<CircleIcon className='fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2' />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };
+56
View File
@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import { GripVerticalIcon } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/utils';
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot='resizable-panel-group'
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot='resizable-panel' {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot='resizable-handle'
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className='bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border'>
<GripVerticalIcon className='size-2.5' />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+58
View File
@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot='scroll-area'
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot='scroll-area-viewport'
className='focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1'
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot='scroll-area-scrollbar'
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot='scroll-area-thumb'
className='bg-border relative flex-1 rounded-full'
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
+185
View File
@@ -0,0 +1,185 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot='select' {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot='select-group' {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot='select-value' {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot='select-trigger'
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className='size-4 opacity-50' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot='select-content'
className={cn(
'bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
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)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot='select-label'
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot='select-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className='absolute right-2 flex size-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot='select-separator'
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot='select-scroll-up-button'
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUpIcon className='size-4' />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot='select-scroll-down-button'
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDownIcon className='size-4' />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+28
View File
@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator-root'
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
);
}
export { Separator };
+139
View File
@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-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,
};
+726
View File
@@ -0,0 +1,726 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from 'class-variance-authority';
import { PanelLeftIcon } from 'lucide-react';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/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 SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot='sidebar-wrapper'
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
data-slot='sidebar'
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className='sr-only'>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className='group peer text-sidebar-foreground hidden md:block'
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot='sidebar'
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot='sidebar-gap'
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 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)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
/>
<div
data-slot='sidebar-container'
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 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)+(--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'
data-slot='sidebar-inner'
className='bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm'
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar='trigger'
data-slot='sidebar-trigger'
variant='ghost'
size='icon'
className={cn('size-7', className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className='sr-only'>Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar='rail'
data-slot='sidebar-rail'
aria-label='Toggle Sidebar'
tabIndex={-1}
onClick={toggleSidebar}
title='Toggle Sidebar'
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot='sidebar-inset'
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot='sidebar-input'
data-sidebar='input'
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-header'
data-sidebar='header'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-footer'
data-sidebar='footer'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot='sidebar-separator'
data-sidebar='separator'
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-content'
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}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group'
data-sidebar='group'
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot='sidebar-group-label'
data-sidebar='group-label'
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 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}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-group-action'
data-sidebar='group-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group-content'
data-sidebar='group-content'
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-item'
data-sidebar='menu-item'
className={cn('group/menu-item relative', className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden 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-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground 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',
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot='sidebar-menu-button'
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>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-menu-action'
data-sidebar='menu-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after: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 &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-menu-badge'
data-sidebar='menu-badge'
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-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}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot='sidebar-menu-skeleton'
data-sidebar='menu-skeleton'
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className='size-4 rounded-md'
data-sidebar='menu-skeleton-icon'
/>
)}
<Skeleton
className='h-4 max-w-(--skeleton-width) flex-1'
data-sidebar='menu-skeleton-text'
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu-sub'
data-sidebar='menu-sub'
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-sub-item'
data-sidebar='menu-sub-item'
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='sidebar-menu-sub-button'
data-sidebar='menu-sub-button'
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'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}
/>
);
}
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,
};
+13
View File
@@ -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 };
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className='toaster group'
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };
+31
View File
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot='switch'
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot='switch-thumb'
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+116
View File
@@ -0,0 +1,116 @@
'use client';
import * 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,
};
+66
View File
@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
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 };
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot='textarea'
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
);
}
export { Textarea };
+130
View File
@@ -0,0 +1,130 @@
'use client';
import * as React from 'react';
import { X } from 'lucide-react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
success: 'success group border-green-500 bg-green-500 text-white',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'ring-offset-background hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none disabled:opacity-50',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'text-foreground/50 hover:text-foreground absolute top-2 right-2 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-2 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=''
{...props}
>
<X className='h-4 w-4' />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
+73
View File
@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot='toggle-group'
data-variant={variant}
data-size={size}
className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot='toggle-group-item'
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };
+47
View File
@@ -0,0 +1,47 @@
'use client';
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot='toggle'
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
+61
View File
@@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot='tooltip-provider'
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot='tooltip' {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot='tooltip-trigger' {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot='tooltip-content'
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Loader2, RefreshCw, AlertCircle } from 'lucide-react';
import { WalletService } from '@/lib/api/services/wallet';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function WalletBalance({
refreshInterval = 10000,
}: {
refreshInterval?: number;
}) {
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
queryKey: ['wallet-balance'],
queryFn: async () => {
return WalletService.getBalance();
},
refetchInterval: refreshInterval,
});
return (
<Card className='h-full w-full shadow-sm'>
<CardHeader className='pb-2'>
<div className='flex items-center justify-between'>
<CardTitle className='text-xl'>Wallet Balance</CardTitle>
<Button
variant='ghost'
size='icon'
onClick={() => refetch()}
disabled={isLoading || isFetching}
className='h-8 w-8'
>
<RefreshCw
className={cn(
'h-4 w-4',
(isFetching || isLoading) && 'animate-spin'
)}
/>
<span className='sr-only'>Refresh balance</span>
</Button>
</div>
<CardDescription>Current available balance</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-primary h-8 w-8 animate-spin' />
</div>
) : isError ? (
<div className='bg-destructive/10 text-destructive flex items-center space-x-2 rounded-md p-4'>
<AlertCircle className='h-5 w-5' />
<span>Error loading balance: {(error as Error).message}</span>
</div>
) : (
<div className='py-6'>
<div className='text-primary text-5xl font-bold tracking-tight'>
{data?.balance.toLocaleString()}{' '}
<span className='text-3xl'>sats</span>
</div>
<p className='text-muted-foreground mt-2 text-sm'>
Updated {new Date().toLocaleTimeString()}
</p>
</div>
)}
</CardContent>
</Card>
);
}
+260
View File
@@ -0,0 +1,260 @@
'use client';
import { useState, useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AlertCircle, Copy, CheckCircle } from 'lucide-react';
interface WithdrawModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
balances: BalanceDetail[];
onSuccess?: () => void;
}
export function WithdrawModal({
open,
onOpenChange,
balances,
onSuccess,
}: WithdrawModalProps) {
const [selectedMintUnit, setSelectedMintUnit] = useState('');
const [amount, setAmount] = useState('');
const [withdrawnToken, setWithdrawnToken] = useState('');
const [copiedToken, setCopiedToken] = useState(false);
const availableBalances = balances.filter(
(b) => !b.error && b.wallet_balance > 0
);
const selectedBalance = availableBalances.find(
(b) => `${b.mint_url}|${b.unit}` === selectedMintUnit
);
const withdrawMutation = useMutation({
mutationFn: async () => {
if (!selectedBalance) throw new Error('No balance selected');
const amountNum = parseInt(amount);
if (isNaN(amountNum) || amountNum <= 0) {
throw new Error('Invalid amount');
}
return WalletService.withdraw(
amountNum,
selectedBalance.mint_url,
selectedBalance.unit
);
},
onSuccess: (data) => {
setWithdrawnToken(data.token);
onSuccess?.();
},
});
useEffect(() => {
if (selectedBalance) {
const recommendedAmount =
selectedBalance.owner_balance > 0 ? selectedBalance.owner_balance : 0;
setAmount(recommendedAmount.toString());
}
}, [selectedBalance]);
useEffect(() => {
if (!open) {
setWithdrawnToken('');
setAmount('');
setSelectedMintUnit('');
setCopiedToken(false);
}
}, [open]);
const handleCopyToken = () => {
navigator.clipboard.writeText(withdrawnToken);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 2000);
};
const amountNum = parseInt(amount) || 0;
const showWarning =
selectedBalance &&
amountNum > selectedBalance.owner_balance &&
amountNum <= selectedBalance.wallet_balance;
if (withdrawnToken) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-xl'>
<DialogHeader>
<DialogTitle>Withdrawal Successful</DialogTitle>
<DialogDescription>
Save this token! It represents your withdrawn balance.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950'>
<div className='mb-2 flex items-center gap-2'>
<CheckCircle className='h-5 w-5 text-green-600' />
<span className='font-semibold text-green-900 dark:text-green-100'>
Withdrawal Token
</span>
</div>
<div className='rounded-md bg-gray-900 p-3 font-mono text-xs break-all text-green-400'>
{withdrawnToken}
</div>
</div>
<div className='flex gap-2'>
<Button
onClick={handleCopyToken}
className='flex-1'
variant={copiedToken ? 'outline' : 'default'}
>
{copiedToken ? (
<>
<CheckCircle className='mr-2 h-4 w-4' />
Copied!
</>
) : (
<>
<Copy className='mr-2 h-4 w-4' />
Copy Token
</>
)}
</Button>
<Button onClick={() => onOpenChange(false)} variant='outline'>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Withdraw Balance</DialogTitle>
<DialogDescription>
Select mint and currency, then enter the amount to withdraw.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='mint-select'>Mint / Currency</Label>
<Select
value={selectedMintUnit}
onValueChange={setSelectedMintUnit}
>
<SelectTrigger id='mint-select'>
<SelectValue placeholder='Select mint and currency' />
</SelectTrigger>
<SelectContent>
{availableBalances.map((balance) => (
<SelectItem
key={`${balance.mint_url}|${balance.unit}`}
value={`${balance.mint_url}|${balance.unit}`}
>
{balance.mint_url
.replace('https://', '')
.replace('http://', '')}{' '}
{balance.unit.toUpperCase()} ({balance.owner_balance})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='amount'>Amount</Label>
<Input
id='amount'
type='number'
min='1'
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder='Enter amount'
/>
{selectedBalance && (
<div className='space-y-1 text-xs'>
<p className='text-muted-foreground'>
Maximum:{' '}
<span className='font-semibold'>
{selectedBalance.wallet_balance} {selectedBalance.unit}
</span>
</p>
<p className='text-muted-foreground'>
Your recommended balance:{' '}
<span className='font-semibold'>
{selectedBalance.owner_balance} {selectedBalance.unit}
</span>
</p>
</div>
)}
</div>
{showWarning && (
<div className='bg-destructive/10 text-destructive flex items-start gap-2 rounded-md p-3 text-sm'>
<AlertCircle className='mt-0.5 h-5 w-5 flex-shrink-0' />
<span>
Warning: Withdrawing more than your balance will use user funds!
</span>
</div>
)}
{withdrawMutation.isError && (
<div className='bg-destructive/10 text-destructive flex items-start gap-2 rounded-md p-3 text-sm'>
<AlertCircle className='mt-0.5 h-5 w-5 flex-shrink-0' />
<span>
{(withdrawMutation.error as Error).message ||
'Failed to withdraw'}
</span>
</div>
)}
<div className='flex gap-2'>
<Button
onClick={() => withdrawMutation.mutate()}
disabled={
!selectedMintUnit ||
!amount ||
withdrawMutation.isPending ||
amountNum <= 0 ||
(selectedBalance && amountNum > selectedBalance.wallet_balance)
}
className='flex-1'
>
{withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
</Button>
<Button
onClick={() => onOpenChange(false)}
variant='outline'
disabled={withdrawMutation.isPending}
>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
];
export default eslintConfig;
+21
View File
@@ -0,0 +1,21 @@
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;
}
+156
View File
@@ -0,0 +1,156 @@
import axios, { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { ConfigurationService } from './services/configuration';
class ApiClient {
private getBaseUrl(): string {
return ConfigurationService.getLocalBaseUrl();
}
private getHeaders() {
return ConfigurationService.getAuthHeaders();
}
private handleAuthError(error: unknown): void {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (
axiosError.response?.status === 401 ||
axiosError.response?.status === 403
) {
ConfigurationService.clearToken();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
}
}
async get<T>(
endpoint: string,
params?: Record<string, string | number | boolean | undefined>
): Promise<T> {
const config: AxiosRequestConfig = {
headers: this.getHeaders(),
params,
withCredentials: false,
};
try {
console.log(`Making GET request to ${this.getBaseUrl()}${endpoint}`);
const response: AxiosResponse<T> = await axios.get<T>(
`${this.getBaseUrl()}${endpoint}`,
config
);
return response.data;
} catch (error) {
this.handleAuthError(error);
throw error;
}
}
async post<T>(endpoint: string, data: Record<string, unknown>): Promise<T> {
const config: AxiosRequestConfig = {
headers: this.getHeaders(),
withCredentials: false,
};
try {
console.log(
`Making POST request to ${this.getBaseUrl()}${endpoint}`,
data
);
const response: AxiosResponse<T> = await axios.post<T>(
`${this.getBaseUrl()}${endpoint}`,
data,
config
);
return response.data;
} catch (error) {
this.handleAuthError(error);
console.error(`Error posting to ${endpoint}:`, error);
throw error;
}
}
async put<T>(endpoint: string, data: Record<string, unknown>): Promise<T> {
const config: AxiosRequestConfig = {
headers: this.getHeaders(),
withCredentials: false,
};
try {
console.log(
`Making PUT request to ${this.getBaseUrl()}${endpoint}`,
data
);
const response: AxiosResponse<T> = await axios.put<T>(
`${this.getBaseUrl()}${endpoint}`,
data,
config
);
return response.data;
} catch (error) {
this.handleAuthError(error);
console.error(`Error updating ${endpoint}:`, error);
throw error;
}
}
async patch<T>(endpoint: string, data: Record<string, unknown>): Promise<T> {
const config: AxiosRequestConfig = {
headers: this.getHeaders(),
withCredentials: false,
};
try {
console.log(
`Making PATCH request to ${this.getBaseUrl()}${endpoint}`,
data
);
const response: AxiosResponse<T> = await axios.patch<T>(
`${this.getBaseUrl()}${endpoint}`,
data,
config
);
return response.data;
} catch (error) {
this.handleAuthError(error);
console.error(`Error patching ${endpoint}:`, error);
throw error;
}
}
async delete<T>(endpoint: string): Promise<T> {
const config: AxiosRequestConfig = {
headers: this.getHeaders(),
withCredentials: false,
};
try {
console.log(`Making DELETE request to ${this.getBaseUrl()}${endpoint}`);
const response: AxiosResponse<T> = await axios.delete<T>(
`${this.getBaseUrl()}${endpoint}`,
config
);
return response.data;
} catch (error) {
this.handleAuthError(error);
console.error(`Error deleting from ${endpoint}:`, error);
throw error;
}
}
}
export const apiClient = new ApiClient();
export class ApiError extends Error {
status: number;
data?: unknown;
constructor(message: string, status: number, data?: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
export const ApiKeySchema = z.object({
id: z.string(),
name: z.string(),
key: z.string(),
user_id: z.string(),
organization_id: z.string(),
last_used_at: z.string().optional(),
expires_at: z.string().optional(),
is_active: z.boolean().default(true),
created_at: z.string(),
updated_at: z.string().optional(),
});
export const CreateApiKeySchema = ApiKeySchema.omit({
id: true,
key: true,
created_at: true,
updated_at: true,
last_used_at: true,
});
export const UpdateApiKeySchema = z.object({
name: z.string().optional(),
is_active: z.boolean().optional(),
expires_at: z.string().optional(),
});
export type ApiKey = z.infer<typeof ApiKeySchema>;
export type CreateApiKey = z.infer<typeof CreateApiKeySchema>;
export type UpdateApiKey = z.infer<typeof UpdateApiKeySchema>;
+82
View File
@@ -0,0 +1,82 @@
import { z } from 'zod';
export const CreditTransactionTypeEnum = z.enum([
'deposit',
'refund',
'usage',
'withdrawal',
]);
export type CreditTransactionType = z.infer<typeof CreditTransactionTypeEnum>;
export const CreditTransactionSchema = z.object({
id: z.string(),
user_id: z.string(),
api_key_id: z.string().optional(),
amount: z.number().int(),
transaction_type: CreditTransactionTypeEnum,
provider_id: z.string().optional(),
model_id: z.string().optional(),
created_at: z.string().datetime(),
status: z.enum(['pending', 'completed', 'failed', 'cancelled']),
description: z.string().optional(),
});
export type CreditTransaction = z.infer<typeof CreditTransactionSchema>;
export const CreditBalanceSchema = z.object({
user_id: z.string(),
total_balance: z.number().int(),
available_balance: z.number().int(),
pending_balance: z.number().int(),
last_updated: z.string().datetime(),
});
export type CreditBalance = z.infer<typeof CreditBalanceSchema>;
export const CreditUsageByApiKeySchema = z.object({
api_key_id: z.string(),
api_key_name: z.string(),
total_spent: z.number().int(),
usage_by_provider: z.record(z.string(), z.number().int()).optional(),
usage_by_model: z.record(z.string(), z.number().int()).optional(),
});
export type CreditUsageByApiKey = z.infer<typeof CreditUsageByApiKeySchema>;
export const CreditUsageByPeriodSchema = z.object({
period: z.string(),
total_spent: z.number().int(),
by_api_key: z.record(z.string(), z.number().int()).optional(),
by_provider: z.record(z.string(), z.number().int()).optional(),
by_model: z.record(z.string(), z.number().int()).optional(),
});
export type CreditUsageByPeriod = z.infer<typeof CreditUsageByPeriodSchema>;
export const CreditUsageStatisticsSchema = z.object({
user_id: z.string(),
total_spent: z.number().int(),
by_api_key: z.array(CreditUsageByApiKeySchema),
by_period: z.array(CreditUsageByPeriodSchema),
from_date: z.string().datetime(),
to_date: z.string().datetime(),
});
export type CreditUsageStatistics = z.infer<typeof CreditUsageStatisticsSchema>;
export const AddCreditsRequestSchema = z.object({
amount: z.number().int().positive(),
provider_id: z.string().optional(),
});
export type AddCreditsRequest = z.infer<typeof AddCreditsRequestSchema>;
export const RefundCreditsRequestSchema = z.object({
amount: z.number().int().positive(),
api_key_id: z.string().optional(),
});
export type RefundCreditsRequest = z.infer<typeof RefundCreditsRequestSchema>;
export const GetUsageStatisticsRequestSchema = z.object({
from_date: z.string().datetime().optional(),
to_date: z.string().datetime().optional(),
group_by: z.enum(['day', 'week', 'month']).optional(),
api_key_id: z.string().optional(),
});
export type GetUsageStatisticsRequest = z.infer<
typeof GetUsageStatisticsRequestSchema
>;
+150
View File
@@ -0,0 +1,150 @@
import { z } from 'zod';
// Base model schema that defines common properties for all models
export const ModelSchema = z.object({
id: z.string(),
name: z.string(),
full_name: z.string(),
description: z.string().optional(),
modelType: z.string(), // (['text', 'embedding', 'image', 'audio', 'multimodal']),
isEnabled: z.boolean(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
provider: z.string(),
url: z.string().url(),
api_key: z.string().optional(),
input_cost: z.number().min(0),
output_cost: z.number().min(0),
min_cost_per_request: z
.number()
.min(0, 'Minimum cost per request must be non-negative')
.default(0),
min_cash_per_request: z
.number()
.min(0, 'Minimum cash per request must be non-negative')
.default(0),
contextLength: z.number().int().optional(),
apiKeyRequired: z.boolean().default(true),
provider_id: z.string().optional(),
is_free: z.boolean().default(false),
soft_deleted: z.boolean().optional(),
// API key type indicators
has_own_api_key: z.boolean(),
api_key_type: z.string(), // "individual" or "group"
});
// Schema for a model with additional provider-specific settings
export const ModelWithSettingsSchema = ModelSchema.extend({
settings: z.record(z.unknown()).optional(),
pricing: z
.object({
inputCostPer1kTokens: z.number().optional(),
outputCostPer1kTokens: z.number().optional(),
unitCost: z.number().optional(),
})
.optional(),
});
// Schema for creating a new model
export const CreateModelSchema = ModelSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
has_own_api_key: true,
api_key_type: true,
}).extend({
settings: z.record(z.unknown()).optional(),
});
// Schema for updating an existing model
export const UpdateModelSchema = ModelSchema.partial().omit({
id: true,
createdAt: true,
updatedAt: true,
has_own_api_key: true,
api_key_type: true,
});
// Schema for manual model addition form
export const ManualModelSchema = z
.object({
name: z.string().min(1, 'Name is required'),
full_name: z.string().optional(),
input_cost: z.number().min(0, 'Input cost must be non-negative'),
output_cost: z.number().min(0, 'Output cost must be non-negative'),
provider: z.string().min(1, 'Provider is required'),
modelType: z.enum(['text', 'embedding', 'image', 'audio', 'multimodal']),
description: z
.string()
.optional()
.transform((val) => (val === '' ? undefined : val)),
contextLength: z
.number()
.int()
.min(0)
.optional()
.transform((val) => (val === 0 ? undefined : val)),
})
.transform((data) => ({
...data,
}));
// Schema for group settings
export const GroupSettingsSchema = z.object({
provider: z.string().min(1, 'Provider is required'),
group_api_key: z
.string()
.transform((val) => (val === '' ? undefined : val))
.optional(),
group_url: z
.string()
.transform((val) => (val === '' ? undefined : val))
.pipe(z.string().url('Please enter a valid URL').optional())
.optional(),
});
// Schema for listing models with pagination
export const ModelListResponseSchema = z.object({
data: z.array(ModelSchema),
pagination: z.object({
total: z.number(),
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
}),
});
// Schema for model testing request
export const ModelTestRequestSchema = z.object({
modelId: z.string(),
input: z.string(),
parameters: z.record(z.unknown()).optional(),
});
// Schema for model testing response
export const ModelTestResponseSchema = z.object({
output: z.string(),
usage: z
.object({
promptTokens: z.number().optional(),
completionTokens: z.number().optional(),
totalTokens: z.number().optional(),
})
.optional(),
timings: z
.object({
totalMs: z.number(),
})
.optional(),
});
// Export types derived from the schemas
export type Model = z.infer<typeof ModelSchema>;
export type ModelWithSettings = z.infer<typeof ModelWithSettingsSchema>;
export type CreateModel = z.infer<typeof CreateModelSchema>;
export type UpdateModel = z.infer<typeof UpdateModelSchema>;
export type ModelListResponse = z.infer<typeof ModelListResponseSchema>;
export type ModelTestRequest = z.infer<typeof ModelTestRequestSchema>;
export type ModelTestResponse = z.infer<typeof ModelTestResponseSchema>;
export type ManualModel = z.infer<typeof ManualModelSchema>;
export type GroupSettings = z.infer<typeof GroupSettingsSchema>;
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod';
export const OrganizationSchema = z.object({
id: z.string(),
name: z.string(),
slug: z.string().optional(),
logo_url: z.string().optional(),
domain: z.string().optional(),
is_active: z.boolean().default(true),
plan: z.string().optional().default('free'),
created_at: z.string(),
updated_at: z.string().optional(),
});
export const CreateOrganizationSchema = z.object({
name: z.string(),
slug: z.string().optional(),
logo_url: z.string().optional(),
domain: z.string().optional(),
is_active: z.boolean().default(true),
plan: z.string().optional().default('free'),
});
export const UpdateOrganizationSchema = CreateOrganizationSchema.partial();
export type Organization = z.infer<typeof OrganizationSchema>;
export type CreateOrganization = z.infer<typeof CreateOrganizationSchema>;
export type UpdateOrganization = z.infer<typeof UpdateOrganizationSchema>;
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod';
// Schema for provider
export const ProviderSchema = z.object({
id: z.string(),
name: z.string(),
url: z.string().url(),
iconUrl: z.string().optional(),
apiKeyRequired: z.boolean().default(true),
created_at: z.string().optional(),
updated_at: z.string().optional(),
description: z.string().optional(),
isEnabled: z.boolean().default(true),
});
// Schema for creating a provider
export const CreateProviderSchema = ProviderSchema.omit({
id: true,
created_at: true,
updated_at: true,
});
// Schema for updating a provider
export const UpdateProviderSchema = CreateProviderSchema.partial();
// Export types
export type Provider = z.infer<typeof ProviderSchema>;
export type CreateProvider = z.infer<typeof CreateProviderSchema>;
export type UpdateProvider = z.infer<typeof UpdateProviderSchema>;
+34
View File
@@ -0,0 +1,34 @@
import { z } from 'zod';
export const UserRoleEnum = z.enum([
'superuser',
'admin',
'manager',
'group_leader',
'developer',
]);
export type UserRole = z.infer<typeof UserRoleEnum>;
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
avatar_url: z.string().optional(),
organization_id: z.string(),
role: UserRoleEnum,
is_active: z.boolean().default(true),
created_at: z.string().optional(),
updated_at: z.string().optional(),
});
export const CreateUserSchema = UserSchema.omit({
id: true,
created_at: true,
updated_at: true,
});
export const UpdateUserSchema = CreateUserSchema.partial();
export type User = z.infer<typeof UserSchema>;
export type CreateUser = z.infer<typeof CreateUserSchema>;
export type UpdateUser = z.infer<typeof UpdateUserSchema>;
+647
View File
@@ -0,0 +1,647 @@
import { apiClient } from '../client';
import { z } from 'zod';
export const UpstreamProviderSchema = z.object({
id: z.number(),
provider_type: z.string(),
base_url: z.string(),
api_key: z.string().optional(),
api_version: z.string().nullable().optional(),
enabled: z.boolean(),
});
export const CreateUpstreamProviderSchema = z.object({
provider_type: z.string(),
base_url: z.string(),
api_key: z.string(),
api_version: z.string().nullable().optional(),
enabled: z.boolean().default(true),
});
export const UpdateUpstreamProviderSchema = z.object({
provider_type: z.string().optional(),
base_url: z.string().optional(),
api_key: z.string().optional(),
api_version: z.string().nullable().optional(),
enabled: z.boolean().optional(),
});
export const AdminModelPricingSchema = z.object({
prompt: z.number().optional(),
completion: z.number().optional(),
request: z.number().optional(),
image: z.number().optional(),
web_search: z.number().optional(),
internal_reasoning: z.number().optional(),
});
export const AdminModelArchitectureSchema = z.object({
modality: z.string().optional(),
input_modalities: z.array(z.string()).optional(),
output_modalities: z.array(z.string()).optional(),
tokenizer: z.string().optional(),
instruct_type: z.string().nullable().optional(),
});
export const AdminModelSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
created: z.number(),
context_length: z.number(),
architecture: AdminModelArchitectureSchema.or(z.record(z.any())),
pricing: AdminModelPricingSchema.or(z.record(z.any())),
per_request_limits: z.record(z.any()).nullable().optional(),
top_provider: z.record(z.any()).nullable().optional(),
upstream_provider_id: z.number().nullable().optional(),
enabled: z.boolean().default(true),
});
export const ProviderModelsSchema = z.object({
provider: z.object({
id: z.number(),
provider_type: z.string(),
base_url: z.string(),
}),
db_models: z.array(AdminModelSchema),
remote_models: z.array(AdminModelSchema),
});
export type UpstreamProvider = z.infer<typeof UpstreamProviderSchema>;
export type CreateUpstreamProvider = z.infer<
typeof CreateUpstreamProviderSchema
>;
export type UpdateUpstreamProvider = z.infer<
typeof UpdateUpstreamProviderSchema
>;
export type AdminModel = z.infer<typeof AdminModelSchema>;
export type AdminModelPricing = z.infer<typeof AdminModelPricingSchema>;
export type AdminModelArchitecture = z.infer<
typeof AdminModelArchitectureSchema
>;
export type ProviderModels = z.infer<typeof ProviderModelsSchema>;
export interface AdminModelAsModel {
id: string;
name: string;
full_name: string;
description?: string;
modelType: string;
isEnabled: boolean;
createdAt: string;
updatedAt: string;
provider: string;
url: string;
api_key?: string;
input_cost: number;
output_cost: number;
min_cost_per_request: number;
min_cash_per_request: number;
contextLength?: number;
apiKeyRequired: boolean;
provider_id?: string;
is_free: boolean;
soft_deleted?: boolean;
has_own_api_key: boolean;
api_key_type: string;
}
export interface AdminModelGroup {
id: string;
provider: string;
group_api_key?: string;
group_url?: string;
created_at: string;
updated_at: string;
}
export class AdminService {
static convertPricingToPerMillionTokens(
pricing: Record<string, unknown>
): Record<string, unknown> {
if (!pricing) return pricing;
const result = { ...pricing };
if (typeof result.prompt === 'number') {
result.prompt = result.prompt * 1000;
}
if (typeof result.completion === 'number') {
result.completion = result.completion * 1000;
}
return result;
}
static convertPricingToPerToken(
pricing: Record<string, unknown>
): Record<string, unknown> {
if (!pricing) return pricing;
const result = { ...pricing };
if (typeof result.prompt === 'number') {
result.prompt = result.prompt;
}
if (typeof result.completion === 'number') {
result.completion = result.completion;
}
return result;
}
static transformAdminModelToModel(
adminModel: AdminModel,
providerName?: string
): AdminModelAsModel {
const pricing = this.convertPricingToPerMillionTokens(adminModel.pricing);
const inputCost = (pricing?.prompt as number) || 0;
const outputCost = (pricing?.completion as number) || 0;
const requestCost = (pricing?.request as number) || 0;
return {
id: adminModel.id,
name: adminModel.name,
full_name: adminModel.name,
description: adminModel.description,
modelType:
((adminModel.architecture as Record<string, unknown>)
?.modality as string) || 'text',
isEnabled: adminModel.enabled,
createdAt: new Date(adminModel.created * 1000).toISOString(),
updatedAt: new Date(adminModel.created * 1000).toISOString(),
provider: providerName || 'Unknown',
url: '',
api_key: undefined,
input_cost: inputCost,
output_cost: outputCost,
min_cost_per_request: requestCost,
min_cash_per_request: 0,
contextLength: adminModel.context_length,
apiKeyRequired: true,
provider_id: adminModel.upstream_provider_id?.toString(),
is_free: inputCost === 0 && outputCost === 0,
soft_deleted: !adminModel.enabled,
has_own_api_key: false,
api_key_type: 'group',
};
}
static transformModelToAdminModel(model: AdminModelAsModel): AdminModel {
const pricing = this.convertPricingToPerToken({
prompt: model.input_cost,
completion: model.output_cost,
request: model.min_cost_per_request,
image: 0,
web_search: 0,
internal_reasoning: 0,
});
return {
id: model.id,
name: model.name,
description: model.description || '',
created: Math.floor(new Date(model.createdAt).getTime() / 1000),
context_length: model.contextLength || 0,
architecture: {
modality: model.modelType,
input_modalities: [model.modelType],
output_modalities: [model.modelType],
tokenizer: '',
instruct_type: null,
},
pricing,
per_request_limits: null,
top_provider: null,
upstream_provider_id: model.provider_id
? parseInt(model.provider_id)
: null,
enabled: model.isEnabled,
};
}
static async getUpstreamProviders(): Promise<UpstreamProvider[]> {
return await apiClient.get<UpstreamProvider[]>(
'/admin/api/upstream-providers'
);
}
static async getUpstreamProvider(id: number): Promise<UpstreamProvider> {
return await apiClient.get<UpstreamProvider>(
`/admin/api/upstream-providers/${id}`
);
}
static async createUpstreamProvider(
data: CreateUpstreamProvider
): Promise<UpstreamProvider> {
return await apiClient.post<UpstreamProvider>(
'/admin/api/upstream-providers',
data
);
}
static async updateUpstreamProvider(
id: number,
data: UpdateUpstreamProvider
): Promise<UpstreamProvider> {
return await apiClient.patch<UpstreamProvider>(
`/admin/api/upstream-providers/${id}`,
data
);
}
static async deleteUpstreamProvider(
id: number
): Promise<{ ok: boolean; deleted_id: number }> {
return await apiClient.delete<{ ok: boolean; deleted_id: number }>(
`/admin/api/upstream-providers/${id}`
);
}
static async getProviderModels(providerId: number): Promise<ProviderModels> {
const data = await apiClient.get<ProviderModels>(
`/admin/api/upstream-providers/${providerId}/models`
);
return {
...data,
db_models: data.db_models.map((m) => ({
...m,
pricing: this.convertPricingToPerMillionTokens(m.pricing),
})),
remote_models: data.remote_models.map((m) => ({
...m,
pricing: this.convertPricingToPerMillionTokens(m.pricing),
})),
};
}
static async getAdminModels(): Promise<AdminModel[]> {
const models = await apiClient.get<AdminModel[]>('/admin/api/models');
return models.map((m) => ({
...m,
pricing: this.convertPricingToPerMillionTokens(m.pricing),
}));
}
static async getAdminModel(modelId: string): Promise<AdminModel> {
const model = await apiClient.get<AdminModel>(
`/admin/api/models/${encodeURIComponent(modelId)}`
);
return {
...model,
pricing: this.convertPricingToPerMillionTokens(model.pricing),
};
}
static async createAdminModel(data: AdminModel): Promise<AdminModel> {
const payload = {
...data,
pricing: this.convertPricingToPerToken(data.pricing),
};
const model = await apiClient.post<AdminModel>(
'/admin/api/models',
payload
);
return {
...model,
pricing: this.convertPricingToPerMillionTokens(model.pricing),
};
}
static async updateAdminModel(
modelId: string,
data: AdminModel
): Promise<AdminModel> {
const payload = {
...data,
pricing: this.convertPricingToPerToken(data.pricing),
};
const model = await apiClient.patch<AdminModel>(
`/admin/api/models/${encodeURIComponent(modelId)}`,
payload
);
return {
...model,
pricing: this.convertPricingToPerMillionTokens(model.pricing),
};
}
static async deleteAdminModel(
modelId: string
): Promise<{ ok: boolean; deleted_id: string }> {
return await apiClient.delete<{ ok: boolean; deleted_id: string }>(
`/admin/api/models/${encodeURIComponent(modelId)}`
);
}
static async deleteAllAdminModels(): Promise<{
ok: boolean;
deleted: string;
}> {
return await apiClient.delete<{ ok: boolean; deleted: string }>(
'/admin/api/models'
);
}
static async batchCreateModels(
models: AdminModel[]
): Promise<{ created: number; skipped: number }> {
const payload = {
models: models.map((m) => ({
...m,
pricing: this.convertPricingToPerToken(m.pricing),
})),
};
return await apiClient.post<{ created: number; skipped: number }>(
'/admin/api/models/batch',
payload
);
}
static async getModelsWithProviders(): Promise<{
models: AdminModelAsModel[];
groups: AdminModelGroup[];
}> {
const providers = await this.getUpstreamProviders();
const groups: AdminModelGroup[] = providers.map((p) => ({
id: p.id.toString(),
provider: p.provider_type,
group_api_key: p.api_key,
group_url: p.base_url,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
const allModels: AdminModelAsModel[] = [];
const seenModelIds = new Set<string>();
for (const provider of providers) {
try {
const providerModels = await this.getProviderModels(provider.id);
providerModels.db_models.forEach((dbModel) => {
seenModelIds.add(dbModel.id);
allModels.push({
...this.transformAdminModelToModel(dbModel, provider.provider_type),
has_own_api_key: false,
api_key_type: 'group',
});
});
providerModels.remote_models.forEach((remoteModel) => {
if (!seenModelIds.has(remoteModel.id)) {
allModels.push({
...this.transformAdminModelToModel(
remoteModel,
provider.provider_type
),
has_own_api_key: false,
api_key_type: 'remote',
soft_deleted: false,
});
}
});
} catch (error) {
console.error(
`Failed to fetch models for provider ${provider.id}:`,
error
);
}
}
return { models: allModels, groups };
}
static async createModelGroup(data: {
provider: string;
group_api_key?: string;
group_url?: string;
}): Promise<AdminModelGroup> {
const provider = await this.createUpstreamProvider({
provider_type: data.provider,
base_url: data.group_url || '',
api_key: data.group_api_key || '',
enabled: true,
});
return {
id: provider.id.toString(),
provider: provider.provider_type,
group_api_key: provider.api_key,
group_url: provider.base_url,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
static async updateModelGroup(
id: string,
data: { provider?: string; group_api_key?: string; group_url?: string }
): Promise<AdminModelGroup> {
const provider = await this.updateUpstreamProvider(parseInt(id), {
provider_type: data.provider,
base_url: data.group_url,
api_key: data.group_api_key,
});
return {
id: provider.id.toString(),
provider: provider.provider_type,
group_api_key: provider.api_key,
group_url: provider.base_url,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
static async createModel(
data: Record<string, unknown>
): Promise<AdminModelAsModel> {
const pricing = {
prompt: (data.input_cost as number) / 1000000,
completion: (data.output_cost as number) / 1000000,
request: (data.min_cost_per_request as number) || 0,
image: 0,
web_search: 0,
internal_reasoning: 0,
};
const adminModel: AdminModel = {
id: (data.id as string) || (data.full_name as string),
name: (data.name as string) || (data.full_name as string),
description: (data.description as string) || '',
created: Math.floor(Date.now() / 1000),
context_length: (data.contextLength as number) || 0,
architecture: {
modality: (data.modelType as string) || 'text',
input_modalities: [(data.modelType as string) || 'text'],
output_modalities: [(data.modelType as string) || 'text'],
tokenizer: '',
instruct_type: null,
},
pricing,
per_request_limits: null,
top_provider: null,
upstream_provider_id: data.provider_id
? parseInt(data.provider_id as string)
: null,
enabled: data.isEnabled !== false,
};
const created = await this.createAdminModel(adminModel);
return this.transformAdminModelToModel(created, data.provider as string);
}
static async updateModel(
modelId: string,
data: Record<string, unknown>
): Promise<AdminModelAsModel> {
const pricing = {
prompt: (data.input_cost as number) / 1000000,
completion: (data.output_cost as number) / 1000000,
request: (data.min_cost_per_request as number) || 0,
};
const payload: Record<string, unknown> = {
id: modelId,
pricing,
};
if (data.name) payload.name = data.name;
if (data.description) payload.description = data.description;
if (data.contextLength !== undefined)
payload.context_length = data.contextLength;
if (data.provider_id)
payload.upstream_provider_id = parseInt(data.provider_id as string);
if (data.isEnabled !== undefined) payload.enabled = data.isEnabled;
const updated = await apiClient.post<AdminModel>(
'/admin/api/models/update',
payload
);
return this.transformAdminModelToModel(updated, data.provider as string);
}
static async deleteModel(modelId: string): Promise<{ message: string }> {
await this.deleteAdminModel(modelId);
return { message: 'Model deleted successfully' };
}
static async softDeleteModel(modelId: string): Promise<{ message: string }> {
await apiClient.post('/admin/api/models/update', {
id: modelId,
enabled: false,
});
return { message: 'Model soft deleted successfully' };
}
static async deleteModels(
modelIds: string[]
): Promise<{ deleted_count: number; message: string }> {
for (const id of modelIds) {
await this.deleteAdminModel(id);
}
return {
deleted_count: modelIds.length,
message: 'Models deleted successfully',
};
}
static async softDeleteModels(
modelIds: string[]
): Promise<{ deleted_count: number; message: string }> {
for (const id of modelIds) {
const model = await this.getAdminModel(id);
await this.updateAdminModel(id, { ...model, enabled: false });
}
return {
deleted_count: modelIds.length,
message: 'Models soft deleted successfully',
};
}
static async bulkUpdateModels(
modelIds: string[],
updates: { api_key?: string; url?: string }
): Promise<{
updated_count: number;
total_count: number;
message: string;
errors: string[];
}> {
const errors: string[] = [];
let updated_count = 0;
console.log('Bulk update not implemented, ignoring updates:', updates);
for (const id of modelIds) {
try {
const model = await this.getAdminModel(id);
await this.updateAdminModel(id, model);
updated_count++;
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
errors.push(`Failed to update ${id}: ${errorMessage}`);
}
}
return {
updated_count,
total_count: modelIds.length,
message: 'Bulk update completed',
errors,
};
}
static async deleteAllModels(): Promise<{
deleted_count: number;
message: string;
}> {
const models = await this.getAdminModels();
await this.deleteAllAdminModels();
return {
deleted_count: models.length,
message: 'All models deleted successfully',
};
}
static async deleteModelsByProvider(
providerId: string
): Promise<{ deleted_count: number; message: string }> {
const models = await this.getAdminModels();
const providerModels = models.filter(
(m) => m.upstream_provider_id === parseInt(providerId)
);
for (const model of providerModels) {
await this.deleteAdminModel(model.id);
}
return {
deleted_count: providerModels.length,
message: 'Provider models deleted successfully',
};
}
static async restoreModels(
modelIds: string[]
): Promise<{ restored_count: number; message: string }> {
for (const id of modelIds) {
await apiClient.post('/admin/api/models/update', {
id,
enabled: true,
});
}
return {
restored_count: modelIds.length,
message: 'Models restored successfully',
};
}
static async refreshAllModels(): Promise<{ message: string }> {
return { message: 'Refresh not implemented for admin API' };
}
static async refreshModels(data: {
provider_id: string;
}): Promise<{ message: string }> {
console.log(
'Refresh models not implemented for admin API, ignoring data:',
data
);
return { message: 'Refresh not implemented for admin API' };
}
}
+91
View File
@@ -0,0 +1,91 @@
import { apiClient } from '../client';
import { ApiKey, CreateApiKey, UpdateApiKey } from '../schemas/api-keys';
// Commented out as it's not used
// const mockApiKeys: ApiKey[] = [
// {
// id: '1',
// name: 'Production API Key',
// key: 'sk_prod_' + crypto.randomBytes(16).toString('hex'),
// user_id: '1',
// organization_id: '1',
// is_active: true,
// created_at: new Date('2023-01-01').toISOString(),
// },
// {
// id: '2',
// name: 'Development API Key',
// key: 'sk_dev_' + crypto.randomBytes(16).toString('hex'),
// user_id: '2',
// organization_id: '1',
// is_active: true,
// created_at: new Date('2023-02-15').toISOString(),
// },
// {
// id: '3',
// name: 'Testing API Key',
// key: 'sk_test_' + crypto.randomBytes(16).toString('hex'),
// user_id: '3',
// organization_id: '1',
// is_active: false,
// created_at: new Date('2023-03-10').toISOString(),
// },
// ];
export class ApiKeyService {
static async listApiKeys(organizationId?: string): Promise<ApiKey[]> {
try {
const params: Record<string, string | undefined> = {};
if (organizationId) {
params.organization_id = organizationId;
}
return await apiClient.get<ApiKey[]>('/api/api-keys', params);
} catch (error) {
console.error('Error fetching API keys:', error);
throw error;
}
}
static async getApiKey(id: string): Promise<ApiKey> {
try {
return await apiClient.get<ApiKey>(`/api/api-keys/${id}`);
} catch (error) {
console.error(`Error fetching API key ${id}:`, error);
throw error;
}
}
static async createApiKey(data: CreateApiKey): Promise<ApiKey> {
try {
return await apiClient.post<ApiKey>(
'/api/api-keys',
data as Record<string, unknown>
);
} catch (error) {
console.error('Error creating API key:', error);
throw error;
}
}
static async updateApiKey(id: string, data: UpdateApiKey): Promise<ApiKey> {
try {
return await apiClient.put<ApiKey>(
`/api/api-keys/${id}`,
data as Record<string, unknown>
);
} catch (error) {
console.error(`Error updating API key ${id}:`, error);
throw error;
}
}
static async deleteApiKey(id: string): Promise<void> {
try {
await apiClient.delete(`/api/api-keys/${id}`);
} catch (error) {
console.error(`Error deleting API key ${id}:`, error);
throw error;
}
}
}
+84
View File
@@ -0,0 +1,84 @@
import axios from 'axios';
import type { z } from 'zod';
const PRODUCTION = 'production';
export enum HTTPMethod {
GET = 'GET',
POST = 'POST',
}
export enum HTTPStatusCode {
OK = 200,
}
// Define the Nostr Event interface
interface NostrEvent {
kind: number;
created_at: number;
content: string;
tags: string[][];
}
// Define the Nostr interface for the window object
interface NostrWindow extends Window {
nostr: {
signEvent: (event: NostrEvent) => Promise<NostrEvent>;
};
}
export default function api<Request, Response>({
method,
path,
requestSchema,
responseSchema,
}: {
method: HTTPMethod;
path: string;
requestSchema: z.ZodType<Request>;
responseSchema: z.ZodType<Response>;
}): (data: Request) => Promise<Response> {
return function (requestData: Request) {
requestSchema.parse(requestData);
async function apiCall() {
const auth_event = await (
window as unknown as NostrWindow
).nostr.signEvent({
kind: 27235,
created_at: Math.floor(new Date().getTime() / 1000),
content: 'application/json',
tags: [
['u', `${process.env.API_URL}${path}`],
['method', method],
],
});
const response = await axios({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: {
authorization: `Nostr ${btoa(JSON.stringify(auth_event))}`,
'Content-Type': 'application/json',
},
method,
url: path,
[method === HTTPMethod.GET ? 'params' : 'data']: requestData,
});
if (process.env.NODE_ENV === PRODUCTION) {
responseSchema.safeParseAsync(response.data).then((result) => {
if (!result.success) {
console.log('failed to validate result', path);
// TODO: Send error to sentry or other error reporting service
}
});
return response.data as Response;
}
return responseSchema.parse(response.data);
}
return apiCall();
};
}
+130
View File
@@ -0,0 +1,130 @@
import { z } from 'zod';
import { apiClient } from '../client';
import { ConfigurationService } from './configuration';
import axios from 'axios';
export const loginSchema = z.object({
username: z.string().optional(),
password: z.string().optional(),
});
export type LoginRequest = z.infer<typeof loginSchema>;
export const loginResponseSchema = z.object({
id: z.string(),
});
export type LoginResponse = z.infer<typeof loginResponseSchema>;
export async function login(data: LoginRequest): Promise<LoginResponse> {
try {
return await apiClient.post<LoginResponse>('/api/login', data);
} catch (error) {
console.error('Login error:', error);
throw error;
}
}
export const adminLoginSchema = z.object({
password: z.string().min(1, 'Password is required'),
});
export type AdminLoginRequest = z.infer<typeof adminLoginSchema>;
export const adminLoginResponseSchema = z.object({
ok: z.boolean(),
token: z.string(),
expires_in: z.number(),
});
export type AdminLoginResponse = z.infer<typeof adminLoginResponseSchema>;
export async function adminLogin(
password: string
): Promise<AdminLoginResponse> {
try {
const baseUrl = ConfigurationService.getLocalBaseUrl();
const response = await axios.post<AdminLoginResponse>(
`${baseUrl}/admin/api/login`,
{ password },
{
headers: { 'Content-Type': 'application/json' },
}
);
if (response.data.token && response.data.expires_in) {
ConfigurationService.setToken(
response.data.token,
response.data.expires_in
);
}
return response.data;
} catch (error) {
console.error('Admin login error:', error);
throw error;
}
}
export async function adminLogout(): Promise<void> {
try {
const token =
typeof window !== 'undefined'
? localStorage.getItem('admin_token')
: null;
if (token) {
const baseUrl = ConfigurationService.getLocalBaseUrl();
await axios.post(
`${baseUrl}/admin/api/logout`,
{},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
);
}
} catch (error) {
console.error('Admin logout error:', error);
} finally {
ConfigurationService.clearToken();
}
}
export const registerSchema = z.object({
npub: z.string().min(10, { message: 'must have at least 10 character' }),
name: z.string().optional(),
});
export type RegisterRequest = z.infer<typeof registerSchema>;
export type SchemaRegisterProps = z.infer<typeof registerSchema>;
export const registerResponseSchema = z.object({
user_id: z.string(),
theme: z.string(),
});
export type RegisterResponse = z.infer<typeof registerResponseSchema>;
export async function register(
data: RegisterRequest
): Promise<RegisterResponse> {
try {
return await apiClient.post<RegisterResponse>('/api/register', data);
} catch (error) {
console.error('Registration error:', error);
throw error;
}
}
export const registerUser = register;
export async function getUserSettings(): Promise<{ id: string }> {
try {
return await apiClient.get<{ id: string }>('/api/user/settings');
} catch (error) {
console.error('Error fetching user settings:', error);
throw error;
}
}
+270
View File
@@ -0,0 +1,270 @@
import { z } from 'zod';
import axios from 'axios';
// import { apiClient } from '../client';
// Schema for server configuration
export const ServerConfigSchema = z.object({
endpoint: z.string().url().or(z.literal('')),
apiKey: z.string(),
enabled: z.boolean(),
});
export type ServerConfig = z.infer<typeof ServerConfigSchema>;
/**
* Configuration service to manage external server settings
*/
export class ConfigurationService {
/**
* Get the current server configuration from localStorage
*/
static getServerConfig(): ServerConfig {
if (typeof window === 'undefined') {
return {
endpoint: '',
apiKey: '',
enabled: false,
};
}
return {
endpoint: localStorage.getItem('server_endpoint') || '',
apiKey: localStorage.getItem('server_api_key') || '',
enabled: localStorage.getItem('server_enabled') === 'true',
};
}
/**
* Save server configuration to localStorage
*/
static saveServerConfig(config: ServerConfig): void {
if (typeof window === 'undefined') {
return;
}
localStorage.setItem('server_endpoint', config.endpoint);
localStorage.setItem('server_api_key', config.apiKey);
localStorage.setItem('server_enabled', config.enabled.toString());
}
/**
* Check if the server configuration is valid and enabled
*/
static isServerConfigValid(): boolean {
const config = this.getServerConfig();
return config.enabled && !!config.endpoint && !!config.apiKey;
}
/**
* Get the local base URL (never use external configuration)
*/
static getLocalBaseUrl(): string {
return process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000';
}
/**
* Get the base URL for API requests, using the external server if configured
*/
static getBaseUrl(): string {
const config = this.getServerConfig();
if (config.enabled && config.endpoint) {
return config.endpoint;
}
return this.getLocalBaseUrl();
}
/**
* Get authorization headers for requests including API key if configured
* This ensures the server knows which external service to forward to if needed
*/
static getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
try {
if (typeof window !== 'undefined') {
const adminToken = localStorage.getItem('admin_token');
const tokenExpiry = localStorage.getItem('admin_token_expiry');
if (adminToken && tokenExpiry) {
const expiryTime = parseInt(tokenExpiry, 10);
if (Date.now() < expiryTime) {
headers['Authorization'] = `Bearer ${adminToken}`;
return headers;
} else {
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_token_expiry');
}
}
}
} catch (error) {
console.warn('Error accessing localStorage:', error);
}
const adminApiKey = process.env.NEXT_PUBLIC_ADMIN_API_KEY || '';
if (adminApiKey) {
headers['Authorization'] = `Bearer ${adminApiKey}`;
}
return headers;
}
static isTokenValid(): boolean {
if (typeof window === 'undefined') {
return false;
}
const adminToken = localStorage.getItem('admin_token');
const tokenExpiry = localStorage.getItem('admin_token_expiry');
if (!adminToken || !tokenExpiry) {
return false;
}
const expiryTime = parseInt(tokenExpiry, 10);
return Date.now() < expiryTime;
}
static clearToken(): void {
if (typeof window === 'undefined') {
return;
}
localStorage.removeItem('admin_token');
localStorage.removeItem('admin_token_expiry');
}
static setToken(token: string, expiresIn: number): void {
if (typeof window === 'undefined') {
return;
}
const expiryTime = Date.now() + expiresIn * 1000;
localStorage.setItem('admin_token', token);
localStorage.setItem('admin_token_expiry', String(expiryTime));
}
static async testConnection(config: ServerConfig): Promise<boolean> {
if (!config.endpoint) {
return false;
}
try {
const response = await axios.get(`${config.endpoint}/health`, {
headers: {
'Content-Type': 'application/json',
},
timeout: 1000,
});
return response.status === 200;
} catch (error) {
console.error('Server connection test failed:', error);
return false;
}
}
static async saveServerConfigToBackend(
config: ServerConfig
): Promise<boolean> {
if (typeof window === 'undefined') {
return false;
}
try {
// Save to localStorage first
this.saveServerConfig(config);
// Always use the local server URL for saving configuration
const localBaseUrl = this.getLocalBaseUrl();
// Convert from camelCase to snake_case for the backend
const backendConfig = {
endpoint: config.endpoint,
api_key: config.apiKey,
};
// Then save to the backend server using the local URL
await axios.post(`${localBaseUrl}/api/server-config`, backendConfig, {
headers: {
'Content-Type': 'application/json',
},
});
return true;
} catch (error) {
console.error('Failed to save configuration to backend:', error);
return false;
}
}
/**
* Load server configuration from the backend server
*/
static async loadServerConfigFromBackend(): Promise<ServerConfig | null> {
try {
// Always use the local server URL for loading configuration
const localBaseUrl = this.getLocalBaseUrl();
const response = await axios.get<{
endpoint: string;
api_key: string;
}>(`${localBaseUrl}/api/server-config`, {
headers: {
'Content-Type': 'application/json',
},
});
// Transform the response to match our client-side schema
if (response && response.data) {
const config: ServerConfig = {
endpoint: response.data.endpoint || '',
apiKey: response.data.api_key || '',
enabled: !!response.data.endpoint && !!response.data.api_key, // Enable if both fields are present
};
// Save it to localStorage
this.saveServerConfig(config);
return config;
}
return null;
} catch (error) {
console.error('Failed to load configuration from backend:', error);
return null;
}
}
/**
* Request a new API key from the backend
* @param reason The reason for requesting a new key
* @returns The new API key
*/
static async requestNewApiKey(reason: string): Promise<string> {
try {
// Always use the local server URL for requesting new API key
const localBaseUrl = this.getLocalBaseUrl();
const response = await axios.post<{ api_key: string }>(
`${localBaseUrl}/api/server-config/new-key`,
{ reason },
{
headers: {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
},
}
);
if (response.data && response.data.api_key) {
return response.data.api_key;
}
throw new Error('No API key received from server');
} catch (error) {
console.error('Failed to request new API key:', error);
throw error;
}
}
}
+509
View File
@@ -0,0 +1,509 @@
import { apiClient } from '../client';
import { Model, CreateModel, UpdateModel } from '../schemas/models';
import { z } from 'zod';
// Model group schemas matching backend
export const ModelGroupSchema = z.object({
id: z.string(),
created_at: z.string(),
updated_at: z.string(),
provider: z.string(),
group_api_key: z.string().optional(),
group_url: z.string().optional(),
});
export const CreateModelGroupSchema = z.object({
provider: z.string(),
group_api_key: z.string().optional(),
group_url: z.string().optional(),
});
export const UpdateModelGroupSchema = z.object({
provider: z.string().optional(),
group_api_key: z.string().optional(),
group_url: z.string().optional(),
});
export const CollectModelsRequestSchema = z.object({
base_endpoint: z.string(),
api_key: z.string().optional(),
provider_name: z.string(),
default_input_cost: z.number(),
default_output_cost: z.number(),
default_min_cost: z.number(),
});
export const CollectModelsResponseSchema = z.object({
collected_count: z.number(),
skipped_count: z.number(),
models: z.array(z.any()),
errors: z.array(z.string()),
});
export const RefreshModelsRequestSchema = z.object({
provider_id: z.string(),
});
export const RefreshModelsResponseSchema = z.object({
refreshed_count: z.number(),
created_count: z.number(),
restored_count: z.number(),
deleted_count: z.number(),
errors: z.array(z.string()),
});
export const RefreshAllModelsResponseSchema = z.object({
total_refreshed_count: z.number(),
total_created_count: z.number(),
total_restored_count: z.number(),
total_deleted_count: z.number(),
provider_results: z.array(
z.object({
provider_name: z.string(),
refreshed_count: z.number(),
created_count: z.number(),
restored_count: z.number(),
deleted_count: z.number(),
errors: z.array(z.string()),
})
),
errors: z.array(z.string()),
});
export type ModelGroup = z.infer<typeof ModelGroupSchema>;
export type CreateModelGroup = z.infer<typeof CreateModelGroupSchema>;
export type UpdateModelGroup = z.infer<typeof UpdateModelGroupSchema>;
export type CollectModelsRequest = z.infer<typeof CollectModelsRequestSchema>;
export type CollectModelsResponse = z.infer<typeof CollectModelsResponseSchema>;
export type RefreshModelsRequest = z.infer<typeof RefreshModelsRequestSchema>;
export type RefreshModelsResponse = z.infer<typeof RefreshModelsResponseSchema>;
export type RefreshAllModelsResponse = z.infer<
typeof RefreshAllModelsResponseSchema
>;
// Enhanced models schema with provider URL
export const EnhancedOpenAIModelSchema = z.object({
id: z.string(),
canonical_slug: z.string().optional(),
hugging_face_id: z.string().optional(),
name: z.string().optional(),
created: z.number(),
description: z.string().optional(),
context_length: z.number().optional(),
architecture: z.any().optional(),
pricing: z.any().optional(),
top_provider: z.any().optional(),
per_request_limits: z.any().optional(),
supported_parameters: z.array(z.string()).optional(),
provider_url: z.string(),
provider_name: z.string().optional(),
group_id: z.string().optional(),
});
export const EnhancedModelListSchema = z.object({
object: z.string(),
data: z.array(EnhancedOpenAIModelSchema),
});
export type EnhancedOpenAIModel = z.infer<typeof EnhancedOpenAIModelSchema>;
export type EnhancedModelList = z.infer<typeof EnhancedModelListSchema>;
// Backend model structure
export const BackendModelSchema = z.object({
id: z.string(),
created_at: z.string(),
updated_at: z.string(),
full_name: z.string(),
name: z.string(),
url: z.string(),
input_cost: z.string(), // BigDecimal as string
output_cost: z.string(), // BigDecimal as string
api_key: z.string().optional(),
min_cash_per_request: z.string(), // BigDecimal as string
min_cost_per_request: z.string().optional(), // BigDecimal as string
provider_id: z.string().optional(),
provider: z.string().optional(),
soft_deleted: z.boolean().optional(),
architecture: z.any().optional(),
model_type: z.string().optional(),
description: z.string().optional(),
context_length: z.number().optional(),
is_free: z.boolean().optional(),
// API key type indicators from backend
has_own_api_key: z.boolean(),
api_key_type: z.string(),
});
export type BackendModel = z.infer<typeof BackendModelSchema>;
// Transform backend model to frontend model
function transformBackendModelToFrontend(
backendModel: BackendModel,
providerName?: string
): Model {
return {
id: backendModel.id,
name: backendModel.name,
full_name: backendModel.full_name,
description: backendModel.description,
modelType: backendModel.model_type || 'text',
isEnabled: !backendModel.soft_deleted,
createdAt: backendModel.created_at,
updatedAt: backendModel.updated_at,
provider: backendModel.provider || providerName || '',
url: backendModel.url,
api_key: backendModel.api_key,
input_cost: Number(backendModel.input_cost),
output_cost: Number(backendModel.output_cost),
min_cost_per_request: Number(backendModel.min_cost_per_request || '0'),
min_cash_per_request: Number(backendModel.min_cash_per_request || '0'),
contextLength: backendModel.context_length,
apiKeyRequired: true,
provider_id: backendModel.provider_id,
is_free: backendModel.is_free ?? false,
soft_deleted: backendModel.soft_deleted ?? false,
has_own_api_key: backendModel.has_own_api_key,
api_key_type: backendModel.api_key_type,
};
}
export class ModelService {
// Model Group operations
static async createModelGroup(data: CreateModelGroup): Promise<ModelGroup> {
return await apiClient.post<ModelGroup>('/api/model-groups', {
provider: data.provider,
group_api_key: data.group_api_key,
group_url: data.group_url,
});
}
static async getModelGroups(): Promise<ModelGroup[]> {
return await apiClient.get<ModelGroup[]>('/api/model-groups');
}
static async getModelGroup(id: string): Promise<ModelGroup> {
return await apiClient.get<ModelGroup>(`/api/model-groups/${id}`);
}
static async updateModelGroup(
id: string,
data: UpdateModelGroup
): Promise<ModelGroup> {
return await apiClient.put<ModelGroup>(`/api/model-groups/${id}`, {
provider: data.provider,
group_api_key: data.group_api_key,
group_url: data.group_url,
});
}
static async deleteModelGroup(id: string): Promise<{ message: string }> {
return await apiClient.delete<{ message: string }>(
`/api/model-groups/${id}`
);
}
// Model operations
static async createModel(data: CreateModel): Promise<Model> {
const backendData = {
full_name: data.name, // Use name as full_name for manually created models
name: data.name,
url: data.url,
api_key: data.api_key,
input_cost: data.input_cost,
output_cost: data.output_cost,
min_cost_per_request: data.min_cost_per_request,
min_cash_per_request: data.min_cash_per_request,
provider: data.provider,
model_type: data.modelType,
description: data.description,
context_length: data.contextLength,
is_free: data.is_free,
};
const backendModel = await apiClient.post<BackendModel>(
'/api/models',
backendData
);
return transformBackendModelToFrontend(backendModel);
}
static async getModels(): Promise<Model[]> {
const backendModels = await apiClient.get<BackendModel[]>('/api/models');
return backendModels.map((model) => transformBackendModelToFrontend(model));
}
static async getModel(id: string): Promise<Model> {
const backendModel = await apiClient.get<BackendModel>(`/api/models/${id}`);
return transformBackendModelToFrontend(backendModel);
}
static async getModelsByProvider(providerId: string): Promise<Model[]> {
const backendModels = await apiClient.get<BackendModel[]>(
`/api/models/provider/${providerId}`
);
return backendModels.map((model) => transformBackendModelToFrontend(model));
}
static async updateModel(modelId: string, data: UpdateModel): Promise<Model> {
const backendData = {
name: data.name,
url: data.url,
api_key: data.api_key,
input_cost: data.input_cost,
output_cost: data.output_cost,
min_cost_per_request: data.min_cost_per_request,
min_cash_per_request: data.min_cash_per_request,
provider: data.provider,
model_type: data.modelType,
description: data.description,
context_length: data.contextLength,
is_free: data.is_free,
};
const backendModel = await apiClient.put<BackendModel>(
`/api/models/${modelId}`,
backendData
);
return transformBackendModelToFrontend(backendModel);
}
static async deleteModel(id: string): Promise<{ message: string }> {
return await apiClient.delete<{ message: string }>(`/api/models/${id}`);
}
static async softDeleteModel(id: string): Promise<{ message: string }> {
return await apiClient.put<{ message: string }>(
`/api/models/${id}/soft-delete`,
{}
);
}
// Bulk deletion methods
static async deleteModels(
modelIds: string[]
): Promise<{ deleted_count: number; message: string }> {
return await apiClient.post<{ deleted_count: number; message: string }>(
'/api/models/bulk/delete',
{
model_ids: modelIds,
}
);
}
static async softDeleteModels(
modelIds: string[]
): Promise<{ deleted_count: number; message: string }> {
return await apiClient.post<{ deleted_count: number; message: string }>(
'/api/models/bulk/soft-delete',
{
model_ids: modelIds,
}
);
}
// Bulk update method
static async bulkUpdateModels(
modelIds: string[],
updates: { api_key?: string; url?: string }
): Promise<{
updated_count: number;
total_count: number;
message: string;
errors: string[];
}> {
return await apiClient.post<{
updated_count: number;
total_count: number;
message: string;
errors: string[];
}>('/api/models/bulk/update', {
model_ids: modelIds,
api_key: updates.api_key,
url: updates.url,
});
}
static async deleteAllModels(): Promise<{
deleted_count: number;
message: string;
}> {
return await apiClient.post<{ deleted_count: number; message: string }>(
'/api/models/all/delete',
{}
);
}
static async deleteModelsByProvider(
providerId: string
): Promise<{ deleted_count: number; message: string }> {
return await apiClient.post<{ deleted_count: number; message: string }>(
`/api/models/provider/${providerId}/delete`,
{}
);
}
static async restoreModels(
modelIds: string[]
): Promise<{ restored_count: number; message: string }> {
return await apiClient.post<{ restored_count: number; message: string }>(
'/api/models/bulk/restore',
{
model_ids: modelIds,
}
);
}
static async getSoftDeletedModels(): Promise<Model[]> {
const backendModels = await apiClient.get<BackendModel[]>(
'/api/models/deleted'
);
return backendModels.map((model) => transformBackendModelToFrontend(model));
}
// Model collection
static async collectModels(
data: CollectModelsRequest
): Promise<CollectModelsResponse> {
return await apiClient.post<CollectModelsResponse>(
'/api/models/collect',
data
);
}
// Get models with provider information
static async getModelsWithProviders(): Promise<{
models: Model[];
groups: ModelGroup[];
}> {
const [backendModels, groups] = await Promise.all([
apiClient.get<BackendModel[]>('/api/models'),
this.getModelGroups(),
]);
// Map provider_id to provider name and transform models with provider names
const groupsMap = new Map(groups.map((g) => [g.id, g.provider]));
const enhancedModels = backendModels.map((model) =>
transformBackendModelToFrontend(
model,
groupsMap.get(model.provider_id || '') || 'Unknown'
)
);
return { models: enhancedModels, groups };
}
// Legacy method for compatibility
static async listModels(options?: {
team_id?: string;
return_wildcard_routes?: boolean;
enabled?: boolean;
}): Promise<Model[]> {
const { models } = await this.getModelsWithProviders();
return options?.enabled !== false
? models.filter((m) => m.isEnabled)
: models;
}
// Legacy method for compatibility
static async getModelInfo(modelId: string): Promise<Model> {
return await this.getModel(modelId);
}
// Refresh models using group credentials
static async refreshModels(
data: RefreshModelsRequest
): Promise<RefreshModelsResponse> {
return await apiClient.post<RefreshModelsResponse>(
'/api/models/refresh',
data
);
}
// Refresh all models using all group credentials
static async refreshAllModels(): Promise<RefreshAllModelsResponse> {
return await apiClient.post<RefreshAllModelsResponse>(
'/api/models/refresh-all',
{}
);
}
// Enhanced models with provider URL information
static async getEnhancedModels(): Promise<EnhancedModelList> {
return await apiClient.get<EnhancedModelList>('/api/enhanced-models');
}
static async getEnhancedModelsByProvider(
providerName: string
): Promise<EnhancedModelList> {
return await apiClient.get<EnhancedModelList>(
`/api/enhanced-models/${encodeURIComponent(providerName)}`
);
}
// Download functions for JSON export
static downloadModelsAsJson(
data: EnhancedModelList | Model[] | unknown,
filename: string = 'models.json'
) {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
static async downloadAllEnhancedModels() {
try {
const data = await this.getEnhancedModels();
this.downloadModelsAsJson(data, 'enhanced-models-all.json');
} catch (error) {
console.error('Error downloading all enhanced models:', error);
throw error;
}
}
static async downloadEnhancedModelsByProvider(providerName: string) {
try {
const data = await this.getEnhancedModelsByProvider(providerName);
this.downloadModelsAsJson(
data,
`enhanced-models-${providerName.toLowerCase()}.json`
);
} catch (error) {
console.error(
`Error downloading enhanced models for provider ${providerName}:`,
error
);
throw error;
}
}
// Test model through proxy to avoid CORS issues
static async testModel(
modelId: string,
endpointType: string,
requestData: Record<string, unknown>
): Promise<{
success: boolean;
data?: unknown;
error?: string;
status_code?: number;
}> {
const response = await apiClient.post<{
success: boolean;
data?: unknown;
error?: string;
status_code?: number;
}>('/api/models/test', {
model_id: modelId,
endpoint_type: endpointType,
request_data: requestData,
});
return response;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { apiClient } from '../client';
import { User, CreateUser, UpdateUser } from '../schemas/users';
export class UserService {
static async listUsers(organizationId?: string): Promise<User[]> {
try {
const params: Record<string, string | undefined> = {};
if (organizationId) {
params.organization_id = organizationId;
}
return await apiClient.get<User[]>('/api/users', params);
} catch (error) {
console.error('Error fetching users:', error);
throw error;
}
}
static async getUser(userId: string): Promise<User> {
try {
return await apiClient.get<User>(`/api/users/${userId}`);
} catch (error) {
console.error(`Error fetching user ${userId}:`, error);
throw error;
}
}
static async createUser(userData: CreateUser): Promise<User> {
try {
return await apiClient.post<User>(
'/api/users',
userData as Record<string, unknown>
);
} catch (error) {
console.error('Error creating user:', error);
throw error;
}
}
static async updateUser(userId: string, userData: UpdateUser): Promise<User> {
try {
return await apiClient.put<User>(
`/api/users/${userId}`,
userData as Record<string, unknown>
);
} catch (error) {
console.error(`Error updating user ${userId}:`, error);
throw error;
}
}
static async deleteUser(userId: string): Promise<void> {
try {
await apiClient.delete(`/api/users/${userId}`);
} catch (error) {
console.error(`Error deleting user ${userId}:`, error);
throw error;
}
}
}
+124
View File
@@ -0,0 +1,124 @@
import { z } from 'zod';
import { apiClient } from '../client';
// Schema for redeeming tokens
export const RedeemTokenRequestSchema = z.object({
token: z.string().min(1),
});
export const RedeemTokenResponseSchema = z.object({
success: z.boolean(),
amount: z.number().optional(),
message: z.string().optional(),
});
// Schema for sending tokens
export const SendTokenRequestSchema = z.object({
amount: z.number().positive(),
offline: z.boolean().optional(),
});
export const SendTokenResponseSchema = z.object({
token: z.string(),
balance: z.number(),
npub: z.string().optional(),
});
export type RedeemTokenRequest = z.infer<typeof RedeemTokenRequestSchema>;
export type RedeemTokenResponse = z.infer<typeof RedeemTokenResponseSchema>;
export type SendTokenRequest = z.infer<typeof SendTokenRequestSchema>;
export type SendTokenResponse = z.infer<typeof SendTokenResponseSchema>;
export class WalletService {
static async redeemToken(token: string): Promise<RedeemTokenResponse> {
try {
const response = await apiClient.post<RedeemTokenResponse>(
'/api/wallet/redeem',
{ token }
);
return response;
} catch (error) {
console.error('Error redeeming token:', error);
return {
success: false,
message: 'Failed to redeem token. Please try again.',
};
}
}
static async sendToken(amount: number): Promise<SendTokenResponse> {
try {
const response = await apiClient.post<SendTokenResponse>(
'/api/wallet/send',
{ amount }
);
return response;
} catch (error) {
console.error('Error generating token:', error);
throw new Error('Failed to generate token. Please try again.');
}
}
static async getBalance(): Promise<{ balance: number }> {
try {
const response = await apiClient.get<{ balance: number }>(
'/api/wallet/balance'
);
return response;
} catch (error) {
console.error('Error fetching balance:', error);
throw error;
}
}
static async getDetailedBalances(): Promise<BalanceDetail[]> {
try {
const response = await apiClient.get<BalanceDetail[]>(
'/admin/api/balances'
);
return response;
} catch (error) {
console.error('Error fetching detailed balances:', error);
throw error;
}
}
static async withdraw(
amount: number,
mintUrl?: string,
unit: string = 'sat'
): Promise<WithdrawResponse> {
try {
const response = await apiClient.post<WithdrawResponse>(
'/admin/withdraw',
{
amount,
mint_url: mintUrl,
unit,
}
);
return response;
} catch (error) {
console.error('Error withdrawing funds:', error);
throw error;
}
}
}
export interface BalanceDetail {
mint_url: string;
unit: string;
wallet_balance: number;
user_balance: number;
owner_balance: number;
error?: string;
}
export interface WithdrawResponse {
token: string;
}
+159
View File
@@ -0,0 +1,159 @@
import React, { createContext, useContext, useState } from 'react';
import { User } from '@/lib/api/schemas/users';
import { UserService } from '@/lib/api/services/users';
// Nostr extension interface
interface NostrWindow extends Window {
nostr: {
getPublicKey: () => Promise<string>;
};
}
// Define the shape of our auth context state
type AuthContextType = {
user: User | null;
isLoading: boolean;
error: Error | null;
isAuthenticated: boolean;
nostrPublicKey: string | null;
signin: () => Promise<void>;
signout: () => void;
connectNostr: () => Promise<string | null>;
};
// Create the auth context with default values
const AuthContext = createContext<AuthContextType>({
user: null,
isLoading: false,
error: null,
isAuthenticated: false,
nostrPublicKey: null,
signin: async () => {},
signout: () => {},
connectNostr: async () => null,
});
// Auth context provider component
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
const [nostrPublicKey, setNostrPublicKey] = useState<string | null>(null);
// useEffect(() => {
// const setupAuthState = async () => {
// try {
// const savedUser = localStorage.getItem('auth_user');
// if (savedUser) {
// setUser(JSON.parse(savedUser));
// } else {
// try {
// const demoUser = await UserService.getUser('1');
// if (demoUser) {
// setUser(demoUser);
// localStorage.setItem('auth_user', JSON.stringify(demoUser));
// }
// } catch (error) {
// console.error('Error getting demo user:', error);
// }
// }
// const savedPublicKey = localStorage.getItem('nostr_public_key');
// if (savedPublicKey) {
// setNostrPublicKey(savedPublicKey);
// }
// } catch (error) {
// console.error('Error restoring auth state:', error);
// } finally {
// setIsLoading(false);
// }
// };
// setupAuthState();
// }, []);
// Connect to Nostr and get public key
const connectNostr = async (): Promise<string | null> => {
try {
// Check if window.nostr exists
if (typeof window !== 'undefined' && 'nostr' in window) {
// Request public key from extension
const publicKey = await (window as NostrWindow).nostr.getPublicKey();
// Store the public key
setNostrPublicKey(publicKey);
localStorage.setItem('nostr_public_key', publicKey);
return publicKey;
} else {
console.error('Nostr extension not found');
return null;
}
} catch (error) {
console.error('Error connecting to Nostr:', error);
return null;
}
};
// Signin function that returns the admin user for now
const signin = async () => {
setIsLoading(true);
setError(null);
try {
// For now, just return the admin user (id: 1) regardless of credentials
const adminUser = await UserService.getUser('1');
if (!adminUser) {
throw new Error('Admin user not found');
}
// Store user in state and localStorage
setUser(adminUser);
localStorage.setItem('auth_user', JSON.stringify(adminUser));
// Try to connect to Nostr if not already connected
if (!nostrPublicKey) {
await connectNostr();
}
} catch (error) {
setError(error instanceof Error ? error : new Error('Sign in failed'));
throw error;
} finally {
setIsLoading(false);
}
};
// Sign out function
const signout = () => {
setUser(null);
localStorage.removeItem('auth_user');
// We don't clear the Nostr public key on logout to maintain the connection
};
return (
<AuthContext.Provider
value={{
user,
isLoading,
error,
isAuthenticated: true, // Always set to true to bypass auth checks
nostrPublicKey,
signin,
signout,
connectNostr,
}}
>
{children}
</AuthContext.Provider>
);
}
// Custom hook to use the auth context
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

Some files were not shown because too many files have changed in this diff Show More