Merge pull request #648 from ashen0x/chore/dead-code-purge

chore: remove unused dashboard code
This commit is contained in:
9qeklajc
2026-08-13 23:03:42 +02:00
committed by GitHub
44 changed files with 4 additions and 6582 deletions
-614
View File
@@ -1,614 +0,0 @@
[
{
"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"
}
]
+4 -7
View File
@@ -3,7 +3,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
import { AuthProvider } from '@/lib/auth/auth-context';
import { ProtectedRoute } from '@/lib/auth/protected-route';
import { ThemeProvider } from '@/components/theme-provider';
import { TooltipProvider } from '@/components/ui/tooltip';
@@ -36,12 +35,10 @@ export function Providers({ children }: ProvidersProps) {
disableTransitionOnChange
>
<TooltipProvider>
<AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</AuthProvider>
<ProtectedRoute>
{children}
<Toaster position='top-right' />
</ProtectedRoute>
</TooltipProvider>
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
-321
View File
@@ -1,321 +0,0 @@
'use client';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from '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);
type ManualModelInput = z.input<typeof ManualModelSchema>;
const form = useForm<ManualModelInput, unknown, ManualModel>({
resolver: zodResolver(ManualModelSchema),
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-[90dvh] 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>
);
}
-144
View File
@@ -1,144 +0,0 @@
'use client';
import * as React from 'react';
import {
ExternalLinkIcon,
FileTextIcon,
DatabaseIcon,
LayoutDashboardIcon,
ServerIcon,
SettingsIcon,
WalletIcon,
ArrowRightLeftIcon,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { NavSecondary } from '@/components/nav-secondary';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
const data = {
navMain: [
{
title: 'Dashboard',
url: '/',
icon: LayoutDashboardIcon,
},
],
navClouds: [],
navSecondary: [
{
title: 'Dashboard',
url: '/',
icon: LayoutDashboardIcon,
},
{
title: 'Balances',
url: '/balances',
icon: WalletIcon,
},
{
title: 'Transactions',
url: '/transactions',
icon: ArrowRightLeftIcon,
},
{
title: 'Logs',
url: '/logs',
icon: FileTextIcon,
},
{
title: 'Models',
url: '/model',
icon: DatabaseIcon,
},
{
title: 'Providers',
url: '/providers',
icon: ServerIcon,
},
{
title: 'Settings',
url: '/settings',
icon: SettingsIcon,
},
],
documents: [],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible='offcanvas' {...props}>
<SidebarHeader className='px-3 pt-4 pb-3'>
<SidebarMenu>
<SidebarMenuItem>
<div className='flex items-center gap-2 px-2 py-1'>
<Image
src='/icon.ico'
alt='Routstr Node'
width={24}
height={24}
className='rounded'
/>
<div className='space-y-0.5'>
<p className='text-sm font-semibold tracking-tight'>
Routstr Node
</p>
<p className='text-muted-foreground text-[11px]'>
Admin dashboard
</p>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className='flex-1 overflow-y-auto px-2 pb-2'>
<NavSecondary items={data.navSecondary} />
<SidebarGroup className='mt-auto px-0 pt-2 pb-0'>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
<Link
href='https://docs.routstr.com'
target='_blank'
rel='noreferrer'
>
<span>Docs</span>
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild className='h-10 rounded-lg px-3'>
<Link
href='https://chat.routstr.com'
target='_blank'
rel='noreferrer'
>
<span>Chat App</span>
<ExternalLinkIcon className='ml-auto h-3.5 w-3.5' />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{/*
<SidebarFooter>
<NavUser />
</SidebarFooter>
*/}
</Sidebar>
);
}
-289
View File
@@ -1,289 +0,0 @@
'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: 'var(--chart-1)',
},
mobile: {
label: 'Mobile',
color: '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>
);
}
-358
View File
@@ -1,358 +0,0 @@
'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.createModel({
...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-[80dvh] 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 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-sm font-medium'>
{remoteModels.length} models available
</div>
<div className='flex flex-wrap 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>
);
}
-46
View File
@@ -1,46 +0,0 @@
'use client';
import React from 'react';
import { type Model } from '@/lib/api/schemas/models';
import { CostCalculator } from '@/components/cost-calculator';
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-[90dvh] 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>
);
}
-230
View File
@@ -1,230 +0,0 @@
'use client';
import React, { useState, useMemo } from 'react';
import { type Model } from '@/lib/api/schemas/models';
import {
calculateRequestCost,
estimateMinimumTokensForCost,
} from '@/lib/services/cost-validation';
import { formatUsdAmountForDisplayUnit } from '@/lib/currency';
import { useDisplayCurrency } from '@/lib/hooks/use-display-currency';
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 { displayUnit, usdPerSat } = useDisplayCurrency();
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;
const formatDisplayCost = (amountUsd: number) =>
formatUsdAmountForDisplayUnit(amountUsd, displayUnit, usdPerSat);
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'>
{formatDisplayCost(costCalculation.inputCost)}
</span>
</div>
<div className='flex justify-between'>
<span>Output Cost ({outputTokens.toLocaleString()} tokens):</span>
<span className='font-mono'>
{formatDisplayCost(costCalculation.outputCost)}
</span>
</div>
<hr className='my-2' />
<div className='flex justify-between'>
<span>Base Cost:</span>
<span className='font-mono'>
{formatDisplayCost(costCalculation.baseCost)}
</span>
</div>
<div className='flex justify-between'>
<span>Minimum Cost per Request:</span>
<span className='font-mono'>
{formatDisplayCost(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'>
{formatDisplayCost(costCalculation.finalCost)}
</span>
</div>
</div>
</div>
{/* Minimum Cost Alert */}
{hasMinimumCost && (
<Alert
variant={costCalculation.isMinimumApplied ? 'destructive' : 'default'}
>
{costCalculation.isMinimumApplied ? (
<AlertTriangle className='h-4 w-4' />
) : (
<CheckCircle className='h-4 w-4' />
)}
<AlertTitle>
{costCalculation.isMinimumApplied
? 'Minimum Cost Applied'
: 'Above Minimum Cost'}
</AlertTitle>
<AlertDescription>
{costCalculation.isMinimumApplied
? `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) is below the minimum, so the minimum cost of ${formatDisplayCost(costCalculation.minCostPerRequest)} is applied.`
: `The calculated cost (${formatDisplayCost(costCalculation.baseCost)}) meets the minimum requirement of ${formatDisplayCost(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'>
{formatDisplayCost(model.input_cost)}
</span>
</div>
<div className='flex justify-between'>
<span>Output cost per 1M tokens:</span>
<span className='font-mono'>
{formatDisplayCost(model.output_cost)}
</span>
</div>
<div className='flex justify-between'>
<span>Minimum cost per request:</span>
<span className='font-mono'>
{formatDisplayCost(model.min_cost_per_request)}
</span>
</div>
</div>
</div>
</div>
);
}
-813
View File
@@ -1,813 +0,0 @@
'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 /> : <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>
);
}
-199
View File
@@ -1,199 +0,0 @@
'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 } 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>
<CardTitle>Send eCash</CardTitle>
<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>
);
}
-255
View File
@@ -1,255 +0,0 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { WalletBalanceStats } from './wallet-balance-stats';
import type { WalletSnapshot, ChildKeyInfo } from './key-info-details';
import type { RefundReceipt } from './cashu-payment-workflow';
interface ApiKeyManagerProps {
baseUrl: string;
apiKey?: string;
walletInfo?: WalletSnapshot | null;
onApiKeyChanged?: (apiKey: string) => void;
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
onRefundComplete?: (receipt: RefundReceipt) => void;
}
async function fetchWalletInfo(
baseUrl: string,
apiKey: string
): Promise<WalletSnapshot> {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
reserved?: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
child_keys?: ChildKeyInfo[];
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
export function ApiKeyManager({
baseUrl,
apiKey = '',
walletInfo = null,
onApiKeyChanged,
onWalletInfoUpdated,
onRefundComplete,
}: ApiKeyManagerProps): JSX.Element {
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
}
if (typeof navigator === 'undefined' || !navigator.clipboard) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
}
}, []);
const handleSyncBalance = useCallback(async (): Promise<void> => {
const activeApiKey = apiKeyInput.trim();
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsSyncingBalance(true);
try {
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onWalletInfoUpdated?.(snapshot);
toast.success('Balance synced');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to sync balance'
);
} finally {
setIsSyncingBalance(false);
}
}, [apiKeyInput, baseUrl, onWalletInfoUpdated]);
const handleRefund = useCallback(async (): Promise<void> => {
const activeApiKey = apiKeyInput.trim();
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${activeApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const receipt = (await response.json()) as RefundReceipt;
onRefundComplete?.(receipt);
onWalletInfoUpdated?.(null);
setApiKeyInput('');
toast.success('Refund completed');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [apiKeyInput, baseUrl, onRefundComplete, onWalletInfoUpdated]);
const handleApiKeyChange = useCallback(
(newKey: string) => {
setApiKeyInput(newKey);
onApiKeyChanged?.(newKey);
if (newKey !== apiKey) {
onWalletInfoUpdated?.(null);
}
},
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
);
const activeApiKey = apiKeyInput.trim();
const showManageDetails =
hasInteractedManage || Boolean(walletInfo) || activeApiKey.length > 0;
return (
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='text-xl'>API Key Management</CardTitle>
<p className='text-muted-foreground text-xs tracking-wide'>
Manage your existing API keys and balances
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Manage existing key</span>
{walletInfo && (
<span className='text-primary'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(event) => handleApiKeyChange(event.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10'
onClick={() => handleCopy(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isSyncingBalance || !activeApiKey}
>
<RefreshCcw className='h-4 w-4' />
Sync
</Button>
</div>
</div>
{showManageDetails && (
<div className='space-y-4'>
<WalletBalanceStats
balanceMsats={walletInfo?.balanceMsats}
reservedMsats={walletInfo?.reservedMsats}
/>
<Separator />
<div className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>Refund remaining balance</span>
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding || !activeApiKey}
variant='destructive'
className='gap-2'
>
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing...' : 'Refund Key'}
</Button>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
</div>
</div>
)}
</section>
</CardContent>
</Card>
);
}
-198
View File
@@ -1,198 +0,0 @@
'use client';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Copy, RotateCcw } from 'lucide-react';
import type { WalletSnapshot } from './key-info-details';
import { toast } from 'sonner';
interface KeyInfoDisplayProps {
walletInfo: WalletSnapshot;
onResetSpent?: (childKey: string) => Promise<void>;
isResetting?: string | null;
}
const formatSats = (msats: number) =>
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
const formatMsats = (msats: number) =>
new Intl.NumberFormat('en-US').format(msats);
const formatDate = (timestamp: number | null) =>
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
export function KeyInfoDisplay({
walletInfo,
onResetSpent,
isResetting,
}: KeyInfoDisplayProps) {
const handleCopy = (value: string) => {
navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
};
return (
<div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Status & Identity</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Type</span>
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
</Badge>
</div>
{walletInfo.parentKey && (
<div className='space-y-1'>
<span className='text-muted-foreground text-xs tracking-wider'>
Parent Key
</span>
<div className='flex items-center gap-2'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{walletInfo.parentKey}
</code>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(walletInfo.parentKey!)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Validity</span>
<span className='text-sm font-medium'>
{formatDate(walletInfo.validityDate)}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Infos</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Total Spent</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
{walletInfo.balanceLimit !== null && (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spend Limit
</span>
<span className='font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceLimit)} sats
</span>
</div>
{walletInfo.balanceLimitReset && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Reset Policy
</span>
<Badge variant='outline' className='capitalize'>
{walletInfo.balanceLimitReset}
</Badge>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{!walletInfo.isChild &&
walletInfo.childKeys &&
walletInfo.childKeys.length > 0 && (
<Card>
<CardHeader>
<CardTitle className='text-lg'>
Child Keys ({walletInfo.childKeys.length})
</CardTitle>
<CardDescription>
Secondary keys using this account&apos;s balance
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{walletInfo.childKeys.map((ck) => (
<div
key={ck.api_key}
className='space-y-3 rounded-lg border p-4'
>
<div className='flex items-center justify-between gap-4'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{ck.api_key}
</code>
<div className='flex gap-1'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(ck.api_key)}
>
<Copy className='h-4 w-4' />
</Button>
{onResetSpent && (
<Button
variant='ghost'
size='icon'
className='text-destructive h-8 w-8'
title='Reset consumption'
disabled={isResetting === ck.api_key}
onClick={() => onResetSpent(ck.api_key)}
>
{isResetting === ck.api_key ? (
<RotateCcw className='h-4 w-4 animate-spin' />
) : (
<RotateCcw className='h-4 w-4' />
)}
</Button>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
@@ -1,63 +0,0 @@
'use client';
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from '@/components/ui/card';
interface WalletBalanceStatsProps {
balanceMsats?: number;
reservedMsats?: number;
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function WalletMetricCard({
label,
valueMsats,
}: {
label: string;
valueMsats?: number;
}) {
const hasValue = typeof valueMsats === 'number';
return (
<Card className='shadow-none'>
<CardHeader className='p-3 pb-2'>
<CardDescription className='text-[0.65rem] tracking-wide'>
{label}
</CardDescription>
</CardHeader>
<CardContent className='px-3 pt-0 pb-3'>
<p className='text-xl font-semibold tabular-nums'>
{hasValue ? `${formatSats(valueMsats)} sats` : '-'}
</p>
{hasValue && (
<p className='text-muted-foreground text-xs tabular-nums'>
{formatMsats(valueMsats)} msats
</p>
)}
</CardContent>
</Card>
);
}
export function WalletBalanceStats({
balanceMsats,
reservedMsats,
}: WalletBalanceStatsProps) {
return (
<div className='grid gap-3 sm:grid-cols-2'>
<WalletMetricCard label='Spendable' valueMsats={balanceMsats} />
<WalletMetricCard label='Reserved' valueMsats={reservedMsats} />
</div>
);
}
-86
View File
@@ -1,86 +0,0 @@
'use client';
import Link from 'next/link';
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>
<Link href={item.url}>
<item.icon />
<span>{item.name}</span>
</Link>
</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>
);
}
-54
View File
@@ -1,54 +0,0 @@
'use client';
import Link from 'next/link';
import { type LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
}) {
const pathname = usePathname();
return (
<SidebarGroup className='px-0'>
<SidebarGroupContent>
<SidebarMenu className='gap-1.5'>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
asChild
className='h-11 rounded-xl px-3 text-[0.95rem]'
isActive={
item.url === '/'
? pathname === '/'
: pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
-58
View File
@@ -1,58 +0,0 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { LucideIcon } from 'lucide-react';
import { usePathname } from 'next/navigation';
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { cn } from '@/lib/utils';
export function NavSecondary({
items,
className,
...props
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const pathname = usePathname();
return (
<SidebarGroup className={cn('px-0', className)} {...props}>
<SidebarGroupContent>
<SidebarMenu className='gap-1.5'>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
className='h-11 rounded-xl px-3 text-[0.95rem]'
isActive={
item.url === '/'
? pathname === '/'
: pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
-142
View File
@@ -1,142 +0,0 @@
'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/auth-context';
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>
);
}
-172
View File
@@ -1,172 +0,0 @@
'use client';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AdminService } from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { SimpleLightningTopup } from './SimpleLightningTopup';
import { SimpleCashuTopup } from './SimpleCashuTopup';
interface ProviderBalanceProps {
providerId: number;
platformUrl?: string | null;
isRoutstr?: boolean;
nodeUrl?: string;
}
export function ProviderBalance({
providerId,
platformUrl,
isRoutstr = false,
nodeUrl,
}: ProviderBalanceProps) {
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const queryClient = useQueryClient();
const {
data: balanceData,
isLoading,
error,
} = useQuery({
queryKey: ['provider-balance', providerId],
queryFn: () => AdminService.getProviderBalance(providerId),
refetchInterval: 30000,
refetchOnWindowFocus: true,
retry: 1,
});
const handleTopUpClick = () => {
if (
platformUrl &&
(platformUrl.includes('openrouter.ai') ||
platformUrl.includes('openai.com'))
) {
window.open(platformUrl, '_blank');
return;
}
setIsTopupDialogOpen(true);
};
const handleCloseDialog = () => {
setIsTopupDialogOpen(false);
queryClient.invalidateQueries({
queryKey: ['provider-balance', providerId],
});
};
if (isLoading) {
return <Skeleton className='h-9 w-24' />;
}
if (
error ||
!balanceData?.ok ||
balanceData.balance_data === undefined ||
balanceData.balance_data === null
) {
return null;
}
const balance = balanceData.balance_data;
let displayValue = 'N/A';
if (typeof balance === 'number') {
displayValue = isRoutstr
? `${balance.toLocaleString()} sats`
: `$${balance.toFixed(2)}`;
} else if (balance && typeof balance === 'object') {
const b = balance as Record<string, unknown>;
if (typeof b.balance === 'number') {
displayValue = `$${b.balance.toFixed(2)}`;
} else if (typeof b.balance === 'string') {
displayValue = b.balance;
} else if (b.amount !== undefined) {
displayValue = `$${Number(b.amount).toFixed(2)}`;
}
}
return (
<>
<Button
variant='outline'
size='sm'
onClick={handleTopUpClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className='w-full font-mono sm:w-auto'
>
{isHovered ? 'Top Up' : displayValue}
</Button>
<Dialog open={isTopupDialogOpen} onOpenChange={handleCloseDialog}>
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-md'>
<DialogHeader>
<DialogTitle>Top Up Balance</DialogTitle>
<DialogDescription>
{isRoutstr
? `Top up your balance on node ${nodeUrl}`
: 'Choose a payment method to top up your account balance.'}
</DialogDescription>
</DialogHeader>
<div className='space-y-6 py-4'>
<section className='space-y-2'>
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
Lightning Top-up
</Label>
<SimpleLightningTopup
providerId={providerId}
baseUrl={nodeUrl || ''}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-balance', providerId],
});
}}
/>
</section>
<Separator />
<section className='space-y-2'>
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
Cashu Token Top-up
</Label>
<SimpleCashuTopup
providerId={providerId}
baseUrl={nodeUrl || ''}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ['provider-balance', providerId],
});
}}
/>
</section>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleCloseDialog}
className='w-full'
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,272 +0,0 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Database,
Pencil,
Trash2,
ChevronDown,
ChevronUp,
RotateCcw,
AlertTriangle,
KeyRound,
} from 'lucide-react';
import {
AdminService,
UpstreamProvider,
UpdateUpstreamProvider,
} from '@/lib/api/services/admin';
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
import { RoutstrCreateKeySection } from './RoutstrCreateKeySection';
import { toast } from 'sonner';
interface RoutstrProviderCardProps {
provider: UpstreamProvider;
expanded: boolean;
onToggleExpand: () => void;
onEdit: () => void;
onDelete: () => void;
onUpdateKey?: () => void;
balanceComponent: React.ReactNode;
children?: React.ReactNode;
}
export function RoutstrProviderCard({
provider,
expanded,
onToggleExpand,
onEdit,
onDelete,
balanceComponent,
children,
}: RoutstrProviderCardProps) {
const queryClient = useQueryClient();
const [isKeyDialogOpen, setIsKeyDialogOpen] = useState(false);
const hasMint = !!provider.provider_settings?.topup_mint_url;
const hasApiKey = !!provider.api_key;
const refundMutation = useMutation({
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
onSuccess: (data) => {
if (data.ok) {
toast.success('Refund successful', {
description: data.message,
});
queryClient.invalidateQueries({
queryKey: ['provider-balance', provider.id],
});
queryClient.invalidateQueries({ queryKey: ['balances'] });
} else {
toast.error('Refund failed', {
description: data.message,
});
}
},
onError: (error: Error) => {
toast.error(`Refund error: ${error.message}`);
},
});
const updateKeyMutation = useMutation({
mutationFn: (data: { id: number; data: UpdateUpstreamProvider }) =>
AdminService.updateUpstreamProvider(data.id, data.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
queryClient.invalidateQueries({
queryKey: ['provider-balance', provider.id],
});
setIsKeyDialogOpen(false);
toast.success('API key saved to provider');
},
onError: (error: Error) => {
toast.error(`Failed to save key: ${error.message}`);
},
});
const handleKeyCreated = async (newApiKey: string) => {
if (hasApiKey) {
try {
const result = await RoutstrProviderService.refundBalance(provider.id);
if (result.ok) {
toast.success('Old key refunded', {
description: result.message,
});
} else {
toast.warning('Refund skipped', {
description: result.message,
});
}
} catch (error) {
toast.warning(
`Could not refund old key: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
updateKeyMutation.mutate({
id: provider.id,
data: { api_key: newApiKey },
});
};
return (
<>
<Card>
<CardHeader>
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<CardTitle className='truncate text-lg'>Routstr Node</CardTitle>
<Badge
variant={provider.enabled ? 'default' : 'secondary'}
className='w-fit sm:ml-2'
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{!hasApiKey && (
<Badge
variant='outline'
className='flex items-center gap-1 border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400'
>
<AlertTriangle className='h-3 w-3' />
No API Key
</Badge>
)}
{!hasMint && (
<Badge
variant='outline'
className='flex items-center gap-1 border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-400'
title='Top-up is not possible because no top-up mint is selected in the provider settings. Please edit settings to select a mint from your node configuration.'
>
<AlertTriangle className='h-3 w-3' />
Top-up Disabled: No Mint Selected
</Badge>
)}
</div>
<CardDescription className='mt-1 break-all'>
{provider.base_url}
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
{hasApiKey && (
<div className='flex flex-col gap-1'>{balanceComponent}</div>
)}
<Button
variant='outline'
size='sm'
onClick={() => setIsKeyDialogOpen(true)}
className='w-full sm:w-auto'
title={
hasApiKey
? 'Create a new key on the upstream node'
: 'Create an API key on the upstream node'
}
>
<KeyRound className='mr-1 h-4 w-4' />
<span className='hidden sm:inline'>
{hasApiKey ? 'New Key' : 'Create Key'}
</span>
</Button>
{hasApiKey && (
<Button
variant='outline'
size='sm'
onClick={() => refundMutation.mutate()}
disabled={refundMutation.isPending}
className='text-orange-600 hover:text-orange-700 dark:text-orange-400'
title='Refund balance to local wallet'
>
<RotateCcw
className={`mr-1 h-4 w-4 ${refundMutation.isPending ? 'animate-spin' : ''}`}
/>
<span className='hidden sm:inline'>Refund</span>
</Button>
)}
<Button
variant='outline'
size='sm'
onClick={onToggleExpand}
className='w-full sm:w-auto'
>
<Database className='mr-1 h-4 w-4' />
<span className='hidden sm:inline'>Models</span>
{expanded ? (
<ChevronUp className='ml-1 h-4 w-4' />
) : (
<ChevronDown className='ml-1 h-4 w-4' />
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={onEdit}
className='w-full sm:w-auto'
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='sm'
onClick={onDelete}
className='w-full sm:w-auto'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
</CardHeader>
{children}
</Card>
<Dialog open={isKeyDialogOpen} onOpenChange={setIsKeyDialogOpen}>
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-lg'>
<DialogHeader>
<DialogTitle>
{hasApiKey ? 'Create New Key on Upstream Node' : 'Create API Key'}
</DialogTitle>
<DialogDescription>
{hasApiKey
? 'Create a new API key on the upstream node. The remaining balance on the current key will be automatically refunded to your local wallet before it is replaced.'
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
</DialogDescription>
</DialogHeader>
<RoutstrCreateKeySection
baseUrl={provider.base_url}
onApiKeyCreated={handleKeyCreated}
/>
<DialogFooter>
<Button
variant='outline'
onClick={() => setIsKeyDialogOpen(false)}
className='w-full'
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,69 +0,0 @@
'use client';
import { type JSX, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { AdminService } from '@/lib/api/services/admin';
interface SimpleCashuTopupProps {
providerId: number;
baseUrl: string;
onSuccess?: () => void;
}
export function SimpleCashuTopup({
providerId,
onSuccess,
}: SimpleCashuTopupProps): JSX.Element {
const [token, setToken] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleTopup = async () => {
if (!token.trim()) {
toast.error('Enter a Cashu token');
return;
}
setIsLoading(true);
try {
// Use the backend to proxy the token topup
// This is safer as the backend has the actual API key
const response = await AdminService.topupProviderWithToken(
providerId,
token.trim()
);
if (!response.ok) throw new Error(response.message || 'Top-up failed');
toast.success('Token redeemed successfully!');
setToken('');
onSuccess?.();
} catch (e) {
const error = e as Error;
toast.error(error.message || 'Top-up failed');
} finally {
setIsLoading(false);
}
};
return (
<div className='bg-muted/20 space-y-3 rounded-lg border p-4'>
<div className='space-y-2'>
<Textarea
placeholder='Paste Cashu token here...'
value={token}
onChange={(e) => setToken(e.target.value)}
rows={2}
className='font-mono text-xs'
/>
<Button
onClick={handleTopup}
disabled={isLoading}
size='sm'
className='w-full'
>
{isLoading ? 'Redeeming...' : 'Redeem Token'}
</Button>
</div>
</div>
);
}
@@ -1,188 +0,0 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import Image from 'next/image';
import { Copy, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import QRCode from 'qrcode';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AdminService } from '@/lib/api/services/admin';
async function generateQRCodeSVG(text: string): Promise<string> {
try {
return await QRCode.toDataURL(text, {
type: 'image/png',
width: 400,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
} catch (error) {
console.error('Failed to generate QR code:', error);
return '';
}
}
interface SimpleLightningTopupProps {
providerId: number;
baseUrl: string;
onSuccess?: () => void;
}
export function SimpleLightningTopup({
providerId,
onSuccess,
}: SimpleLightningTopupProps): JSX.Element {
const [amount, setAmount] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [invoice, setInvoice] = useState<{
bolt11: string;
invoice_id: string;
} | null>(null);
const [qrCode, setQrCode] = useState<string>('');
const [isWaiting, setIsWaiting] = useState(false);
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const pollStatus = useCallback(
async (invoiceId: string) => {
const maxAttempts = 60; // 5 minutes with 5 second intervals
let attempts = 0;
const poll = async () => {
try {
const response = await AdminService.checkTopupStatus(
providerId,
invoiceId
);
if (response.paid) {
toast.success('Payment received!');
setInvoice(null);
setQrCode('');
setIsWaiting(false);
onSuccess?.();
return;
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000);
} else {
toast.error('Payment timeout - please check manually');
setIsWaiting(false);
}
} catch (e) {
console.error('Failed to poll topup status:', e);
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000);
} else {
toast.error('Failed to check payment status');
setIsWaiting(false);
}
}
};
poll();
},
[providerId, onSuccess]
);
const handleCreate = async () => {
const amt = parseInt(amount);
if (!amt) {
toast.error('Enter a valid amount');
return;
}
setIsCreating(true);
try {
const response = await AdminService.initiateProviderTopup(
providerId,
amt
);
if (!response.ok || !response.topup_data)
throw new Error('Failed to create invoice');
const bolt11 = response.topup_data.payment_request as string;
setInvoice({
bolt11,
invoice_id: response.topup_data.invoice_id as string,
});
const qr = await generateQRCodeSVG(bolt11);
setQrCode(qr);
setIsWaiting(true);
pollStatus(response.topup_data.invoice_id as string);
} catch (e) {
const error = e as Error;
toast.error(error.message || 'Failed to request invoice from backend');
} finally {
setIsCreating(false);
}
};
return (
<div className='bg-muted/20 space-y-3 rounded-lg border p-4'>
<div className='flex gap-2'>
<Input
type='number'
placeholder='Amount in sats'
value={amount}
onChange={(e) => setAmount(e.target.value)}
className='h-9'
/>
<Button
onClick={handleCreate}
disabled={isCreating || isWaiting}
size='sm'
>
{isCreating ? 'Creating...' : 'Get Invoice'}
</Button>
</div>
{invoice && (
<div className='space-y-2 border-t pt-2'>
<div className='text-muted-foreground flex items-center justify-between text-xs'>
<span>Invoice Generated</span>
<Button
variant='ghost'
size='icon'
className='h-6 w-6'
onClick={() => handleCopy(invoice.bolt11)}
>
<Copy className='h-3 w-3' />
</Button>
</div>
{qrCode && (
<div className='flex justify-center py-2'>
<Image
src={qrCode}
alt='Lightning Invoice QR Code'
className='h-60 w-60'
width={320}
height={320}
unoptimized
/>
</div>
)}
<div className='bg-muted rounded border p-2 font-mono text-[10px] break-all'>
{invoice.bolt11}
</div>
{isWaiting && (
<div className='flex animate-pulse items-center gap-2 text-xs text-orange-600'>
<Loader2 className='h-3 w-3 animate-spin' />
Waiting for payment...
</div>
)}
</div>
)}
</div>
);
}
-101
View File
@@ -1,101 +0,0 @@
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>
);
}
-90
View File
@@ -1,90 +0,0 @@
'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 { usePathname, useRouter } from 'next/navigation';
import { adminLogout } from '@/lib/api/services/auth';
import { toast } from 'sonner';
import { ThemeToggle } from '@/components/theme-toggle';
import { CurrencyToggle } from '@/components/currency-toggle';
const PAGE_META: Record<string, { title: string; description: string }> = {
'/': {
title: 'Dashboard',
description: 'Usage, errors, revenue, and system health.',
},
'/balances': {
title: 'Balances',
description: 'Wallet and temporary balance overview.',
},
'/logs': {
title: 'System Logs',
description: 'Inspect request and application logs.',
},
'/model': {
title: 'Models',
description: 'Manage model catalog and provider mappings.',
},
'/providers': {
title: 'Providers',
description: 'Configure upstream providers and model sync.',
},
'/settings': {
title: 'Settings',
description: 'Admin, routing, and system-level configuration.',
},
};
export function SiteHeader() {
const router = useRouter();
const pathname = usePathname();
const meta = PAGE_META[pathname] ?? {
title: 'Routstr Node',
description: 'Administration panel',
};
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='border-border/60 bg-background/80 sticky top-0 z-20 border-b backdrop-blur-xl'>
<div className='flex h-16 items-center justify-between gap-3 px-3 sm:px-5 md:px-6'>
<div className='flex min-w-0 items-center gap-2'>
<SidebarTrigger className='shrink-0 md:-ml-1' />
<Separator orientation='vertical' className='hidden h-5 md:block' />
<div className='min-w-0'>
<h1 className='truncate text-sm font-semibold tracking-tight sm:text-base'>
{meta.title}
</h1>
<p className='text-muted-foreground hidden truncate text-xs sm:block'>
{meta.description}
</p>
</div>
</div>
<div className='flex shrink-0 items-center gap-1.5'>
<CurrencyToggle />
<ThemeToggle />
<Button
variant='outline'
size='sm'
onClick={handleLogout}
className='h-8 gap-2 px-2.5 sm:px-3'
>
<LogOut className='h-4 w-4' />
<span className='hidden sm:inline'>Logout</span>
</Button>
</div>
</div>
</header>
);
}
-112
View File
@@ -1,112 +0,0 @@
'use client';
import * as React from 'react';
import { Avatar as AvatarPrimitive } from 'radix-ui';
import { cn } from '@/lib/utils';
function Avatar({
className,
size = 'default',
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: 'default' | 'sm' | 'lg';
}) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
data-size={size}
className={cn(
'after:border-border group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn(
'aspect-square size-full rounded-full object-cover',
className
)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
className
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='avatar-badge'
className={cn(
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group'
className={cn(
'*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2',
className
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='avatar-group-count'
className={cn(
'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};
-702
View File
@@ -1,702 +0,0 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
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,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { PanelLeftIcon } from 'lucide-react';
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}>
<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>
</SidebarContext.Provider>
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
dir,
...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
dir={dir}
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'
data-side={side}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
// 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]:ring-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1'
>
{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-sm'
className={cn(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 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
'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(
'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 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.Root : '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 group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : '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 group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
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-0', 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(
'ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-md p-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-[active=true]:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button 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',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] 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.Root : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot='sidebar-menu-button'
data-sidebar='menu-button'
data-size={size}
data-active={isActive ? 'true' : undefined}
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.Root : '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 group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded: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 peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-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 group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1',
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
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.Root : 'a';
return (
<Comp
data-slot='sidebar-menu-sub-button'
data-sidebar='menu-sub-button'
data-size={size}
data-active={isActive ? 'true' : undefined}
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 data-[active=true]:bg-sidebar-accent data-[active=true]: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 group-data-[collapsible=icon]:hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
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,
};
-76
View File
@@ -1,76 +0,0 @@
'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>
);
}
-32
View File
@@ -1,32 +0,0 @@
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
@@ -1,82 +0,0 @@
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
>;
-28
View File
@@ -1,28 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,34 +0,0 @@
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>;
-91
View File
@@ -1,91 +0,0 @@
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;
}
}
}
-93
View File
@@ -1,93 +0,0 @@
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[][];
}
interface NostrProvider {
signEvent: (event: NostrEvent) => Promise<NostrEvent>;
}
type WindowWithNostr = Window & {
nostr?: NostrProvider;
};
function getNostrProvider(): NostrProvider {
if (typeof window === 'undefined') {
throw new Error('Nostr provider is unavailable on the server');
}
const windowWithNostr = window as WindowWithNostr;
if (
!windowWithNostr.nostr ||
typeof windowWithNostr.nostr.signEvent !== 'function'
) {
throw new Error('Nostr provider is unavailable');
}
return windowWithNostr.nostr;
}
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 nostr = getNostrProvider();
const auth_event = await 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) {
return response.data as Response;
}
return responseSchema.parse(response.data);
}
return apiCall();
};
}
-60
View File
@@ -1,60 +0,0 @@
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;
}
}
}
-159
View File
@@ -1,159 +0,0 @@
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;
}
-48
View File
@@ -1,48 +0,0 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ModelService } from '../api/services/models';
// Query keys for models
const modelKeys = {
all: ['models'] as const,
list: () => [...modelKeys.all, 'list'] as const,
detail: (id: string) => [...modelKeys.all, 'detail', id] as const,
test: (id: string) => [...modelKeys.all, 'test', id] as const,
};
/**
* Hook to fetch the list of available models
*/
export function useModels(options?: {
team_id?: string;
return_wildcard_routes?: boolean;
enabled?: boolean;
}) {
return useQuery({
queryKey: modelKeys.list(),
queryFn: () => ModelService.listModels(options),
enabled: options?.enabled ?? true,
});
}
/**
* Hook to fetch detailed information about a specific model
*/
export function useModelInfo(modelId: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: modelKeys.detail(modelId),
queryFn: () => ModelService.getModelInfo(modelId),
enabled: !!modelId && (options?.enabled ?? true),
});
}
export function usePrefetchModelInfo() {
// This is only used for this function, not needed in the larger scope
const queryClient = useQueryClient();
return async (modelId: string) => {
await queryClient.prefetchQuery({
queryKey: modelKeys.detail(modelId),
queryFn: () => ModelService.getModelInfo(modelId),
});
};
}
-120
View File
@@ -1,120 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { UserService } from '../api/services/users';
import { CreateUser, UpdateUser } from '../api/schemas/users';
import { toast } from 'sonner';
// Query keys for users
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: { organizationId?: string }) =>
[...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
/**
* Hook to fetch users list with optional organization filter
*/
export function useUsers(filters?: {
organizationId?: string;
enabled?: boolean;
}) {
const { organizationId, enabled } = filters || {};
return useQuery({
queryKey: userKeys.list({ organizationId }),
queryFn: () => UserService.listUsers(organizationId),
enabled: enabled ?? true,
});
}
/**
* Hook to fetch a specific user by ID
*/
export function useUser(userId: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => UserService.getUser(userId),
enabled: !!userId && (options?.enabled ?? true),
});
}
/**
* Hook to create a new user
*/
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userData: CreateUser) => {
return UserService.createUser(userData);
},
onSuccess: (user) => {
toast.success('User created successfully');
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
queryClient.invalidateQueries({
queryKey: userKeys.list({ organizationId: user.organization_id }),
});
},
onError: (error) => {
toast.error(`Failed to create user: ${error.message}`);
},
});
}
/**
* Hook to update an existing user
*/
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
userId,
userData,
}: {
userId: string;
userData: UpdateUser;
}) => {
return UserService.updateUser(userId, userData);
},
onSuccess: (updatedUser) => {
if (updatedUser) {
toast.success('User updated successfully');
queryClient.invalidateQueries({
queryKey: userKeys.detail(updatedUser.id),
});
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
queryClient.invalidateQueries({
queryKey: userKeys.list({
organizationId: updatedUser.organization_id,
}),
});
}
},
onError: (error) => {
toast.error(`Failed to update user: ${error.message}`);
},
});
}
/**
* Hook to delete a user
*/
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: string) => {
return UserService.deleteUser(userId);
},
onSuccess: () => {
toast.success('User deleted successfully');
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
onError: (error) => {
toast.error(`Failed to delete user: ${error.message}`);
},
});
}
-7
View File
@@ -14,13 +14,8 @@
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@@ -44,7 +39,6 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -57,7 +51,6 @@
"next": "16.2.6",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.13.2",
-135
View File
@@ -25,27 +25,12 @@ importers:
'@base-ui/react':
specifier: ^1.2.0
version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/modifiers':
specifier: ^9.0.0
version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-avatar':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -115,9 +100,6 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
axios:
specifier: ^1.16.0
version: 1.18.1
@@ -154,9 +136,6 @@ importers:
qrcode:
specifier: ^1.5.4
version: 1.5.4
qrcode.react:
specifier: ^4.2.0
version: 4.2.0(react@19.2.4)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -372,34 +351,6 @@ packages:
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/modifiers@9.0.0':
resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -797,19 +748,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-avatar@1.1.11':
resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
@@ -1618,17 +1556,6 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -3104,11 +3031,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode.react@4.2.0:
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
@@ -3772,38 +3694,6 @@ snapshots:
'@date-fns/tz@1.4.1': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tslib: 2.8.1
'@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -4143,19 +4033,6 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -4986,14 +4863,6 @@ snapshots:
'@tanstack/query-core': 5.90.20
react: 19.2.4
'@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/table-core@8.21.3': {}
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -6574,10 +6443,6 @@ snapshots:
punycode@2.3.1: {}
qrcode.react@4.2.0(react@19.2.4):
dependencies:
react: 19.2.4
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B