mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Merge pull request #656 from ashen0x/ui/copy-to-clipboard-hook
fix: one clipboard hook that only claims success when the copy landed
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Copy, Check } from 'lucide-react';
|
import { Copy, Check } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
|
import { getLogLevelBadgeVariant } from '@/lib/utils/log-level';
|
||||||
import type { LogEntry } from './types';
|
import type { LogEntry } from './types';
|
||||||
|
|
||||||
@@ -24,16 +24,12 @@ export function LogDetailsDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
}: LogDetailsDialogProps) {
|
}: LogDetailsDialogProps) {
|
||||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
const { copiedKey: copiedField, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
if (!log) return null;
|
if (!log) return null;
|
||||||
|
|
||||||
const copyToClipboard = (text: string, fieldName?: string) => {
|
const copyToClipboard = (text: string, fieldName: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
void copy(text, fieldName);
|
||||||
if (fieldName) {
|
|
||||||
setCopiedField(fieldName);
|
|
||||||
setTimeout(() => setCopiedField(null), 2000);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const allFields = Object.keys(log).filter((key) => key !== 'key');
|
const allFields = Object.keys(log).filter((key) => key !== 'key');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { AppPageShell } from '@/components/app-page-shell';
|
import { AppPageShell } from '@/components/app-page-shell';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import {
|
import {
|
||||||
@@ -374,7 +375,7 @@ export default function TransactionsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [type, setType] = useState<string>('all');
|
const [type, setType] = useState<string>('all');
|
||||||
const [status, setStatus] = useState<string>('all');
|
const [status, setStatus] = useState<string>('all');
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const { copiedKey: copiedId, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
// Load filters from localStorage on mount
|
// Load filters from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -482,11 +483,10 @@ export default function TransactionsPage() {
|
|||||||
setLightningPage(0);
|
setLightningPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = (text: string, id: string) => {
|
const copyToClipboard = async (text: string, id: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
if (await copy(text, id)) {
|
||||||
setCopiedId(id);
|
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
setTimeout(() => setCopiedId(null), 2000);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (tx: Transaction) => {
|
const getStatusBadge = (tx: Transaction) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -107,7 +108,7 @@ export function AddProviderModelDialog({
|
|||||||
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||||
const [selectedPresetLabel, setSelectedPresetLabel] =
|
const [selectedPresetLabel, setSelectedPresetLabel] =
|
||||||
useState('Select a preset');
|
useState('Select a preset');
|
||||||
const [forwardedModelIdCopied, setForwardedModelIdCopied] = useState(false);
|
const { copied: forwardedModelIdCopied, copy } = useCopyToClipboard(1500);
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(FormSchema) as never,
|
resolver: zodResolver(FormSchema) as never,
|
||||||
@@ -546,9 +547,7 @@ export function AddProviderModelDialog({
|
|||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
const value = field.value || form.getValues('id');
|
const value = field.value || form.getValues('id');
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
navigator.clipboard.writeText(value);
|
void copy(value);
|
||||||
setForwardedModelIdCopied(true);
|
|
||||||
setTimeout(() => setForwardedModelIdCopied(false), 1500);
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||||
import { WalletService } from '@/lib/api/services/wallet';
|
import { WalletService } from '@/lib/api/services/wallet';
|
||||||
import { ApiKeyInput } from './api-key-input';
|
import { ApiKeyInput } from './api-key-input';
|
||||||
@@ -75,7 +76,7 @@ export function ChildKeyCreator({
|
|||||||
cost_msats: number;
|
cost_msats: number;
|
||||||
parent_balance: number;
|
parent_balance: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
const { copiedKey, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
const addConfig = () => {
|
const addConfig = () => {
|
||||||
setConfigs([
|
setConfigs([
|
||||||
@@ -163,16 +164,16 @@ export function ChildKeyCreator({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = (key: string) => {
|
const copyToClipboard = async (key: string) => {
|
||||||
navigator.clipboard.writeText(key);
|
if (await copy(key, key)) {
|
||||||
setCopiedKey(key);
|
|
||||||
toast.success('API key copied to clipboard');
|
toast.success('API key copied to clipboard');
|
||||||
setTimeout(() => setCopiedKey(null), 2000);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyAllToClipboard = () => {
|
const copyAllToClipboard = async () => {
|
||||||
navigator.clipboard.writeText(newKeys.join('\n'));
|
if (await copy(newKeys.join('\n'), 'all')) {
|
||||||
toast.success('All API keys copied to clipboard');
|
toast.success('All API keys copied to clipboard');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -215,10 +216,15 @@ export function ChildKeyCreator({
|
|||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='icon'
|
size='icon'
|
||||||
onClick={() => navigator.clipboard.writeText(activeApiKey)}
|
aria-label='Copy parent API key'
|
||||||
|
onClick={() => copyToClipboard(activeApiKey)}
|
||||||
disabled={!activeApiKey}
|
disabled={!activeApiKey}
|
||||||
>
|
>
|
||||||
|
{copiedKey === activeApiKey ? (
|
||||||
|
<Check className='h-4 w-4' />
|
||||||
|
) : (
|
||||||
<Copy className='h-4 w-4' />
|
<Copy className='h-4 w-4' />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{walletInfo && (
|
{walletInfo && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type JSX, useCallback, useState } from 'react';
|
import { type JSX, useCallback, useState } from 'react';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { Copy, RefreshCcw } from 'lucide-react';
|
import { Copy, RefreshCcw } from 'lucide-react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -45,6 +46,7 @@ export function CashuPaymentWorkflow({
|
|||||||
onApiKeyCreated,
|
onApiKeyCreated,
|
||||||
onWalletInfoUpdated,
|
onWalletInfoUpdated,
|
||||||
}: CashuPaymentWorkflowProps): JSX.Element {
|
}: CashuPaymentWorkflowProps): JSX.Element {
|
||||||
|
const { copy } = useCopyToClipboard();
|
||||||
const [initialToken, setInitialToken] = useState('');
|
const [initialToken, setInitialToken] = useState('');
|
||||||
const [topupToken, setTopupToken] = useState('');
|
const [topupToken, setTopupToken] = useState('');
|
||||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||||
@@ -65,22 +67,17 @@ export function CashuPaymentWorkflow({
|
|||||||
} = useWalletInfo(baseUrl, activeApiKey);
|
} = useWalletInfo(baseUrl, activeApiKey);
|
||||||
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
|
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
|
||||||
|
|
||||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
const handleCopy = useCallback(
|
||||||
|
async (value: string): Promise<void> => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
if (await copy(value)) {
|
||||||
toast.error('Clipboard API unavailable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Unable to copy');
|
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[copy]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateKey = useCallback(async (): Promise<void> => {
|
const handleCreateKey = useCallback(async (): Promise<void> => {
|
||||||
if (!initialToken.trim()) {
|
if (!initialToken.trim()) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
|
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
|
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -65,6 +66,7 @@ function normalizeBaseUrl(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CheatSheet(): JSX.Element {
|
export function CheatSheet(): JSX.Element {
|
||||||
|
const { copy } = useCopyToClipboard();
|
||||||
const [baseUrl, setBaseUrl] = useState(() =>
|
const [baseUrl, setBaseUrl] = useState(() =>
|
||||||
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
|
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
|
||||||
);
|
);
|
||||||
@@ -98,22 +100,17 @@ export function CheatSheet(): JSX.Element {
|
|||||||
staleTime: 120_000,
|
staleTime: 120_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
const handleCopy = useCallback(
|
||||||
|
async (value: string): Promise<void> => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
if (await copy(value)) {
|
||||||
toast.error('Clipboard API unavailable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Unable to copy');
|
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[copy]
|
||||||
|
);
|
||||||
|
|
||||||
const handleApiKeyCreated = useCallback(
|
const handleApiKeyCreated = useCallback(
|
||||||
(apiKey: string, snapshot: WalletSnapshot) => {
|
(apiKey: string, snapshot: WalletSnapshot) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -58,6 +59,7 @@ export function KeyInfoDetails({
|
|||||||
onWalletInfoUpdated,
|
onWalletInfoUpdated,
|
||||||
onRefundComplete,
|
onRefundComplete,
|
||||||
}: KeyInfoDetailsProps): React.ReactNode {
|
}: KeyInfoDetailsProps): React.ReactNode {
|
||||||
|
const { copy } = useCopyToClipboard();
|
||||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||||
const [isResetting, setIsResetting] = useState<string | null>(null);
|
const [isResetting, setIsResetting] = useState<string | null>(null);
|
||||||
const [isRefunding, setIsRefunding] = useState(false);
|
const [isRefunding, setIsRefunding] = useState(false);
|
||||||
@@ -92,9 +94,10 @@ export function KeyInfoDetails({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (value: string) => {
|
const handleCopy = async (value: string) => {
|
||||||
navigator.clipboard.writeText(value);
|
if (await copy(value)) {
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetSpent = async (childKey: string) => {
|
const handleResetSpent = async (childKey: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type JSX, useCallback, useState } from 'react';
|
import { type JSX, useCallback, useState } from 'react';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Copy, CheckCircle, Loader2 } from 'lucide-react';
|
import { Copy, CheckCircle, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -204,6 +205,7 @@ export function LightningPaymentWorkflow({
|
|||||||
const [createdApiKey, setCreatedApiKey] = useState<string>('');
|
const [createdApiKey, setCreatedApiKey] = useState<string>('');
|
||||||
const [topupApiKeyResult, setTopupApiKeyResult] = useState<string>('');
|
const [topupApiKeyResult, setTopupApiKeyResult] = useState<string>('');
|
||||||
const [recoveredApiKey, setRecoveredApiKey] = useState<string>('');
|
const [recoveredApiKey, setRecoveredApiKey] = useState<string>('');
|
||||||
|
const { copy } = useCopyToClipboard();
|
||||||
const [isWaitingPayment, setIsWaitingPayment] = useState(false);
|
const [isWaitingPayment, setIsWaitingPayment] = useState(false);
|
||||||
const [isWaitingTopupPayment, setIsWaitingTopupPayment] = useState(false);
|
const [isWaitingTopupPayment, setIsWaitingTopupPayment] = useState(false);
|
||||||
|
|
||||||
@@ -218,22 +220,15 @@ export function LightningPaymentWorkflow({
|
|||||||
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
||||||
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
|
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
|
||||||
|
|
||||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
const handleCopy = useCallback(
|
||||||
|
async (value: string): Promise<void> => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
if (await copy(value)) {
|
||||||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
|
||||||
toast.error('Clipboard API unavailable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Unable to copy');
|
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[copy]
|
||||||
|
);
|
||||||
|
|
||||||
const pollInvoiceStatus = useCallback(
|
const pollInvoiceStatus = useCallback(
|
||||||
async (invoiceId: string, onPaid: (status: InvoiceStatus) => void) => {
|
async (invoiceId: string, onPaid: (status: InvoiceStatus) => void) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { CheckCircle2 } from 'lucide-react';
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AdminService } from '@/lib/api/services/admin';
|
import { AdminService } from '@/lib/api/services/admin';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -33,6 +34,7 @@ export function ProviderBalance({
|
|||||||
isRoutstr = false,
|
isRoutstr = false,
|
||||||
nodeUrl,
|
nodeUrl,
|
||||||
}: ProviderBalanceProps) {
|
}: ProviderBalanceProps) {
|
||||||
|
const { copy } = useCopyToClipboard();
|
||||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||||
const [topupAmount, setTopupAmount] = useState('');
|
const [topupAmount, setTopupAmount] = useState('');
|
||||||
const [topupError, setTopupError] = useState('');
|
const [topupError, setTopupError] = useState('');
|
||||||
@@ -257,11 +259,10 @@ export function ProviderBalance({
|
|||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
variant='outline'
|
variant='outline'
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
navigator.clipboard.writeText(
|
if (await copy(invoiceData.payment_request)) {
|
||||||
invoiceData.payment_request
|
|
||||||
);
|
|
||||||
toast.success('Invoice copied to clipboard!');
|
toast.success('Invoice copied to clipboard!');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className='w-full sm:w-auto'
|
className='w-full sm:w-auto'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
@@ -35,6 +36,7 @@ export function RoutstrCreateKeySection({
|
|||||||
onApiKeyCreated,
|
onApiKeyCreated,
|
||||||
}: RoutstrCreateKeySectionProps) {
|
}: RoutstrCreateKeySectionProps) {
|
||||||
// Lightning state
|
// Lightning state
|
||||||
|
const { copy } = useCopyToClipboard();
|
||||||
const [lnAmount, setLnAmount] = useState('');
|
const [lnAmount, setLnAmount] = useState('');
|
||||||
const [lnInvoice, setLnInvoice] = useState<{
|
const [lnInvoice, setLnInvoice] = useState<{
|
||||||
bolt11: string;
|
bolt11: string;
|
||||||
@@ -61,11 +63,8 @@ export function RoutstrCreateKeySection({
|
|||||||
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
||||||
|
|
||||||
const handleCopy = async (text: string) => {
|
const handleCopy = async (text: string) => {
|
||||||
try {
|
if (await copy(text)) {
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
toast.success('Copied to clipboard');
|
toast.success('Copied to clipboard');
|
||||||
} catch {
|
|
||||||
toast.error('Failed to copy');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
|||||||
import { AlertCircle, Copy, Trash2, Check } from 'lucide-react';
|
import { AlertCircle, Copy, Trash2, Check } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getApiErrorMessage } from '@/lib/api/errors';
|
import { getApiErrorMessage } from '@/lib/api/errors';
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||||
|
|
||||||
function formatTs(ts: number | null): string {
|
function formatTs(ts: number | null): string {
|
||||||
if (!ts) return '—';
|
if (!ts) return '—';
|
||||||
@@ -36,7 +37,7 @@ export function CliTokensSettings(): React.ReactElement {
|
|||||||
const [expiresInDays, setExpiresInDays] = useState<string>('');
|
const [expiresInDays, setExpiresInDays] = useState<string>('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [newToken, setNewToken] = useState<CliTokenCreated | null>(null);
|
const [newToken, setNewToken] = useState<CliTokenCreated | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const { copied, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
const loadTokens = useCallback(async (): Promise<void> => {
|
const loadTokens = useCallback(async (): Promise<void> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -101,9 +102,7 @@ export function CliTokensSettings(): React.ReactElement {
|
|||||||
|
|
||||||
async function handleCopy(): Promise<void> {
|
async function handleCopy(): Promise<void> {
|
||||||
if (!newToken) return;
|
if (!newToken) return;
|
||||||
await navigator.clipboard.writeText(newToken.token);
|
await copy(newToken.token);
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
async function writeToClipboard(text: string): Promise<boolean> {
|
||||||
|
// navigator.clipboard is undefined on plain-HTTP deployments (non-secure context)
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// execCommand reports that a copy ran, not that anything landed, so write the
|
||||||
|
// text from the copy event and treat that firing as the proof of delivery.
|
||||||
|
let delivered = false;
|
||||||
|
const onCopy = (event: ClipboardEvent) => {
|
||||||
|
event.clipboardData?.setData('text/plain', text);
|
||||||
|
event.preventDefault();
|
||||||
|
delivered = true;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
document.addEventListener('copy', onCopy);
|
||||||
|
return document.execCommand('copy') && delivered;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
document.removeEventListener('copy', onCopy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clipboard copy with a success indicator that never lies: `copied` only
|
||||||
|
* turns true when the text actually reached the clipboard.
|
||||||
|
* `key` distinguishes multiple copy targets sharing one hook instance.
|
||||||
|
*/
|
||||||
|
export function useCopyToClipboard(resetMs = 2000) {
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const latest = useRef(0);
|
||||||
|
|
||||||
|
const copy = useCallback(
|
||||||
|
async (text: string, key = 'default'): Promise<boolean> => {
|
||||||
|
// Copies can overlap; only the newest attempt owns the indicator, so a
|
||||||
|
// slow earlier failure cannot clear a later success.
|
||||||
|
const attempt = ++latest.current;
|
||||||
|
const ok = await writeToClipboard(text);
|
||||||
|
if (attempt !== latest.current) return ok;
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
if (ok) {
|
||||||
|
setCopiedKey(key);
|
||||||
|
timer.current = setTimeout(() => setCopiedKey(null), resetMs);
|
||||||
|
} else {
|
||||||
|
setCopiedKey(null);
|
||||||
|
toast.error('Copy failed. Your browser blocked clipboard access.');
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
|
[resetMs]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { copied: copiedKey !== null, copiedKey, copy };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user