feat: enhance ImagesToPdf component with mobile-friendly file picker and unique file selection logic; update translations for mobile picker guidance
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import { FileImage } from 'lucide-react';
|
import { FileImage } from 'lucide-react';
|
||||||
@@ -18,6 +18,8 @@ export default function ImagesToPdf() {
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [taskId, setTaskId] = useState<string | null>(null);
|
const [taskId, setTaskId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [useSinglePickerFlow, setUseSinglePickerFlow] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const { status, result, error: taskError } = useTaskPolling({
|
const { status, result, error: taskError } = useTaskPolling({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -35,7 +37,22 @@ export default function ImagesToPdf() {
|
|||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coarsePointer = window.matchMedia?.('(pointer: coarse)').matches ?? false;
|
||||||
|
const mobileUserAgent = /android|iphone|ipad|ipod|mobile/i.test(navigator.userAgent);
|
||||||
|
setUseSinglePickerFlow(coarsePointer || mobileUserAgent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/bmp'];
|
const acceptedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/bmp'];
|
||||||
|
const acceptValue = acceptedTypes.join(',');
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
inputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
const handleFilesSelect = (newFiles: FileList | File[]) => {
|
const handleFilesSelect = (newFiles: FileList | File[]) => {
|
||||||
const fileArray = Array.from(newFiles).filter((f) =>
|
const fileArray = Array.from(newFiles).filter((f) =>
|
||||||
@@ -45,7 +62,19 @@ export default function ImagesToPdf() {
|
|||||||
setError(t('tools.imagesToPdf.invalidFiles'));
|
setError(t('tools.imagesToPdf.invalidFiles'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFiles((prev) => [...prev, ...fileArray]);
|
setFiles((prev) => {
|
||||||
|
const seen = new Set(prev.map((file) => `${file.name}:${file.size}:${file.lastModified}`));
|
||||||
|
const uniqueNewFiles = fileArray.filter((file) => {
|
||||||
|
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...prev, ...uniqueNewFiles];
|
||||||
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,8 +141,7 @@ export default function ImagesToPdf() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Drop zone */}
|
{/* Drop zone */}
|
||||||
<div
|
<div
|
||||||
className="upload-zone cursor-pointer"
|
className="upload-zone"
|
||||||
onClick={() => document.getElementById('images-file-input')?.click()}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -122,9 +150,10 @@ export default function ImagesToPdf() {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="images-file-input"
|
id="images-file-input"
|
||||||
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".png,.jpg,.jpeg,.webp,.bmp"
|
accept={acceptValue}
|
||||||
multiple
|
multiple={!useSinglePickerFlow}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.files) handleFilesSelect(e.target.files);
|
if (e.target.files) handleFilesSelect(e.target.files);
|
||||||
@@ -133,12 +162,24 @@ export default function ImagesToPdf() {
|
|||||||
/>
|
/>
|
||||||
<FileImage className="mb-4 h-12 w-12 text-slate-400" />
|
<FileImage className="mb-4 h-12 w-12 text-slate-400" />
|
||||||
<p className="mb-2 text-base font-medium text-slate-700">
|
<p className="mb-2 text-base font-medium text-slate-700">
|
||||||
{t('common.dragDrop')}
|
{files.length > 0 ? t('tools.imagesToPdf.addMore') : t('tools.imagesToPdf.selectImages')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-500">PNG, JPG, WebP, BMP</p>
|
<p className="text-sm text-slate-500">PNG, JPG, WebP, BMP</p>
|
||||||
|
{useSinglePickerFlow && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
{t('tools.imagesToPdf.mobilePickerHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
{t('common.maxSize', { size: 10 })}
|
{t('common.maxSize', { size: 10 })}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openPicker}
|
||||||
|
className="btn-secondary mt-4"
|
||||||
|
>
|
||||||
|
{files.length > 0 ? t('tools.imagesToPdf.addMore') : t('tools.imagesToPdf.selectImages')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File list */}
|
{/* File list */}
|
||||||
|
|||||||
@@ -564,7 +564,8 @@
|
|||||||
"addMore": "أضف صور أخرى",
|
"addMore": "أضف صور أخرى",
|
||||||
"imagesSelected": "{{count}} صور مختارة",
|
"imagesSelected": "{{count}} صور مختارة",
|
||||||
"invalidFiles": "يرجى اختيار ملفات صور صالحة (JPG أو PNG أو WebP).",
|
"invalidFiles": "يرجى اختيار ملفات صور صالحة (JPG أو PNG أو WebP).",
|
||||||
"minFiles": "يرجى اختيار صورة واحدة على الأقل."
|
"minFiles": "يرجى اختيار صورة واحدة على الأقل.",
|
||||||
|
"mobilePickerHint": "في بعض الهواتف يفضَّل اختيار صورة واحدة كل مرة ثم الضغط على حفظ لتأكيدها قبل إضافة الصورة التالية."
|
||||||
},
|
},
|
||||||
"watermarkPdf": {
|
"watermarkPdf": {
|
||||||
"title": "علامة مائية PDF",
|
"title": "علامة مائية PDF",
|
||||||
|
|||||||
@@ -564,7 +564,8 @@
|
|||||||
"addMore": "Add More Images",
|
"addMore": "Add More Images",
|
||||||
"imagesSelected": "{{count}} images selected",
|
"imagesSelected": "{{count}} images selected",
|
||||||
"invalidFiles": "Please select valid image files (JPG, PNG, WebP).",
|
"invalidFiles": "Please select valid image files (JPG, PNG, WebP).",
|
||||||
"minFiles": "Please select at least one image."
|
"minFiles": "Please select at least one image.",
|
||||||
|
"mobilePickerHint": "On some phones, select one image at a time and tap Save to confirm it before adding the next image."
|
||||||
},
|
},
|
||||||
"watermarkPdf": {
|
"watermarkPdf": {
|
||||||
"title": "Watermark PDF",
|
"title": "Watermark PDF",
|
||||||
|
|||||||
@@ -564,7 +564,8 @@
|
|||||||
"addMore": "Ajouter plus d'images",
|
"addMore": "Ajouter plus d'images",
|
||||||
"imagesSelected": "{{count}} images sélectionnées",
|
"imagesSelected": "{{count}} images sélectionnées",
|
||||||
"invalidFiles": "Veuillez sélectionner des fichiers images valides (JPG, PNG, WebP).",
|
"invalidFiles": "Veuillez sélectionner des fichiers images valides (JPG, PNG, WebP).",
|
||||||
"minFiles": "Veuillez sélectionner au moins une image."
|
"minFiles": "Veuillez sélectionner au moins une image.",
|
||||||
|
"mobilePickerHint": "Sur certains téléphones, sélectionnez une image à la fois puis appuyez sur Enregistrer avant d'ajouter la suivante."
|
||||||
},
|
},
|
||||||
"watermarkPdf": {
|
"watermarkPdf": {
|
||||||
"title": "Filigrane PDF",
|
"title": "Filigrane PDF",
|
||||||
|
|||||||
Reference in New Issue
Block a user