fix(clipboard): implement copyText utility and refactor copy logic in components

This commit is contained in:
Noooste
2026-08-04 00:57:48 +02:00
parent 76adb16905
commit 5b04b3483b
5 changed files with 73 additions and 31 deletions
@@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ObjectPreview } from '@/components/buckets/ObjectPreview';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { copyText } from '@/lib/clipboard';
import { downloadObject, formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
@@ -90,9 +91,9 @@ export function ObjectDetailsView() {
const backHref = `/buckets/${bucketName}/objects${parentPath ? `?prefix=${encodeURIComponent(parentPath + '/')}` : ''}`;
const pathSegments = parentPath ? parentPath.split('/').filter(Boolean) : [];
const copy = (text: string, label = 'Copied') => {
navigator.clipboard.writeText(text);
toast.success(label);
const copy = async (text: string, label = 'Copied') => {
if (await copyText(text)) toast.success(label);
else toast.error('Failed to copy');
};
const handleDownload = () => {
@@ -4,6 +4,7 @@ import { IconTile } from '@/components/ui/icon-tile';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { copyText } from '@/lib/clipboard';
import { useBuckets } from '@/hooks/useApi';
import { useBucketCan } from '@/hooks/usePermissions';
import { toast } from 'sonner';
@@ -43,12 +44,8 @@ export function BucketDetailShell() {
const s3Url = `s3://${bucketName}`;
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(s3Url);
toast.success('URL copied');
} catch {
toast.error('Failed to copy');
}
if (await copyText(s3Url)) toast.success('URL copied');
else toast.error('Failed to copy');
};
return (
+21 -11
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Check, Copy, Eye, EyeOff, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { copyText } from '@/lib/clipboard';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
@@ -21,30 +22,39 @@ export function CredentialField({
}) {
const [copied, setCopied] = useState(false);
const [revealed, setRevealed] = useState(!maskable);
const copy = () => {
const copy = async () => {
if (!value) return;
navigator.clipboard.writeText(value);
if (!(await copyText(value))) {
toast.error(`Could not copy ${label}. Select it and copy by hand.`);
return;
}
setCopied(true);
toast.success(`${label} copied`);
setTimeout(() => setCopied(false), 1600);
};
// Copying focuses a textarea on the fallback path, which would clear whatever
// the user just highlighted, so a click that ends a selection copies nothing.
const copyUnlessSelecting = () => {
if (!window.getSelection()?.toString()) copy();
};
const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40));
const copyable = !loading && !!value;
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
{label}
</label>
<div className="flex items-stretch gap-2">
<button
type="button"
onClick={copy}
disabled={loading || !value}
title="Click to copy"
{/* A div, not a button: browsers set user-select: none on buttons, which
left the value impossible to highlight when copying failed. Keyboard
users copy through the button next to it. */}
<div
onClick={copyable ? copyUnlessSelecting : undefined}
title={copyable ? 'Click to copy' : undefined}
className={cn(
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
'px-3 py-2 text-[13.5px] transition-colors',
copyable ? 'cursor-pointer hover:bg-[var(--accent)]' : 'opacity-70',
mono && 'font-mono',
breakAll ? 'break-all' : 'truncate',
)}
@@ -57,7 +67,7 @@ export function CredentialField({
) : (
display
)}
</button>
</div>
{maskable && (
<Button
variant="secondary"
+34
View File
@@ -0,0 +1,34 @@
/**
* Copy text to the clipboard, returning whether it worked.
*
* navigator.clipboard only exists in a secure context, and garage-ui is usually
* reached over plain HTTP, so the clipboard object is often missing entirely.
* The deprecated execCommand path still works there and in every browser we
* support, so it stays as the fallback.
*/
export async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Permission denied or an unfocused document, fall through.
}
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
try {
area.focus();
area.select();
return document.execCommand('copy');
} catch {
return false;
} finally {
area.remove();
}
}
+11 -11
View File
@@ -1,5 +1,6 @@
import {useEffect, useMemo, useState} from 'react';
import {useEffect, useMemo, useState, type MouseEvent} from 'react';
import {cn} from '@/lib/utils';
import {copyText} from '@/lib/clipboard';
import {PageHeader} from '@/components/ui/page-header';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
@@ -36,6 +37,13 @@ import type {AccessKey, Bucket, BucketPermission} from '@/types';
import {AlertTriangle, Calendar, Copy, Database, Edit, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
import {toast} from 'sonner';
// Copying must not also open the key's row.
async function copyAccessKeyId(e: MouseEvent, accessKeyId: string) {
e.stopPropagation();
if (await copyText(accessKeyId)) toast.success('Access Key ID copied to clipboard');
else toast.error('Failed to copy');
}
export function AccessControl() {
const queryClient = useQueryClient();
const [keys, setKeys] = useState<AccessKey[]>([]);
@@ -480,11 +488,7 @@ export function AccessControl() {
<div className="flex items-center gap-2">
<code
className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block cursor-pointer hover:bg-muted/80 transition-colors"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(key.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
onClick={(e) => copyAccessKeyId(e, key.accessKeyId)}
>
{key.accessKeyId}
</code>
@@ -492,11 +496,7 @@ export function AccessControl() {
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(key.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
onClick={(e) => copyAccessKeyId(e, key.accessKeyId)}
>
<Copy className="h-3 w-3" />
</Button>