fix(UI):Optimize mobile layout

The copy in the extraction method has been added with a fallback mechanism to improve adaptability
This commit is contained in:
david_bai
2025-08-25 00:04:03 +08:00
parent f7c4121f22
commit 7b9138ed08
7 changed files with 335 additions and 222 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
className="py-12"
aria-label="File Transfer Application"
>
<div className="container mx-auto px-4">
<div className="w-full max-w-none">
{/* sr-only--screen-only: visually hidden */}
<h2 className={cn("sr-only", "text-3xl font-bold mb-8 text-center")}>
{messages.text.home.h2_screenOnly}
+9 -11
View File
@@ -181,7 +181,7 @@ const ClipboardApp = () => {
}
return (
<div className="container mx-auto px-4 py-8 w-full md:max-w-4xl">
<div className="w-full mx-auto px-1 sm:px-1 py-3 sm:py-8 md:max-w-4xl md:container">
<FullScreenDropZone isDragging={isDragging} messages={messages} />
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
<Button
@@ -205,15 +205,15 @@ const ClipboardApp = () => {
{messages.text.ClipboardApp.html.retrieveTab}
</Button>
</div>
<Card className="border-8 shadow-md">
<CardHeader>
<CardTitle>
<Card className="border-4 sm:border-8 shadow-md">
<CardHeader className="px-3 sm:px-6 py-3 sm:py-6">
<CardTitle className="text-lg sm:text-xl">
{activeTab === "send"
? messages.text.ClipboardApp.html.shareTitle_dis
: messages.text.ClipboardApp.html.retrieveTitle_dis}
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="px-3 sm:px-6">
{activeTab === "send" ? (
<SendTabPanel
messages={messages}
@@ -224,7 +224,6 @@ const ClipboardApp = () => {
processRoomIdInput={processRoomIdInput}
joinRoom={joinRoom}
generateShareLinkAndBroadcast={generateShareLinkAndBroadcast}
shareMessage={shareMessage}
currentValidatedShareRoomId={shareRoomId}
handleLeaveSenderRoom={handleLeaveSenderRoom}
@@ -236,7 +235,6 @@ const ClipboardApp = () => {
setRetrieveRoomIdInput={setRetrieveRoomIdInput}
joinRoom={joinRoom}
retrieveJoinRoomBtnRef={retrieveJoinRoomBtnRef}
richTextToPlainText={richTextToPlainText}
handleDownloadFile={handleDownloadFile}
requestFile={requestFile}
@@ -251,13 +249,13 @@ const ClipboardApp = () => {
</CardContent>
</Card>
{activeTab === "send" && shareLink && messages && (
<Card className="border-2 shadow-md mt-4">
<CardHeader>
<CardTitle>
<Card className="border-2 sm:border-4 shadow-md mt-2 sm:mt-4">
<CardHeader className="pb-3 sm:pb-6">
<CardTitle className="text-base sm:text-lg">
{messages.text.ClipboardApp.html.RetrieveMethodTitle}
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="pt-0 px-3 sm:px-6">
<QRCodeComponent RoomID={shareRoomId} shareLink={shareLink} />
</CardContent>
</Card>
@@ -273,56 +273,66 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
return <div>Loading...</div>;
}
return (
<div className="flex items-center">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0 flex-shrink-0">
{progress && progress.progress < 1 ? ( //Show progress or completed
<TransferProgress
message={
mode === "sender"
? messages.text.FileListDisplay.sending_dis
: messages.text.FileListDisplay.receiving_dis
}
progress={progress}
/>
<div className="w-full sm:w-auto">
<TransferProgress
message={
mode === "sender"
? messages.text.FileListDisplay.sending_dis
: messages.text.FileListDisplay.receiving_dis
}
progress={progress}
/>
</div>
) : showCompletion ? (
<span className="mr-2 text-sm text-green-500">
<span className="text-sm text-green-500 whitespace-nowrap">
{messages.text.FileListDisplay.finish_dis}
</span>
) : null}
{mode === "receiver" &&
onRequest &&
onDownload && ( //Request && Download
<FileTransferButton
onRequest={() => onRequest(item)}
isCurrentFileTransferring={
<div className="flex items-center gap-1 sm:gap-2 flex-wrap">
{mode === "receiver" &&
onRequest &&
onDownload && ( //Request && Download
<FileTransferButton
onRequest={() => onRequest(item)}
isCurrentFileTransferring={
progress
? progress.progress > 0 && progress.progress < 1
: false
}
isOtherFileTransferring={isAnyFileTransferring && !progress}
isSavedToDisk={saveType ? saveType[item.fileId] : false}
/>
)}
{/* display download Num*/}
{mode === "sender" && (
<span className="text-xs sm:text-sm whitespace-nowrap">
{messages.text.FileListDisplay.downloadNum_dis}: {downloadCount}
</span>
)}
{mode === "sender" && onDelete && (
<Button
onClick={() => {
onDelete(item);
}}
variant="destructive"
size="sm"
disabled={
progress
? progress.progress > 0 && progress.progress < 1
? progress?.progress > 0 && progress.progress < 1
: false
}
isOtherFileTransferring={isAnyFileTransferring && !progress}
isSavedToDisk={saveType ? saveType[item.fileId] : false}
/>
className="text-xs sm:text-sm px-2 sm:px-3"
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-2" />
<span className="hidden sm:inline">
{messages.text.FileListDisplay.delete_dis}
</span>
</Button>
)}
{/* display download Num*/}
{mode === "sender" && (
<span className="mr-2 text-sm">
{messages.text.FileListDisplay.downloadNum_dis}: {downloadCount}
</span>
)}
{mode === "sender" && onDelete && (
<Button
onClick={() => {
onDelete(item);
}}
variant="destructive"
size="sm"
disabled={
progress ? progress?.progress > 0 && progress.progress < 1 : false
}
>
<Trash2 className="mr-2 h-4 w-4" />{" "}
{messages.text.FileListDisplay.delete_dis}
</Button>
)}
</div>
</div>
);
};
@@ -340,23 +350,32 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
: `${item.name} ${formatSize}`;
return (
<div key={item.name} className="flex items-center justify-between mb-1">
<div
key={item.name}
className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 p-2 sm:p-3 border border-gray-100 rounded-lg"
>
<Tooltip content={tooltipContent}>
<span className="mr-2 truncate max-w-sm">
{isFolder ? "📁 " : ""}
{item.name.length > filenameDisplayLen
? `${item.name.slice(0, filenameDisplayLen - 3)}...`
: item.name}
{isFolder
? `${formatFolderDis(
messages!.text.FileListDisplay.folder_dis_template,
item.fileCount || 0,
formatSize
)}`
: ` ${formatSize}`}
</span>
<div className="flex-1 min-w-0">
<span className="block truncate text-sm sm:text-base">
{isFolder ? "📁 " : ""}
{item.name.length > filenameDisplayLen
? `${item.name.slice(0, filenameDisplayLen - 3)}...`
: item.name}
</span>
<span className="text-xs sm:text-sm text-gray-500">
{isFolder
? `${formatFolderDis(
messages!.text.FileListDisplay.folder_dis_template,
item.fileCount || 0,
formatSize
)}`
: ` ${formatSize}`}
</span>
</div>
</Tooltip>
{renderItemActions(item)}
<div className="w-full sm:w-auto sm:flex-shrink-0">
{renderItemActions(item)}
</div>
</div>
);
};
@@ -120,47 +120,52 @@ export function RetrieveTabPanel({
? messages.text.ClipboardApp.roomStatus.connected_dis
: messages.text.ClipboardApp.roomStatus.receiverEmptyMsg)}
</div>
<div className="flex flex-col sm:flex-row items-center gap-2 mb-3">
<ReadClipboardButton
title={messages.text.ClipboardApp.html.readClipboard_dis}
onRead={setRetrieveRoomIdInput}
/>
<Input
aria-label="Retrieve Room ID"
value={retrieveRoomIdInput}
onChange={(e) => setRetrieveRoomIdInput(e.target.value)}
placeholder={
messages.text.ClipboardApp.html.retrieveRoomId_placeholder
}
className="flex-grow min-w-0"
/>
</div>
<div className="flex gap-2 mb-3">
<Button
className="flex-1"
onClick={() => joinRoom(false, retrieveRoomIdInput)}
ref={retrieveJoinRoomBtnRef}
disabled={isReceiverInRoom || !retrieveRoomIdInput.trim()}
>
{messages.text.ClipboardApp.html.joinRoom_dis}
</Button>
<Button
variant="outline"
onClick={handleLeaveRoom}
disabled={!isReceiverInRoom || isAnyFileTransferring}
>
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
</Button>
<div className="space-y-3 mb-4">
{/* Room ID input section */}
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<ReadClipboardButton
title={messages.text.ClipboardApp.html.readClipboard_dis}
onRead={setRetrieveRoomIdInput}
/>
<Input
aria-label="Retrieve Room ID"
value={retrieveRoomIdInput}
onChange={(e) => setRetrieveRoomIdInput(e.target.value)}
placeholder={
messages.text.ClipboardApp.html.retrieveRoomId_placeholder
}
className="flex-1 min-w-0"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row gap-2">
<Button
className="flex-1 order-1"
onClick={() => joinRoom(false, retrieveRoomIdInput)}
ref={retrieveJoinRoomBtnRef}
disabled={isReceiverInRoom || !retrieveRoomIdInput.trim()}
>
{messages.text.ClipboardApp.html.joinRoom_dis}
</Button>
<Button
variant="outline"
onClick={handleLeaveRoom}
disabled={!isReceiverInRoom || isAnyFileTransferring}
className="w-full sm:w-auto px-4 order-2"
>
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
</Button>
</div>
</div>
{retrievedContent && (
<div className="my-3 p-2 border rounded bg-gray-50">
<RichTextEditor
value={retrievedContent}
onChange={() => {
/* Read-only */
}}
/>
<div className="mt-2">
<div className="my-3 p-3 border rounded-md">
<div className="bg-white p-3 rounded border border-gray-200 text-sm leading-relaxed">
<div dangerouslySetInnerHTML={{ __html: retrievedContent }} />
</div>
<div className="flex justify-start">
<WriteClipboardButton
title={messages.text.ClipboardApp.html.Copy_dis}
textToCopy={richTextToPlainText(retrievedContent)}
@@ -101,7 +101,7 @@ export function SendTabPanel({
: messages.text.ClipboardApp.roomStatus.senderEmptyMsg)}
</div>
<RichTextEditor value={shareContent} onChange={updateShareContent} />
<div className="flex flex-wrap gap-2 my-3">
<div className="flex flex-col sm:flex-row gap-2 my-3">
<ReadClipboardButton
title={messages.text.ClipboardApp.html.Paste_dis}
onRead={updateShareContent}
@@ -121,46 +121,59 @@ export function SendTabPanel({
onDelete={removeFileToSend}
/>
</div>
<div className="flex flex-col sm:flex-row items-center gap-2 mb-3">
<span className="text-sm whitespace-nowrap">
{messages.text.ClipboardApp.html.inputRoomId_tips}
</span>
<Input
aria-label="Share Room ID"
value={inputFieldValue} // Bind to local state
onChange={handleInputChange}
onPaste={handlePaste}
className="flex-grow min-w-0"
/>
<Button
className="w-full sm:w-auto"
onClick={() => joinRoom(true, inputFieldValue.trim())} // Attempt to join using the current input field value
disabled={isSenderInRoom || !inputFieldValue.trim()}
>
{messages.text.ClipboardApp.html.joinRoom_dis}
</Button>
</div>
<div className="flex gap-2">
<AnimatedButton
className="flex-1"
onClick={generateShareLinkAndBroadcast}
loadingText={messages.text.ClipboardApp.html.SyncSending_loadingText}
disabled={
!isSenderInRoom ||
(sendFiles.length === 0 && shareContent.trim() === "") ||
!currentValidatedShareRoomId.trim() ||
isAnyFileTransferring
} // Ensure there is a validated room ID before allowing sharing
>
{messages.text.ClipboardApp.html.SyncSending_dis}
</AnimatedButton>
<Button
variant="outline"
onClick={handleLeaveSenderRoom}
disabled={!isSenderInRoom || isAnyFileTransferring}
>
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
</Button>
<div className="space-y-3 mb-4">
{/* Room ID input section */}
<div className="space-y-2">
<p className="text-sm text-gray-600">
{messages.text.ClipboardApp.html.inputRoomId_tips}
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
aria-label="Share Room ID"
value={inputFieldValue}
onChange={handleInputChange}
onPaste={handlePaste}
className="flex-1 min-w-0"
placeholder={
messages.text.ClipboardApp.html.retrieveRoomId_placeholder
}
/>
<Button
className="w-full sm:w-auto px-4"
onClick={() => joinRoom(true, inputFieldValue.trim())}
disabled={isSenderInRoom || !inputFieldValue.trim()}
>
{messages.text.ClipboardApp.html.joinRoom_dis}
</Button>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row gap-2">
<AnimatedButton
className="flex-1 order-1"
onClick={generateShareLinkAndBroadcast}
loadingText={
messages.text.ClipboardApp.html.SyncSending_loadingText
}
disabled={
!isSenderInRoom ||
(sendFiles.length === 0 && shareContent.trim() === "") ||
!currentValidatedShareRoomId.trim() ||
isAnyFileTransferring
}
>
{messages.text.ClipboardApp.html.SyncSending_dis}
</AnimatedButton>
<Button
variant="outline"
onClick={handleLeaveSenderRoom}
disabled={!isSenderInRoom || isAnyFileTransferring}
className="w-full sm:w-auto px-4 order-2"
>
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
</Button>
</div>
</div>
{shareMessage && (
<p className="mt-3 text-sm text-blue-600">{shareMessage}</p>
+118 -67
View File
@@ -30,6 +30,19 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
const copyToClipboard = async () => {
if (!qrRef.current) return;
// Check for Clipboard API support for images
if (
!navigator.clipboard ||
!navigator.clipboard.write ||
!window.ClipboardItem
) {
console.warn(
"Clipboard API for images not supported. Falling back to download."
);
downloadQRCode();
return;
}
try {
const svgElement = qrRef.current.querySelector("svg");
if (!svgElement) return;
@@ -44,21 +57,33 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const pngFile = await new Promise<Blob>((resolve) =>
canvas.toBlob((blob) => resolve(blob!), "image/png")
const pngBlob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
);
await navigator.clipboard.write([
new ClipboardItem({
"image/png": pngFile,
}),
]);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
if (pngBlob) {
await navigator.clipboard.write([
new ClipboardItem({
"image/png": pngBlob,
}),
]);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} else {
throw new Error("Canvas to Blob conversion failed");
}
};
img.onerror = () => {
// If image loading fails, fall back to download
console.error(
"Image loading for QR code failed. Falling back to download."
);
downloadQRCode();
};
img.src = "data:image/svg+xml;base64," + btoa(svgData);
} catch (err) {
console.error("Failed to copy QR code: ", err);
alert("Failed to copy QR code. Please try again.");
console.error("Failed to copy QR code, falling back to download: ", err);
downloadQRCode(); // Fallback to download on any error
}
};
useEffect(() => {
@@ -95,73 +120,99 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
return <div>Loading...</div>;
}
return (
<div className="bg-blue-100 p-4 rounded-md">
<p className="text-blue-700 mb-4">{messages.text.RetrieveMethod.P}</p>
<div className="bg-blue-50 p-2 sm:p-4 rounded-lg border border-blue-200">
<p className="text-blue-700 mb-3 sm:mb-4 text-sm sm:text-base">
{messages.text.RetrieveMethod.P}
</p>
{/* Use flex-col instead of list for better control on mobile layout */}
<div className="flex flex-col space-y-4">
{/* Mobile-first responsive layout */}
<div className="space-y-3 sm:space-y-4">
{/* RoomID section */}
<div className="flex flex-col space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span>{messages.text.RetrieveMethod.RoomId_tips + RoomID}</span>
<WriteClipboardButton
title={messages.text.RetrieveMethod.copyRoomId_tips}
textToCopy={RoomID}
/>
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">
{messages.text.RetrieveMethod.RoomId_tips}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<code className="flex-1 bg-gray-100 px-2 py-1 rounded text-sm font-mono break-all">
{RoomID}
</code>
<WriteClipboardButton
title={messages.text.RetrieveMethod.copyRoomId_tips}
textToCopy={RoomID}
/>
</div>
</div>
</div>
{/* URL section */}
<div className="flex flex-col space-y-2">
<div className="break-all">
{messages.text.RetrieveMethod.url_tips + shareLink}
</div>
<div className="flex flex-wrap gap-2">
<WriteClipboardButton
title={messages.text.RetrieveMethod.copyUrl_tips}
textToCopy={shareLink}
/>
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">
{messages.text.RetrieveMethod.url_tips}
</p>
<div className="bg-gray-100 px-2 py-2 rounded text-xs sm:text-sm break-all font-mono">
{shareLink}
</div>
<div className="flex justify-start">
<WriteClipboardButton
title={messages.text.RetrieveMethod.copyUrl_tips}
textToCopy={shareLink}
/>
</div>
</div>
</div>
{/* QR Code section */}
<div className="flex flex-col space-y-2">
<div>{messages.text.RetrieveMethod.scanQR_tips}</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={copyToClipboard}
variant="outline"
className="w-full sm:w-auto"
>
{isCopied ? (
<>
<Check className="w-4 h-4 mr-2" />
{messages.text.RetrieveMethod.Copied_dis}
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />{" "}
{messages.text.RetrieveMethod.Copy_QR_dis}
</>
)}
</Button>
<Button
onClick={downloadQRCode}
variant="outline"
className="w-full sm:w-auto"
>
<Download className="mr-2 h-4 w-4" />{" "}
{messages.text.RetrieveMethod.download_QR_dis}
</Button>
</div>
</div>
</div>
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">
{messages.text.RetrieveMethod.scanQR_tips}
</p>
{/* QR Code display area */}
<div className="mt-4 flex justify-center">
<div className="inline-block border-2 p-4 bg-white rounded-lg">
<div ref={qrRef}>
<QRCodeSVG value={shareLink} />
{/* QR Code display area - moved up for better mobile UX */}
<div className="flex justify-center">
<div className="inline-block border-2 p-2 sm:p-4 bg-gray-50 rounded-lg">
<div ref={qrRef}>
<QRCodeSVG
value={shareLink}
size={120}
className="sm:w-32 sm:h-32"
/>
</div>
</div>
</div>
{/* QR Code action buttons */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<Button
onClick={copyToClipboard}
variant="outline"
size="sm"
className="w-full"
>
{isCopied ? (
<>
<Check className="w-4 h-4 mr-2" />
{messages.text.RetrieveMethod.Copied_dis}
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
{messages.text.RetrieveMethod.Copy_QR_dis}
</>
)}
</Button>
<Button
onClick={downloadQRCode}
variant="outline"
size="sm"
className="w-full"
>
<Download className="mr-2 h-4 w-4" />
{messages.text.RetrieveMethod.download_QR_dis}
</Button>
</div>
</div>
</div>
</div>
+34 -7
View File
@@ -67,17 +67,44 @@ export const useClipboardActions = (): ClipboardActions => {
async (textToCopy: string) => {
setError(null);
setIsCopied(false);
if (!navigator.clipboard) {
setError(clipboardMessages.copyError || "Clipboard API not available.");
return;
// Modern API: navigator.clipboard.writeText
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
return; // Success
} catch (err) {
console.error("Failed to copy text with navigator.clipboard: ", err);
// Fallback will be attempted below
}
}
// Fallback: document.execCommand('copy')
let textArea: HTMLTextAreaElement | null = null;
try {
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
textArea = document.createElement("textarea");
textArea.value = textToCopy;
textArea.style.position = "fixed"; // Prevent scrolling to bottom of page in MS Edge.
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand("copy");
if (successful) {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} else {
throw new Error("document.execCommand failed");
}
} catch (err) {
console.error("Failed to copy text: ", err);
console.error("Fallback copy method failed: ", err);
setError(clipboardMessages.copyError || "Failed to copy.");
} finally {
if (textArea) {
document.body.removeChild(textArea);
}
}
},
[clipboardMessages.copyError]