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,
|
||||
} from '@/components/ui/dialog';
|
||||
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 type { LogEntry } from './types';
|
||||
|
||||
@@ -24,16 +24,12 @@ export function LogDetailsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LogDetailsDialogProps) {
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const { copiedKey: copiedField, copy } = useCopyToClipboard();
|
||||
|
||||
if (!log) return null;
|
||||
|
||||
const copyToClipboard = (text: string, fieldName?: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
if (fieldName) {
|
||||
setCopiedField(fieldName);
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
}
|
||||
const copyToClipboard = (text: string, fieldName: string) => {
|
||||
void copy(text, fieldName);
|
||||
};
|
||||
|
||||
const allFields = Object.keys(log).filter((key) => key !== 'key');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { AppPageShell } from '@/components/app-page-shell';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
@@ -374,7 +375,7 @@ export default function TransactionsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [type, setType] = 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
|
||||
useEffect(() => {
|
||||
@@ -482,11 +483,10 @@ export default function TransactionsPage() {
|
||||
setLightningPage(0);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
const copyToClipboard = async (text: string, id: string) => {
|
||||
if (await copy(text, id)) {
|
||||
toast.success('Copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (tx: Transaction) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -107,7 +108,7 @@ export function AddProviderModelDialog({
|
||||
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||
const [selectedPresetLabel, setSelectedPresetLabel] =
|
||||
useState('Select a preset');
|
||||
const [forwardedModelIdCopied, setForwardedModelIdCopied] = useState(false);
|
||||
const { copied: forwardedModelIdCopied, copy } = useCopyToClipboard(1500);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(FormSchema) as never,
|
||||
@@ -546,9 +547,7 @@ export function AddProviderModelDialog({
|
||||
const handleCopy = () => {
|
||||
const value = field.value || form.getValues('id');
|
||||
if (!value) return;
|
||||
navigator.clipboard.writeText(value);
|
||||
setForwardedModelIdCopied(true);
|
||||
setTimeout(() => setForwardedModelIdCopied(false), 1500);
|
||||
void copy(value);
|
||||
};
|
||||
return (
|
||||
<FormItem>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||
import { WalletService } from '@/lib/api/services/wallet';
|
||||
import { ApiKeyInput } from './api-key-input';
|
||||
@@ -75,7 +76,7 @@ export function ChildKeyCreator({
|
||||
cost_msats: number;
|
||||
parent_balance: number;
|
||||
} | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const { copiedKey, copy } = useCopyToClipboard();
|
||||
|
||||
const addConfig = () => {
|
||||
setConfigs([
|
||||
@@ -163,16 +164,16 @@ export function ChildKeyCreator({
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
setCopiedKey(key);
|
||||
toast.success('API key copied to clipboard');
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
const copyToClipboard = async (key: string) => {
|
||||
if (await copy(key, key)) {
|
||||
toast.success('API key copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const copyAllToClipboard = () => {
|
||||
navigator.clipboard.writeText(newKeys.join('\n'));
|
||||
toast.success('All API keys copied to clipboard');
|
||||
const copyAllToClipboard = async () => {
|
||||
if (await copy(newKeys.join('\n'), 'all')) {
|
||||
toast.success('All API keys copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -215,10 +216,15 @@ export function ChildKeyCreator({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => navigator.clipboard.writeText(activeApiKey)}
|
||||
aria-label='Copy parent API key'
|
||||
onClick={() => copyToClipboard(activeApiKey)}
|
||||
disabled={!activeApiKey}
|
||||
>
|
||||
<Copy className='h-4 w-4' />
|
||||
{copiedKey === activeApiKey ? (
|
||||
<Check className='h-4 w-4' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{walletInfo && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { Copy, RefreshCcw } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
@@ -45,6 +46,7 @@ export function CashuPaymentWorkflow({
|
||||
onApiKeyCreated,
|
||||
onWalletInfoUpdated,
|
||||
}: CashuPaymentWorkflowProps): JSX.Element {
|
||||
const { copy } = useCopyToClipboard();
|
||||
const [initialToken, setInitialToken] = useState('');
|
||||
const [topupToken, setTopupToken] = useState('');
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
@@ -65,22 +67,17 @@ export function CashuPaymentWorkflow({
|
||||
} = useWalletInfo(baseUrl, activeApiKey);
|
||||
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
|
||||
|
||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
||||
toast.error('Clipboard API unavailable');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Unable to copy');
|
||||
}
|
||||
}, []);
|
||||
const handleCopy = useCallback(
|
||||
async (value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (await copy(value)) {
|
||||
toast.success('Copied to clipboard');
|
||||
}
|
||||
},
|
||||
[copy]
|
||||
);
|
||||
|
||||
const handleCreateKey = useCallback(async (): Promise<void> => {
|
||||
if (!initialToken.trim()) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { Bolt, Copy, RefreshCcw, Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -65,6 +66,7 @@ function normalizeBaseUrl(url: string): string {
|
||||
}
|
||||
|
||||
export function CheatSheet(): JSX.Element {
|
||||
const { copy } = useCopyToClipboard();
|
||||
const [baseUrl, setBaseUrl] = useState(() =>
|
||||
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
|
||||
);
|
||||
@@ -98,22 +100,17 @@ export function CheatSheet(): JSX.Element {
|
||||
staleTime: 120_000,
|
||||
});
|
||||
|
||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
||||
toast.error('Clipboard API unavailable');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Unable to copy');
|
||||
}
|
||||
}, []);
|
||||
const handleCopy = useCallback(
|
||||
async (value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (await copy(value)) {
|
||||
toast.success('Copied to clipboard');
|
||||
}
|
||||
},
|
||||
[copy]
|
||||
);
|
||||
|
||||
const handleApiKeyCreated = useCallback(
|
||||
(apiKey: string, snapshot: WalletSnapshot) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { useWalletInfo } from '@/hooks/use-wallet-info';
|
||||
import type { RefundReceipt } from './cashu-payment-workflow';
|
||||
import { toast } from 'sonner';
|
||||
@@ -58,6 +59,7 @@ export function KeyInfoDetails({
|
||||
onWalletInfoUpdated,
|
||||
onRefundComplete,
|
||||
}: KeyInfoDetailsProps): React.ReactNode {
|
||||
const { copy } = useCopyToClipboard();
|
||||
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
|
||||
const [isResetting, setIsResetting] = useState<string | null>(null);
|
||||
const [isRefunding, setIsRefunding] = useState(false);
|
||||
@@ -92,9 +94,10 @@ export function KeyInfoDetails({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
const handleCopy = async (value: string) => {
|
||||
if (await copy(value)) {
|
||||
toast.success('Copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSpent = async (childKey: string) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { type JSX, useCallback, useState } from 'react';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import Image from 'next/image';
|
||||
import { Copy, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -204,6 +205,7 @@ export function LightningPaymentWorkflow({
|
||||
const [createdApiKey, setCreatedApiKey] = useState<string>('');
|
||||
const [topupApiKeyResult, setTopupApiKeyResult] = useState<string>('');
|
||||
const [recoveredApiKey, setRecoveredApiKey] = useState<string>('');
|
||||
const { copy } = useCopyToClipboard();
|
||||
const [isWaitingPayment, setIsWaitingPayment] = useState(false);
|
||||
const [isWaitingTopupPayment, setIsWaitingTopupPayment] = useState(false);
|
||||
|
||||
@@ -218,22 +220,15 @@ export function LightningPaymentWorkflow({
|
||||
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
|
||||
const [hasInteractedRecover, setHasInteractedRecover] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async (value: string): Promise<void> => {
|
||||
if (!value) return;
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) {
|
||||
toast.error('Clipboard API unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success('Copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Unable to copy');
|
||||
}
|
||||
}, []);
|
||||
const handleCopy = useCallback(
|
||||
async (value: string): Promise<void> => {
|
||||
if (!value) return;
|
||||
if (await copy(value)) {
|
||||
toast.success('Copied to clipboard');
|
||||
}
|
||||
},
|
||||
[copy]
|
||||
);
|
||||
|
||||
const pollInvoiceStatus = useCallback(
|
||||
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 { toast } from 'sonner';
|
||||
import { AdminService } from '@/lib/api/services/admin';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -33,6 +34,7 @@ export function ProviderBalance({
|
||||
isRoutstr = false,
|
||||
nodeUrl,
|
||||
}: ProviderBalanceProps) {
|
||||
const { copy } = useCopyToClipboard();
|
||||
const [isTopupDialogOpen, setIsTopupDialogOpen] = useState(false);
|
||||
const [topupAmount, setTopupAmount] = useState('');
|
||||
const [topupError, setTopupError] = useState('');
|
||||
@@ -257,11 +259,10 @@ export function ProviderBalance({
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
invoiceData.payment_request
|
||||
);
|
||||
toast.success('Invoice copied to clipboard!');
|
||||
onClick={async () => {
|
||||
if (await copy(invoiceData.payment_request)) {
|
||||
toast.success('Invoice copied to clipboard!');
|
||||
}
|
||||
}}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import QRCode from 'qrcode';
|
||||
@@ -35,6 +36,7 @@ export function RoutstrCreateKeySection({
|
||||
onApiKeyCreated,
|
||||
}: RoutstrCreateKeySectionProps) {
|
||||
// Lightning state
|
||||
const { copy } = useCopyToClipboard();
|
||||
const [lnAmount, setLnAmount] = useState('');
|
||||
const [lnInvoice, setLnInvoice] = useState<{
|
||||
bolt11: string;
|
||||
@@ -61,11 +63,8 @@ export function RoutstrCreateKeySection({
|
||||
const cleanUrl = baseUrl.replace(/\/+$/, '');
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (await copy(text)) {
|
||||
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 { toast } from 'sonner';
|
||||
import { getApiErrorMessage } from '@/lib/api/errors';
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
|
||||
function formatTs(ts: number | null): string {
|
||||
if (!ts) return '—';
|
||||
@@ -36,7 +37,7 @@ export function CliTokensSettings(): React.ReactElement {
|
||||
const [expiresInDays, setExpiresInDays] = useState<string>('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newToken, setNewToken] = useState<CliTokenCreated | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const loadTokens = useCallback(async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
@@ -101,9 +102,7 @@ export function CliTokensSettings(): React.ReactElement {
|
||||
|
||||
async function handleCopy(): Promise<void> {
|
||||
if (!newToken) return;
|
||||
await navigator.clipboard.writeText(newToken.token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
await copy(newToken.token);
|
||||
}
|
||||
|
||||
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