improve ui

This commit is contained in:
9qeklajc
2026-03-09 22:41:38 +01:00
parent 4643aefb22
commit ce5fa136c2
3 changed files with 334 additions and 498 deletions
+27 -498
View File
@@ -53,167 +53,14 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RoutstrProviderCard } from '@/components/providers/RoutstrProviderCard';
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
import { SimpleLightningTopup } from '@/components/providers/SimpleLightningTopup';
import { SimpleCashuTopup } from '@/components/providers/SimpleCashuTopup';
import { RoutstrNodeSettings } from '@/components/providers/RoutstrNodeSettings';
import { ProviderBalance } from '@/components/providers/ProviderBalance';
import { useState } from 'react';
import { toast } from 'sonner';
function ProviderBalance({
providerId,
platformUrl,
isRoutstr = false,
nodeUrl,
}: {
providerId: number;
platformUrl?: string | null;
isRoutstr?: boolean;
nodeUrl?: string;
}) {
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') {
// Legacy support for object response
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-[90vh] 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>
</>
);
}
export default function ProvidersPage() {
const queryClient = useQueryClient();
const [editingProvider, setEditingProvider] =
@@ -501,6 +348,8 @@ export default function ProvidersPage() {
setBatchOverrideProviderId(providerId);
};
const availableMints = (globalSettings?.cashu_mints as string[]) || [];
return (
<SidebarProvider>
<AppSidebar variant='inset' />
@@ -527,7 +376,7 @@ export default function ProvidersPage() {
Add Provider
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[500px]'>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>Add Upstream Provider</DialogTitle>
<DialogDescription>
@@ -561,184 +410,16 @@ export default function ProvidersPage() {
</Select>
</div>
{formData.provider_type === 'routstr' && (
<div className='bg-muted/30 grid gap-4 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>
Routstr Node Settings
</Label>
<Badge variant='outline' className='text-[10px]'>
JSONB Storage
</Badge>
</div>
<div className='grid gap-3'>
<div className='grid gap-2'>
<Label
htmlFor='edit_topup_mint_url'
className='text-xs'
>
Top-up Mint
</Label>
<Select
value={
formData.provider_settings?.topup_mint_url || ''
}
onValueChange={(value) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_mint_url: value,
},
})
}
>
<SelectTrigger
id='edit_topup_mint_url'
className='h-8 text-xs'
>
<SelectValue placeholder='Select a mint from your node configuration' />
</SelectTrigger>
<SelectContent>
{(
globalSettings?.cashu_mints as
| string[]
| undefined
)?.map((mint: string) => (
<SelectItem
key={mint}
value={mint}
className='text-xs'
>
{mint}
</SelectItem>
))}
{(!(globalSettings?.cashu_mints as string[]) ||
(globalSettings?.cashu_mints as string[])
.length === 0) && (
<SelectItem
value='none'
disabled
className='text-xs'
>
No mints configured in global settings
</SelectItem>
)}
</SelectContent>
</Select>
<p className='text-muted-foreground text-[10px]'>
The token for top-up will be created from this
mint.
</p>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='auto_topup' className='text-sm'>
Enable Auto Top-up
</Label>
<Switch
id='auto_topup'
checked={!!formData.provider_settings?.auto_topup}
onCheckedChange={(checked) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
auto_topup: checked,
},
})
}
/>
</div>
{formData.provider_settings?.auto_topup && (
<div className='border-primary/20 grid gap-4 border-l-2 pt-2 pl-4'>
<div className='grid gap-2'>
<Label
htmlFor='topup_threshold'
className='text-xs font-medium'
>
When credits are below (Sats)
</Label>
<Input
id='topup_threshold'
type='number'
className='h-9'
placeholder='e.g. 1000'
value={
formData.provider_settings
?.topup_threshold || ''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_threshold: parseInt(
e.target.value
),
},
})
}
/>
</div>
<div className='grid gap-2'>
<Label
htmlFor='topup_amount_limit'
className='text-xs font-medium'
>
Purchase this amount (Sats)
</Label>
<Input
id='topup_amount_limit'
type='number'
className='h-9'
placeholder='e.g. 5000'
value={
formData.provider_settings
?.topup_amount_limit || ''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_amount_limit: parseInt(
e.target.value
),
},
})
}
/>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<Label
htmlFor='refund_on_expiry'
className='text-xs'
>
Auto-Refund Expired Keys
</Label>
<Switch
id='refund_on_expiry'
checked={
!!formData.provider_settings?.refund_on_expiry
}
onCheckedChange={(checked) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
refund_on_expiry: checked,
},
})
}
/>
</div>
</div>
</div>
<RoutstrNodeSettings
settings={formData.provider_settings || {}}
onSettingsChange={(settings) =>
setFormData((prev) => ({
...prev,
provider_settings: settings,
}))
}
availableMints={availableMints}
/>
)}
<div className='grid gap-2'>
<Label htmlFor='base_url'>Base URL</Label>
@@ -1243,7 +924,7 @@ export default function ProvidersPage() {
</div>
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className='sm:max-w-[500px]'>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>Edit Upstream Provider</DialogTitle>
<DialogDescription>
@@ -1277,169 +958,17 @@ export default function ProvidersPage() {
</Select>
</div>
{formData.provider_type === 'routstr' && (
<div className='bg-muted/30 grid gap-4 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>
Routstr Node Settings
</Label>
<Badge variant='outline' className='text-[10px]'>
JSONB Storage
</Badge>
</div>
<div className='grid gap-3'>
<div className='grid gap-2'>
<Label htmlFor='edit_topup_mint_url' className='text-xs'>
Top-up Mint
</Label>
<Select
value={formData.provider_settings?.topup_mint_url || ''}
onValueChange={(value) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_mint_url: value,
},
})
}
>
<SelectTrigger
id='edit_topup_mint_url'
className='h-8 text-xs'
>
<SelectValue placeholder='Select a mint from your node configuration' />
</SelectTrigger>
<SelectContent>
{(
globalSettings?.cashu_mints as string[] | undefined
)?.map((mint: string) => (
<SelectItem
key={mint}
value={mint}
className='text-xs'
>
{mint}
</SelectItem>
))}
{(!(globalSettings?.cashu_mints as string[]) ||
(globalSettings?.cashu_mints as string[]).length ===
0) && (
<SelectItem
value='none'
disabled
className='text-xs'
>
No mints configured in global settings
</SelectItem>
)}
</SelectContent>
</Select>
<p className='text-muted-foreground text-[10px]'>
The token for top-up will be created from this mint.
</p>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='edit_auto_topup' className='text-sm'>
Enable Auto Top-up
</Label>
<Switch
id='edit_auto_topup'
checked={!!formData.provider_settings?.auto_topup}
onCheckedChange={(checked) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
auto_topup: checked,
},
})
}
/>
</div>
{formData.provider_settings?.auto_topup && (
<div className='border-primary/20 grid gap-4 border-l-2 pt-2 pl-4'>
<div className='grid gap-2'>
<Label
htmlFor='edit_topup_threshold'
className='text-xs font-medium'
>
When credits are below (Sats)
</Label>
<Input
id='edit_topup_threshold'
type='number'
className='h-9'
placeholder='e.g. 1000'
value={
formData.provider_settings?.topup_threshold || ''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_threshold: parseInt(e.target.value),
},
})
}
/>
</div>
<div className='grid gap-2'>
<Label
htmlFor='edit_topup_amount_limit'
className='text-xs font-medium'
>
Purchase this amount (Sats)
</Label>
<Input
id='edit_topup_amount_limit'
type='number'
className='h-9'
placeholder='e.g. 5000'
value={
formData.provider_settings?.topup_amount_limit ||
''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
topup_amount_limit: parseInt(e.target.value),
},
})
}
/>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<Label
htmlFor='edit_refund_on_expiry'
className='text-xs'
>
Auto-Refund Expired Keys
</Label>
<Switch
id='edit_refund_on_expiry'
checked={!!formData.provider_settings?.refund_on_expiry}
onCheckedChange={(checked) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
refund_on_expiry: checked,
},
})
}
/>
</div>
</div>
</div>
<RoutstrNodeSettings
settings={formData.provider_settings || {}}
onSettingsChange={(settings) =>
setFormData((prev) => ({
...prev,
provider_settings: settings,
}))
}
availableMints={availableMints}
idPrefix='edit'
/>
)}
<div className='grid gap-2'>
<Label htmlFor='edit_base_url'>Base URL</Label>
+172
View File
@@ -0,0 +1,172 @@
'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-[90vh] 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>
</>
);
}
@@ -0,0 +1,135 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
interface ProviderSettings {
topup_mint_url?: string;
auto_topup?: boolean;
topup_threshold?: number;
topup_amount_limit?: number;
refund_on_expiry?: boolean;
[key: string]: unknown;
}
interface RoutstrNodeSettingsProps {
settings: ProviderSettings;
onSettingsChange: (settings: ProviderSettings) => void;
availableMints: string[];
idPrefix?: string;
}
export function RoutstrNodeSettings({
settings,
onSettingsChange,
availableMints,
idPrefix = '',
}: RoutstrNodeSettingsProps) {
const prefix = idPrefix ? `${idPrefix}_` : '';
const update = (patch: Partial<ProviderSettings>) => {
onSettingsChange({ ...settings, ...patch });
};
return (
<div className='bg-muted/30 grid gap-4 rounded-lg border p-4'>
<Label className='text-sm font-semibold'>Routstr Node Settings</Label>
<div className='grid gap-3'>
<div className='grid gap-2'>
<Label htmlFor={`${prefix}topup_mint_url`} className='text-xs'>
Top-up Mint
</Label>
<Select
value={settings.topup_mint_url || ''}
onValueChange={(value) => update({ topup_mint_url: value })}
>
<SelectTrigger
id={`${prefix}topup_mint_url`}
className='h-8 text-xs'
>
<SelectValue placeholder='Select a mint from your node configuration' />
</SelectTrigger>
<SelectContent>
{availableMints.length > 0 ? (
availableMints.map((mint) => (
<SelectItem key={mint} value={mint} className='text-xs'>
{mint}
</SelectItem>
))
) : (
<SelectItem value='none' disabled className='text-xs'>
No mints configured in global settings
</SelectItem>
)}
</SelectContent>
</Select>
<p className='text-muted-foreground text-[10px]'>
The token for top-up will be created from this mint.
</p>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor={`${prefix}auto_topup`} className='text-sm'>
Enable Auto Top-up
</Label>
<Switch
id={`${prefix}auto_topup`}
checked={!!settings.auto_topup}
onCheckedChange={(checked) => update({ auto_topup: checked })}
/>
</div>
{settings.auto_topup && (
<div className='border-primary/20 grid gap-4 border-l-2 pt-2 pl-4'>
<div className='grid gap-2'>
<Label
htmlFor={`${prefix}topup_threshold`}
className='text-xs font-medium'
>
When credits are below (Sats)
</Label>
<Input
id={`${prefix}topup_threshold`}
type='number'
className='h-9'
placeholder='e.g. 1000'
value={settings.topup_threshold || ''}
onChange={(e) =>
update({ topup_threshold: parseInt(e.target.value) })
}
/>
</div>
<div className='grid gap-2'>
<Label
htmlFor={`${prefix}topup_amount_limit`}
className='text-xs font-medium'
>
Purchase this amount (Sats)
</Label>
<Input
id={`${prefix}topup_amount_limit`}
type='number'
className='h-9'
placeholder='e.g. 5000'
value={settings.topup_amount_limit || ''}
onChange={(e) =>
update({ topup_amount_limit: parseInt(e.target.value) })
}
/>
</div>
</div>
)}
</div>
</div>
);
}