mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 15:56:14 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a68998e8ff | ||
|
|
c5a205cf98 | ||
|
|
62185fbd38 | ||
|
|
bb97e8dedb | ||
|
|
a1c2a785a1 | ||
|
|
750193e99d | ||
|
|
4ee654331f | ||
|
|
b8fcf5bcc8 | ||
|
|
2ce8981f0c | ||
|
|
b76ff62f1c | ||
|
|
73ebfe8975 | ||
|
|
3dae03d731 | ||
|
|
8a1f909083 | ||
|
|
f2a0473fb3 | ||
|
|
33d2ef9ef1 | ||
|
|
f1c3b515fe | ||
|
|
74ddc48ff5 |
@@ -49,55 +49,17 @@ curl https://api.routstr.com/v1/chat/completions \
|
||||
}'
|
||||
```
|
||||
|
||||
## Quick Start (Run a Node)
|
||||
## Quick Start (Docker)
|
||||
|
||||
Start earning Bitcoin by selling AI access in under 5 minutes.
|
||||
|
||||
### 1. Prepare Configuration
|
||||
|
||||
Create a `.env` file:
|
||||
|
||||
```bash
|
||||
# Initial Admin Password
|
||||
ADMIN_PASSWORD=mysecretpassword
|
||||
|
||||
# Node Identity
|
||||
NAME="My AI Node"
|
||||
DESCRIPTION="Fast access to models"
|
||||
|
||||
# Lightning Payouts
|
||||
RECEIVE_LN_ADDRESS=yourname@wallet.com
|
||||
```
|
||||
|
||||
### 2. Start the Node
|
||||
If you are a node runner, start a Routstr Core instance and configure upstream access in the dashboard.
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name routstr \
|
||||
--name routstr-proxy \
|
||||
-p 8000:8000 \
|
||||
--env-file .env \
|
||||
-v routstr-data:/app/data \
|
||||
ghcr.io/routstr/proxy:latest
|
||||
```
|
||||
|
||||
Verify it's running:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/info
|
||||
```
|
||||
|
||||
### 3. Configure via Dashboard
|
||||
|
||||
1. Open **Admin Dashboard** at http://localhost:8000/admin/
|
||||
2. Login with your `ADMIN_PASSWORD`
|
||||
3. Go to **Settings** → **Upstream** - Add your AI provider (OpenAI, Anthropic, OpenRouter, etc.)
|
||||
4. Go to **Settings** → **Pricing** - Set your profit margin (default 10%)
|
||||
5. Go to **Settings** → **Admin** - Set a strong password
|
||||
|
||||
### 4. Start Earning
|
||||
|
||||
Clients pay you in Bitcoin (via Cashu) for every AI request. Monitor earnings and withdraw profits from the dashboard.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "routstr"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -36,9 +36,9 @@ setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if os.getenv("VERSION_SUFFIX") is not None:
|
||||
__version__ = f"0.4.0-{os.getenv('VERSION_SUFFIX')}"
|
||||
__version__ = f"0.4.1-{os.getenv('VERSION_SUFFIX')}"
|
||||
else:
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.4.1"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
+76
-16
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from typing import TypedDict
|
||||
|
||||
@@ -60,6 +59,76 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
|
||||
return token
|
||||
|
||||
|
||||
async def _calculate_swap_amount(
|
||||
amount_msat: int,
|
||||
token_unit: str,
|
||||
token_mint_url: str,
|
||||
token_wallet: Wallet,
|
||||
primary_wallet: Wallet,
|
||||
) -> int:
|
||||
"""
|
||||
Calculate the amount to mint on the primary mint after accounting for
|
||||
potential swap fees (melt fees) on the foreign mint.
|
||||
"""
|
||||
if settings.primary_mint_unit == "sat":
|
||||
receive_amount = amount_msat // 1000
|
||||
else:
|
||||
receive_amount = amount_msat
|
||||
|
||||
if token_mint_url == settings.primary_mint:
|
||||
logger.info(
|
||||
"swap_to_primary_mint: skipping fee estimation (same mint)",
|
||||
extra={"minted_amount": receive_amount},
|
||||
)
|
||||
return int(receive_amount)
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: estimating fees",
|
||||
extra={
|
||||
"dummy_amount": receive_amount,
|
||||
"unit": settings.primary_mint_unit,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
dummy_mint_quote = await primary_wallet.request_mint(receive_amount)
|
||||
dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request)
|
||||
|
||||
fee_reserve = dummy_melt_quote.fee_reserve
|
||||
if token_unit == "sat":
|
||||
fee_msat = fee_reserve * 1000
|
||||
else:
|
||||
fee_msat = fee_reserve
|
||||
|
||||
amount_msat_after_fee = amount_msat - fee_msat
|
||||
|
||||
if settings.primary_mint_unit == "sat":
|
||||
minted_amount = int(amount_msat_after_fee // 1000)
|
||||
else:
|
||||
minted_amount = int(amount_msat_after_fee)
|
||||
|
||||
if minted_amount <= 0:
|
||||
raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount")
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: fee estimation result",
|
||||
extra={
|
||||
"token_amount_sat": amount_msat // 1000,
|
||||
"estimated_fee_sat": fee_msat // 1000,
|
||||
"minted_amount": minted_amount,
|
||||
"minted_unit": settings.primary_mint_unit,
|
||||
},
|
||||
)
|
||||
return minted_amount
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: fee estimation failed",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
raise ValueError(f"Failed to estimate fees: {e}") from e
|
||||
|
||||
|
||||
async def swap_to_primary_mint(
|
||||
token_obj: Token, token_wallet: Wallet
|
||||
) -> tuple[int, str, str]:
|
||||
@@ -84,23 +153,14 @@ async def swap_to_primary_mint(
|
||||
amount_msat = token_amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1
|
||||
amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
|
||||
if settings.primary_mint_unit == "sat":
|
||||
minted_amount = int(amount_msat_after_fee // 1000)
|
||||
else:
|
||||
minted_amount = int(amount_msat_after_fee)
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: fee estimation",
|
||||
extra={
|
||||
"token_amount_sat": amount_msat // 1000,
|
||||
"estimated_fee_sat": estimated_fee_sat,
|
||||
"minted_amount": minted_amount,
|
||||
"minted_unit": settings.primary_mint_unit,
|
||||
},
|
||||
minted_amount = await _calculate_swap_amount(
|
||||
amount_msat,
|
||||
token_obj.unit,
|
||||
token_obj.mint,
|
||||
token_wallet,
|
||||
primary_wallet,
|
||||
)
|
||||
|
||||
mint_quote = await primary_wallet.request_mint(minted_amount)
|
||||
|
||||
@@ -217,3 +217,77 @@ async def test_recieve_token_untrusted_mint() -> None:
|
||||
assert amount == 900
|
||||
assert unit == "sat"
|
||||
assert mint == "http://mint:3338"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_to_primary_mint_success() -> None:
|
||||
"""Test successful swap with dynamic fee calculation."""
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = "http://foreign:3338"
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = 1000
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.proofs = [{"amount": 1000}]
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.load_mint = AsyncMock()
|
||||
mock_primary_wallet.load_proofs = AsyncMock()
|
||||
|
||||
# Mocks for the estimation phase
|
||||
# 1. request_mint(dummy_amount=1000) -> invoice_dummy
|
||||
# 2. melt_quote(invoice_dummy) -> fee=10
|
||||
|
||||
# Mocks for the execution phase
|
||||
# 3. request_mint(minted_amount=990) -> invoice_real
|
||||
# 4. melt_quote(invoice_real) -> amount=990, fee=10
|
||||
# 5. melt() -> success
|
||||
# 6. mint() -> success
|
||||
|
||||
mock_mint_quote_dummy = Mock(quote="dummy_quote", request="lnbc_dummy")
|
||||
mock_mint_quote_real = Mock(quote="real_quote", request="lnbc_real")
|
||||
|
||||
# side_effect for request_mint to return dummy then real
|
||||
mock_primary_wallet.request_mint = AsyncMock(
|
||||
side_effect=[mock_mint_quote_dummy, mock_mint_quote_real]
|
||||
)
|
||||
|
||||
mock_melt_quote_dummy = Mock(amount=1000, fee_reserve=10)
|
||||
mock_melt_quote_real = Mock(amount=990, fee_reserve=10)
|
||||
|
||||
# side_effect for melt_quote
|
||||
mock_token_wallet.melt_quote = AsyncMock(
|
||||
side_effect=[mock_melt_quote_dummy, mock_melt_quote_real]
|
||||
)
|
||||
|
||||
mock_token_wallet.melt = AsyncMock(return_value="melted_proofs")
|
||||
mock_primary_wallet.mint = AsyncMock(return_value="minted_proofs")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
||||
amount, unit, mint = await swap_to_primary_mint(
|
||||
mock_token, mock_token_wallet
|
||||
)
|
||||
|
||||
assert amount == 990 # 1000 - 10
|
||||
assert unit == "sat"
|
||||
assert mint == "http://primary:3338"
|
||||
|
||||
# Verify call order/counts
|
||||
assert mock_primary_wallet.request_mint.call_count == 2
|
||||
# First call with full amount for estimation
|
||||
mock_primary_wallet.request_mint.assert_any_call(1000)
|
||||
# Second call with calculated amount
|
||||
mock_primary_wallet.request_mint.assert_any_call(990)
|
||||
|
||||
assert mock_token_wallet.melt_quote.call_count == 2
|
||||
assert mock_token_wallet.melt.called
|
||||
assert mock_primary_wallet.mint.called
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ModelsPage } from '@/components/models-page';
|
||||
|
||||
export default function ModelPage() {
|
||||
return <ModelsPage />;
|
||||
}
|
||||
+2
-2
@@ -449,10 +449,10 @@ function DashboardInsights({
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_: any, payload: readonly any[]) =>
|
||||
labelFormatter={(_: React.ReactNode, payload) =>
|
||||
String(payload?.[0]?.payload?.type ?? '')
|
||||
}
|
||||
formatter={(value: any, name: any) => {
|
||||
formatter={(value, name) => {
|
||||
const numericValue =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ApiKeyInputProps extends React.ComponentProps<'input'> {
|
||||
onApiKeyChange: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
export function ApiKeyInput({
|
||||
value,
|
||||
onApiKeyChange,
|
||||
...props
|
||||
}: ApiKeyInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || '');
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
onApiKeyChange(internalValue as string);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(handler);
|
||||
}, [internalValue, onApiKeyChange]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={internalValue}
|
||||
onChange={(e) => setInternalValue(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ const NAV_ITEMS = [
|
||||
{ title: 'Dashboard', url: '/', icon: LayoutDashboardIcon },
|
||||
{ title: 'Balances', url: '/balances', icon: WalletIcon },
|
||||
{ title: 'Logs', url: '/logs', icon: FileTextIcon },
|
||||
{ title: 'Models', url: '/models', icon: DatabaseIcon },
|
||||
{ title: 'Models', url: '/model', icon: DatabaseIcon },
|
||||
{ title: 'Providers', url: '/providers', icon: ServerIcon },
|
||||
{ title: 'Transactions', url: '/transactions', icon: ArrowRightLeftIcon },
|
||||
{ title: 'Settings', url: '/settings', icon: SettingsIcon },
|
||||
|
||||
@@ -58,7 +58,7 @@ const data = {
|
||||
},
|
||||
{
|
||||
title: 'Models',
|
||||
url: '/models',
|
||||
url: '/model',
|
||||
icon: DatabaseIcon,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -257,7 +257,7 @@ export function ChartAreaInteractive() {
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value: any) => {
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||
import { WalletService } from '@/lib/api/services/wallet';
|
||||
import { ApiKeyInput } from './api-key-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,17 +16,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Key,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Key, Copy, Check, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { KeyOptions } from './key-options';
|
||||
|
||||
interface KeyConfig {
|
||||
@@ -42,6 +35,14 @@ interface ChildKeyCreatorProps {
|
||||
costPerKeyMsats?: number;
|
||||
}
|
||||
|
||||
function formatSats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
export function ChildKeyCreator({
|
||||
baseUrl,
|
||||
apiKey: propApiKey,
|
||||
@@ -50,6 +51,7 @@ export function ChildKeyCreator({
|
||||
}: ChildKeyCreatorProps) {
|
||||
const [internalApiKey, setInternalApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [configs, setConfigs] = useState<KeyConfig[]>([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
@@ -59,15 +61,15 @@ export function ChildKeyCreator({
|
||||
validityDate: '',
|
||||
},
|
||||
]);
|
||||
const [childKeyToCheck, setChildKeyToCheck] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [keyStatus, setKeyStatus] = useState<{
|
||||
total_spent: number;
|
||||
balance_limit: number | null;
|
||||
validity_date: number | null;
|
||||
is_expired: boolean;
|
||||
is_drained: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const activeApiKey = propApiKey ?? internalApiKey;
|
||||
const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey);
|
||||
|
||||
const handleApiKeyChange = (val: string) => {
|
||||
setInternalApiKey(val);
|
||||
onApiKeyChange?.(val);
|
||||
};
|
||||
|
||||
const [newKeys, setNewKeys] = useState<string[]>([]);
|
||||
const [resultInfo, setResultInfo] = useState<{
|
||||
cost_msats: number;
|
||||
@@ -75,13 +77,6 @@ export function ChildKeyCreator({
|
||||
} | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const activeApiKey = propApiKey ?? internalApiKey;
|
||||
|
||||
const handleApiKeyChange = (val: string) => {
|
||||
setInternalApiKey(val);
|
||||
onApiKeyChange?.(val);
|
||||
};
|
||||
|
||||
const addConfig = () => {
|
||||
setConfigs([
|
||||
...configs,
|
||||
@@ -112,6 +107,7 @@ export function ChildKeyCreator({
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let allNewKeys: string[] = [];
|
||||
let totalCost = 0;
|
||||
@@ -152,55 +148,21 @@ export function ChildKeyCreator({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create child key:', error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create child key'
|
||||
);
|
||||
let errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to create child key';
|
||||
try {
|
||||
const parsed = JSON.parse(errorMessage);
|
||||
errorMessage =
|
||||
parsed.detail?.error?.message ||
|
||||
(typeof parsed.detail === 'string' ? parsed.detail : errorMessage);
|
||||
} catch {}
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckKey = async () => {
|
||||
if (!childKeyToCheck) {
|
||||
toast.error('Please provide a Child API key to check');
|
||||
return;
|
||||
}
|
||||
|
||||
setChecking(true);
|
||||
setKeyStatus(null);
|
||||
try {
|
||||
const baseUrlToUse = baseUrl || '';
|
||||
const response = await fetch(`${baseUrlToUse}/v1/balance/info`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${childKeyToCheck}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch key info');
|
||||
}
|
||||
|
||||
const info = await response.json();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
setKeyStatus({
|
||||
total_spent: info.total_spent,
|
||||
balance_limit: info.balance_limit,
|
||||
validity_date: info.validity_date,
|
||||
is_expired: info.validity_date ? now > info.validity_date : false,
|
||||
is_drained: info.balance_limit
|
||||
? info.total_spent >= info.balance_limit
|
||||
: false,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to check child key'
|
||||
);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
setCopiedKey(key);
|
||||
@@ -243,12 +205,55 @@ export function ChildKeyCreator({
|
||||
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
|
||||
Parent API Key
|
||||
</Label>
|
||||
<Input
|
||||
value={activeApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex-1'>
|
||||
<ApiKeyInput
|
||||
value={activeApiKey}
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => navigator.clipboard.writeText(activeApiKey)}
|
||||
disabled={!activeApiKey}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
{walletInfo && (
|
||||
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -362,11 +367,18 @@ export function ChildKeyCreator({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Each key creation has a small one-time fee.
|
||||
</p>
|
||||
{error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Each key creation has a small one-time fee.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{newKeys.length > 0 && (
|
||||
<div className='mt-6 space-y-4'>
|
||||
@@ -458,89 +470,6 @@ export function ChildKeyCreator({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg'>Check Child Key Status</CardTitle>
|
||||
<CardDescription>
|
||||
View the current spending, limit, and expiration status of any child
|
||||
key.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
|
||||
Child API Key
|
||||
</Label>
|
||||
<Input
|
||||
value={childKeyToCheck}
|
||||
onChange={(e) => setChildKeyToCheck(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCheckKey}
|
||||
disabled={checking || !childKeyToCheck}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{checking ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Check Status
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{keyStatus && (
|
||||
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Total Spent:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{keyStatus.total_spent} mSats
|
||||
</span>
|
||||
</div>
|
||||
{keyStatus.balance_limit !== null && (
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Limit:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{keyStatus.balance_limit} mSats
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{keyStatus.validity_date !== null && (
|
||||
<div className='flex justify-between'>
|
||||
<span className='text-muted-foreground'>Expires:</span>
|
||||
<span className='font-mono font-medium'>
|
||||
{new Date(
|
||||
keyStatus.validity_date * 1000
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex gap-2 pt-2'>
|
||||
{keyStatus.is_drained && (
|
||||
<Badge variant='destructive'>Drained</Badge>
|
||||
)}
|
||||
{keyStatus.is_expired && (
|
||||
<Badge variant='destructive'>Expired</Badge>
|
||||
)}
|
||||
{!keyStatus.is_drained && !keyStatus.is_expired && (
|
||||
<Badge>Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ export function ApiKeyManager({
|
||||
className='gap-2'
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
{isRefunding ? 'Processing...' : 'Refund & Delete Key'}
|
||||
{isRefunding ? 'Processing...' : 'Refund Key'}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
Burns the key and returns a fresh Cashu token.
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
|
||||
import { Copy, RefreshCcw } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { KeyOptions } from '@/components/key-options';
|
||||
import { WalletBalanceStats } from './wallet-balance-stats';
|
||||
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
|
||||
import { ApiKeyInput } from '../api-key-input';
|
||||
import type { WalletSnapshot } from './key-info-details';
|
||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||
|
||||
export type RefundReceipt = {
|
||||
token?: string;
|
||||
@@ -29,80 +30,41 @@ interface CashuPaymentWorkflowProps {
|
||||
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));
|
||||
}
|
||||
|
||||
function formatMsats(msats: number): string {
|
||||
return new Intl.NumberFormat('en-US').format(msats);
|
||||
}
|
||||
|
||||
export function CashuPaymentWorkflow({
|
||||
baseUrl,
|
||||
apiKey = '',
|
||||
walletInfo = null,
|
||||
walletInfo: propWalletInfo = null,
|
||||
onApiKeyCreated,
|
||||
onApiKeyChanged,
|
||||
onWalletInfoUpdated,
|
||||
onRefundComplete,
|
||||
}: CashuPaymentWorkflowProps): JSX.Element {
|
||||
const [initialToken, setInitialToken] = useState('');
|
||||
const [topupToken, setTopupToken] = useState('');
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
const [isCreatingKey, setIsCreatingKey] = useState(false);
|
||||
const [isTopupLoading, setIsTopupLoading] = useState(false);
|
||||
const [isRefunding, setIsRefunding] = useState(false);
|
||||
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
|
||||
const [hasInteractedManage, setHasInteractedManage] = useState(false);
|
||||
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
||||
const [balanceLimit, setBalanceLimit] = useState<string>('');
|
||||
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
|
||||
const [validityDate, setValidityDate] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activeApiKey = apiKeyInput.trim();
|
||||
|
||||
const {
|
||||
data: queryWalletInfo,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useWalletInfo(baseUrl, activeApiKey);
|
||||
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
|
||||
|
||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return;
|
||||
@@ -203,20 +165,18 @@ export function CashuPaymentWorkflow({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncingBalance(true);
|
||||
setError(null);
|
||||
try {
|
||||
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
|
||||
onWalletInfoUpdated?.(snapshot);
|
||||
await refetch();
|
||||
toast.success('Balance synced');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to sync balance'
|
||||
);
|
||||
} finally {
|
||||
setIsSyncingBalance(false);
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to sync balance';
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
}, [activeApiKey, baseUrl, onWalletInfoUpdated]);
|
||||
}, [activeApiKey, refetch]);
|
||||
|
||||
const handleTopup = useCallback(async (): Promise<void> => {
|
||||
if (!activeApiKey) {
|
||||
@@ -245,59 +205,25 @@ export function CashuPaymentWorkflow({
|
||||
const payload = (await response.json()) as { msats: number };
|
||||
toast.success(`Added ${formatSats(payload.msats)} sats`);
|
||||
setTopupToken('');
|
||||
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
|
||||
onApiKeyCreated?.(snapshot.apiKey, snapshot);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Top-up failed');
|
||||
} finally {
|
||||
setIsTopupLoading(false);
|
||||
}
|
||||
}, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]);
|
||||
|
||||
const handleRefund = useCallback(async (): Promise<void> => {
|
||||
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 payload = (await response.json()) as RefundReceipt;
|
||||
onRefundComplete?.(payload);
|
||||
onWalletInfoUpdated?.(null);
|
||||
setApiKeyInput('');
|
||||
toast.success('Refund requested');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Refund failed');
|
||||
} finally {
|
||||
setIsRefunding(false);
|
||||
}
|
||||
}, [activeApiKey, baseUrl, onRefundComplete, onWalletInfoUpdated]);
|
||||
}, [activeApiKey, baseUrl, topupToken, refetch]);
|
||||
|
||||
const handleApiKeyChange = useCallback(
|
||||
(newKey: string) => {
|
||||
setApiKeyInput(newKey);
|
||||
onApiKeyChanged?.(newKey);
|
||||
if (newKey !== apiKey) {
|
||||
onWalletInfoUpdated?.(null);
|
||||
}
|
||||
},
|
||||
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
|
||||
[apiKey, onWalletInfoUpdated]
|
||||
);
|
||||
|
||||
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
|
||||
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
|
||||
const canTopup = Boolean(activeApiKey);
|
||||
const showCreateDetails = initialToken.trim().length > 0;
|
||||
@@ -365,40 +291,72 @@ export function CashuPaymentWorkflow({
|
||||
)}
|
||||
</header>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
<ApiKeyInput
|
||||
value={apiKeyInput}
|
||||
onChange={(event) => handleApiKeyChange(event.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
onFocus={() => setHasInteractedManage(true)}
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
/>
|
||||
<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}
|
||||
disabled={isFetching || !activeApiKey}
|
||||
>
|
||||
<RefreshCcw className='h-4 w-4' />
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Sync
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{showManageDetails && (
|
||||
<WalletBalanceStats
|
||||
balanceMsats={walletInfo?.balanceMsats}
|
||||
reservedMsats={walletInfo?.reservedMsats}
|
||||
/>
|
||||
{walletInfo && (
|
||||
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant='destructive' className='mt-2'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -444,28 +402,6 @@ export function CashuPaymentWorkflow({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className='space-y-2'>
|
||||
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
|
||||
<span>4 · Refund</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 remaining balance'}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
Burns the key and returns a fresh Cashu token.
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type RefundReceipt,
|
||||
} from './cashu-payment-workflow';
|
||||
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
||||
import { ApiKeyManager } from './api-key-manager';
|
||||
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
|
||||
import { ChildKeyCreator } from '@/components/child-key-creator';
|
||||
|
||||
@@ -135,8 +134,8 @@ export function CheatSheet(): JSX.Element {
|
||||
|
||||
const handleRefundComplete = useCallback((receipt: RefundReceipt) => {
|
||||
setRefundReceipt(receipt);
|
||||
setWalletInfo(null);
|
||||
setApiKeyInput('');
|
||||
// setWalletInfo(null); // Keep info
|
||||
// setApiKeyInput(''); // Keep input
|
||||
}, []);
|
||||
|
||||
const handleRefreshInfo = useCallback(async (): Promise<void> => {
|
||||
@@ -391,12 +390,11 @@ export function CheatSheet(): JSX.Element {
|
||||
</section>
|
||||
|
||||
<Tabs defaultValue='cashu' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-5'>
|
||||
<TabsList className='grid w-full grid-cols-4'>
|
||||
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
|
||||
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
|
||||
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
||||
<TabsTrigger value='details'>Key Details</TabsTrigger>
|
||||
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
|
||||
<TabsTrigger value='management'>Key Management</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='cashu' className='space-y-4'>
|
||||
@@ -413,8 +411,8 @@ export function CheatSheet(): JSX.Element {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='manage' className='space-y-4'>
|
||||
<ApiKeyManager
|
||||
<TabsContent value='management' className='space-y-4'>
|
||||
<KeyInfoDetails
|
||||
baseUrl={normalizedBaseUrl}
|
||||
apiKey={apiKeyInput}
|
||||
walletInfo={walletInfo}
|
||||
@@ -445,7 +443,7 @@ export function CheatSheet(): JSX.Element {
|
||||
<Textarea
|
||||
value={refundToken}
|
||||
readOnly
|
||||
rows={4}
|
||||
rows={15}
|
||||
className='font-mono text-xs'
|
||||
/>
|
||||
</div>
|
||||
@@ -454,16 +452,6 @@ export function CheatSheet(): JSX.Element {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='details' className='space-y-4'>
|
||||
<KeyInfoDetails
|
||||
baseUrl={normalizedBaseUrl}
|
||||
apiKey={apiKeyInput}
|
||||
walletInfo={walletInfo}
|
||||
onApiKeyChanged={handleApiKeyChanged}
|
||||
onWalletInfoUpdated={handleWalletInfoUpdated}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='child-keys' className='space-y-4'>
|
||||
<ChildKeyCreator
|
||||
baseUrl={normalizedBaseUrl}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useState, useCallback, useEffect } from 'react';
|
||||
import { Copy, RefreshCcw, RotateCcw } from 'lucide-react';
|
||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||
import { toast } from 'sonner';
|
||||
import { ApiKeyInput } from '../api-key-input';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -12,8 +14,9 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { WalletService } from '@/lib/api/services/wallet';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react';
|
||||
|
||||
export type ChildKeyInfo = {
|
||||
api_key: string;
|
||||
@@ -44,68 +47,44 @@ interface KeyInfoDetailsProps {
|
||||
walletInfo?: WalletSnapshot | null;
|
||||
onApiKeyChanged?: (apiKey: string) => void;
|
||||
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
|
||||
onRefundComplete?: (receipt: RefundReceipt) => void;
|
||||
}
|
||||
|
||||
export function KeyInfoDetails({
|
||||
baseUrl,
|
||||
apiKey = '',
|
||||
walletInfo = null,
|
||||
walletInfo: propWalletInfo = null,
|
||||
onApiKeyChanged,
|
||||
onWalletInfoUpdated,
|
||||
}: KeyInfoDetailsProps): JSX.Element {
|
||||
onRefundComplete,
|
||||
}: KeyInfoDetailsProps): React.ReactNode {
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState<string | null>(null);
|
||||
const [isRefunding, setIsRefunding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: queryWalletInfo,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useWalletInfo(baseUrl, apiKeyInput);
|
||||
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
|
||||
|
||||
// Sync internal state with props if they change
|
||||
useEffect(() => {
|
||||
setApiKeyInput(apiKey);
|
||||
}, [apiKey]);
|
||||
|
||||
const fetchDetails = useCallback(
|
||||
async (keyToFetch: string) => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||
headers: { Authorization: `Bearer ${keyToFetch}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch key info');
|
||||
}
|
||||
const payload = await response.json();
|
||||
const snapshot: WalletSnapshot = {
|
||||
apiKey: payload.api_key || keyToFetch,
|
||||
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,
|
||||
};
|
||||
onWalletInfoUpdated?.(snapshot);
|
||||
toast.success('Key details synced');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to fetch details'
|
||||
);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
[baseUrl, onWalletInfoUpdated]
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const handleRefresh = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!apiKeyInput) return;
|
||||
await fetchDetails(apiKeyInput);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const handleKeyChange = (newKey: string) => {
|
||||
setApiKeyInput(newKey);
|
||||
setError(null);
|
||||
onApiKeyChanged?.(newKey);
|
||||
// Optionally clear info when key changes
|
||||
if (newKey !== apiKey) {
|
||||
@@ -125,7 +104,7 @@ export function KeyInfoDetails({
|
||||
try {
|
||||
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
|
||||
toast.success('Child key spent reset');
|
||||
await fetchDetails(apiKeyInput);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to reset child key'
|
||||
@@ -135,6 +114,36 @@ export function KeyInfoDetails({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefund = useCallback(async (): Promise<void> => {
|
||||
if (!apiKeyInput) {
|
||||
toast.error('Paste an API key first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefunding(true);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKeyInput}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Refund failed');
|
||||
}
|
||||
const receipt = (await response.json()) as RefundReceipt;
|
||||
onRefundComplete?.(receipt);
|
||||
toast.success('Refund completed');
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(error instanceof Error ? error.message : 'Refund failed');
|
||||
} finally {
|
||||
setIsRefunding(false);
|
||||
}
|
||||
}, [apiKeyInput, baseUrl, onRefundComplete, refetch]);
|
||||
|
||||
const formatSats = (msats: number) =>
|
||||
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
|
||||
const formatMsats = (msats: number) =>
|
||||
@@ -153,17 +162,11 @@ export function KeyInfoDetails({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<Input
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder='sk-...'
|
||||
className='font-mono text-sm'
|
||||
/>
|
||||
<ApiKeyInput value={apiKeyInput} onApiKeyChange={handleKeyChange} />
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-10 w-10 shrink-0'
|
||||
onClick={() => handleCopy(apiKeyInput)}
|
||||
disabled={!apiKeyInput}
|
||||
>
|
||||
@@ -174,15 +177,22 @@ export function KeyInfoDetails({
|
||||
size='sm'
|
||||
className='min-w-[80px] gap-1'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || !apiKeyInput}
|
||||
disabled={isFetching || !apiKeyInput}
|
||||
type='button'
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
className={`h-8 w-4 ${isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isRefreshing ? 'Syncing...' : 'Sync'}
|
||||
{isFetching ? 'Syncing...' : 'Sync'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant='destructive' className='mt-2'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -228,6 +238,14 @@ export function KeyInfoDetails({
|
||||
{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
|
||||
@@ -236,14 +254,6 @@ export function KeyInfoDetails({
|
||||
{formatSats(walletInfo.balanceMsats)} sats
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-lg'>Consumption</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Total Requests
|
||||
@@ -390,18 +400,16 @@ export function KeyInfoDetails({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<div className='flex justify-center gap-4'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleRefund}
|
||||
disabled={isRefunding || !apiKeyInput}
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className='text-muted-foreground'
|
||||
className='gap-2'
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Last synced: {new Date().toLocaleTimeString()}
|
||||
<Trash2 className='h-4 w-4' />
|
||||
{isRefunding ? 'Processing...' : 'Refund Key'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
'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'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>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
export default function ModelsPage() {
|
||||
export function ModelsPage() {
|
||||
const [filteredModels, setFilteredModels] = useState<Model[] | undefined>(
|
||||
undefined
|
||||
);
|
||||
@@ -23,7 +23,7 @@ const PAGE_META: Record<string, { title: string; description: string }> = {
|
||||
title: 'System Logs',
|
||||
description: 'Inspect request and application logs.',
|
||||
},
|
||||
'/models': {
|
||||
'/model': {
|
||||
title: 'Models',
|
||||
description: 'Manage model catalog and provider mappings.',
|
||||
},
|
||||
|
||||
+6
-25
@@ -1,26 +1,13 @@
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
||||
import nextPlugin from '@next/eslint-plugin-next';
|
||||
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTypescript from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = [
|
||||
...nextCoreWebVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooksPlugin,
|
||||
react: reactPlugin,
|
||||
jsxA11y: jsxA11yPlugin,
|
||||
'@next/next': nextPlugin,
|
||||
},
|
||||
rules: {
|
||||
...reactHooksPlugin.configs.recommended.rules,
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...jsxA11yPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
'react/no-unknown-property': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
'@next/next/no-img-element': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/incompatible-library': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
@@ -28,12 +15,6 @@ const eslintConfig = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/incompatible-library': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { WalletSnapshot } from '@/components/landing/key-info-details';
|
||||
|
||||
export function useWalletInfo(baseUrl: string, apiKey: string) {
|
||||
return useQuery({
|
||||
queryKey: ['walletInfo', baseUrl, apiKey],
|
||||
queryFn: async (): Promise<WalletSnapshot> => {
|
||||
const response = await fetch(`${baseUrl}/v1/balance/info`, {
|
||||
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();
|
||||
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,
|
||||
};
|
||||
},
|
||||
enabled: !!baseUrl && !!apiKey,
|
||||
staleTime: 5000, // Consider data stale after 5 seconds
|
||||
});
|
||||
}
|
||||
@@ -84,10 +84,6 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"@next/eslint-plugin-next": "^16.1.6",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.0",
|
||||
|
||||
Generated
+2
-60
@@ -186,9 +186,6 @@ importers:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
'@next/eslint-plugin-next':
|
||||
specifier: ^16.1.6
|
||||
version: 16.1.6
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.1
|
||||
@@ -207,9 +204,6 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^8.0.0
|
||||
version: 8.57.0(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint:
|
||||
specifier: ^9.7.0
|
||||
version: 9.38.0(jiti@2.6.1)
|
||||
@@ -219,18 +213,12 @@ importers:
|
||||
eslint-config-prettier:
|
||||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y:
|
||||
specifier: ^6.10.0
|
||||
version: 6.10.2(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-plugin-prettier:
|
||||
specifier: ^5.5.5
|
||||
version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.38.0(jiti@2.6.1)))(eslint@9.38.0(jiti@2.6.1))(prettier@3.8.1)
|
||||
eslint-plugin-react:
|
||||
specifier: ^7.37.5
|
||||
version: 7.37.5(eslint@9.38.0(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(eslint@9.38.0(jiti@2.6.1))
|
||||
prettier:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
@@ -486,105 +474,89 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -651,28 +623,24 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.6':
|
||||
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
|
||||
@@ -1544,28 +1512,24 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
||||
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
||||
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
||||
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
||||
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
||||
@@ -1780,49 +1744,41 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -2308,12 +2264,6 @@ packages:
|
||||
eslint-config-prettier:
|
||||
optional: true
|
||||
|
||||
eslint-plugin-react-hooks@5.2.0:
|
||||
resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
|
||||
|
||||
eslint-plugin-react-hooks@7.0.1:
|
||||
resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2789,28 +2739,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.31.1:
|
||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.31.1:
|
||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||
@@ -5218,7 +5164,7 @@ snapshots:
|
||||
call-bind: 1.0.8
|
||||
call-bound: 1.0.4
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.24.1
|
||||
es-abstract: 1.24.0
|
||||
es-object-atoms: 1.1.1
|
||||
get-intrinsic: 1.3.0
|
||||
is-string: 1.1.1
|
||||
@@ -5254,7 +5200,7 @@ snapshots:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
define-properties: 1.2.1
|
||||
es-abstract: 1.24.1
|
||||
es-abstract: 1.24.0
|
||||
es-shim-unscopables: 1.1.0
|
||||
|
||||
array.prototype.tosorted@1.1.4:
|
||||
@@ -5808,10 +5754,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
eslint-config-prettier: 10.1.8(eslint@9.38.0(jiti@2.6.1))
|
||||
|
||||
eslint-plugin-react-hooks@5.2.0(eslint@9.38.0(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.38.0(jiti@2.6.1)
|
||||
|
||||
eslint-plugin-react-hooks@7.0.1(eslint@9.38.0(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
Reference in New Issue
Block a user