mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-09 05:09:22 +00:00
fix(clipboard): implement copyText utility and refactor copy logic in components
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user