Merge pull request #633 from the-luap/feat/export-preview-modal-631
feat(admin/exports): inline preview modal with copy-to-clipboard (#631)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Copy, Check, Download } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button, Card } from '../common';
|
||||
|
||||
interface ExportPreviewModalProps {
|
||||
format: 'txt' | 'csv';
|
||||
content: string;
|
||||
filename: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline preview for text-based photo exports (#631).
|
||||
*
|
||||
* Daniel reported in #623 that the Lightroom TXT export was effectively
|
||||
* unusable as a file download — admins re-open the file, select-all, copy,
|
||||
* paste into Lightroom's search field. He suggested a modal with a
|
||||
* copy-to-clipboard button as a follow-up. Same workflow applies to the
|
||||
* CSV export (paste straight into Sheets / Excel).
|
||||
*
|
||||
* The modal preserves the file-download path so admins who want the file
|
||||
* (sharing with colleagues, archiving, post-processing tooling) aren't
|
||||
* worse off. XMP (ZIP archive) and JSON exports stay direct downloads —
|
||||
* neither makes sense as a textarea preview.
|
||||
*/
|
||||
export const ExportPreviewModal: React.FC<ExportPreviewModalProps> = ({
|
||||
format,
|
||||
content,
|
||||
filename,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
toast.success(t('export.preview.copied', 'Copied to clipboard.'));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Some browsers (older Safari, hardened sandboxes) reject
|
||||
// clipboard writes. Fall back to manual select-all so the admin
|
||||
// can Cmd/Ctrl+C themselves; doesn't fail silently.
|
||||
toast.error(
|
||||
t('export.preview.copyFailed', 'Clipboard write blocked. Select the text and copy manually.'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([content], {
|
||||
type: format === 'csv' ? 'text/csv;charset=utf-8' : 'text/plain;charset=utf-8',
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const titleKey = format === 'csv' ? 'export.preview.titleCsv' : 'export.preview.titleTxt';
|
||||
const titleDefault = format === 'csv' ? 'CSV export' : 'Lightroom filename list';
|
||||
const helpKey = format === 'csv'
|
||||
? 'export.preview.helpCsv'
|
||||
: 'export.preview.helpTxt';
|
||||
const helpDefault = format === 'csv'
|
||||
? 'Paste into a spreadsheet (Google Sheets, Excel, Numbers) — the first row is the column header.'
|
||||
: 'Paste into Lightroom\'s filename search. The list is comma-separated with no extension so it matches a catalog that holds RAW files.';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="max-w-2xl w-full">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t(titleKey, titleDefault)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
|
||||
{t(helpKey, helpDefault)}
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={content}
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
className="w-full h-64 p-3 rounded-md border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-900 text-sm font-mono text-neutral-900 dark:text-neutral-100 mb-4"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400 font-mono truncate">
|
||||
{filename}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
>
|
||||
{t('export.preview.download', 'Download as file')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCopy}
|
||||
leftIcon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
>
|
||||
{copied
|
||||
? t('export.preview.copiedShort', 'Copied')
|
||||
: t('export.preview.copyButton', 'Copy to clipboard')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service';
|
||||
import { ExportPreviewModal } from './ExportPreviewModal';
|
||||
|
||||
// TXT + CSV render through the preview modal (with copy-to-clipboard and a
|
||||
// fallback download button). XMP is a ZIP archive — no textarea preview makes
|
||||
// sense. JSON stays a direct download because operators consuming it want a
|
||||
// file for tooling. See #631.
|
||||
const PREVIEW_FORMATS: ReadonlyArray<'txt' | 'csv'> = ['txt', 'csv'];
|
||||
|
||||
interface PhotoExportMenuProps {
|
||||
eventId: number;
|
||||
@@ -47,6 +54,11 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [preview, setPreview] = useState<{
|
||||
format: 'txt' | 'csv';
|
||||
content: string;
|
||||
filename: string;
|
||||
} | null>(null);
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options),
|
||||
@@ -59,6 +71,21 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: ({ options, format }: { options: ExportOptions; format: 'txt' | 'csv' }) =>
|
||||
photosService.exportPhotosAsText(eventId, options).then((result) => ({
|
||||
...result,
|
||||
format,
|
||||
})),
|
||||
onSuccess: (result) => {
|
||||
setPreview(result);
|
||||
setIsOpen(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(t('export.error', 'Export failed: ') + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleExport = (format: 'txt' | 'csv' | 'xmp' | 'json') => {
|
||||
const options: ExportOptions = {
|
||||
format,
|
||||
@@ -96,7 +123,11 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
exportMutation.mutate(options);
|
||||
if ((PREVIEW_FORMATS as readonly string[]).includes(format)) {
|
||||
previewMutation.mutate({ options, format: format as 'txt' | 'csv' });
|
||||
} else {
|
||||
exportMutation.mutate(options);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelection = selectedPhotoIds.length > 0;
|
||||
@@ -108,13 +139,14 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
);
|
||||
|
||||
const isDisabled = disabled || (!hasSelection && !hasFilters);
|
||||
const isWorking = exportMutation.isPending || previewMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isDisabled || exportMutation.isPending}
|
||||
disabled={isDisabled || isWorking}
|
||||
className={`
|
||||
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
|
||||
transition-colors
|
||||
@@ -124,7 +156,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
}
|
||||
`}
|
||||
>
|
||||
{exportMutation.isPending ? (
|
||||
{isWorking ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
@@ -162,7 +194,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
<button
|
||||
key={format.value}
|
||||
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
|
||||
disabled={exportMutation.isPending}
|
||||
disabled={isWorking}
|
||||
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-700 text-left transition-colors"
|
||||
>
|
||||
<Icon className="w-5 h-5 text-neutral-500 dark:text-neutral-400 mt-0.5" />
|
||||
@@ -187,6 +219,15 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
{t('export.hint', 'Select photos or apply filters to export')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<ExportPreviewModal
|
||||
format={preview.format}
|
||||
content={preview.content}
|
||||
filename={preview.filename}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export { PhotoFilters } from './PhotoFilters';
|
||||
export { PasswordResetModal } from './PasswordResetModal';
|
||||
export { PublishGalleryDialog } from './PublishGalleryDialog';
|
||||
export { DuplicateEventDialog } from './DuplicateEventDialog';
|
||||
export { ExportPreviewModal } from './ExportPreviewModal';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
|
||||
@@ -2920,7 +2920,18 @@
|
||||
"exportFiltered": "Gefilterte Fotos exportieren",
|
||||
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren",
|
||||
"exportSelected_one": "{{count}} ausgewähltes exportieren",
|
||||
"exportSelected_other": "{{count}} ausgewählte exportieren"
|
||||
"exportSelected_other": "{{count}} ausgewählte exportieren",
|
||||
"preview": {
|
||||
"titleTxt": "Dateinamen-Liste für Lightroom",
|
||||
"titleCsv": "CSV-Export",
|
||||
"helpTxt": "In das Lightroom-Dateinamensuchfeld einfügen. Die Liste ist kommagetrennt und ohne Dateiendung, damit sie auch zu einem Katalog mit RAW-Dateien passt.",
|
||||
"helpCsv": "In eine Tabellenkalkulation einfügen (Google Sheets, Excel, Numbers) – die erste Zeile ist die Spaltenüberschrift.",
|
||||
"copyButton": "In die Zwischenablage kopieren",
|
||||
"copiedShort": "Kopiert",
|
||||
"copied": "In die Zwischenablage kopiert.",
|
||||
"copyFailed": "Zugriff auf die Zwischenablage blockiert. Bitte den Text markieren und manuell kopieren.",
|
||||
"download": "Als Datei herunterladen"
|
||||
}
|
||||
},
|
||||
"clientAccess": {
|
||||
"title": "Kundenzugang",
|
||||
|
||||
@@ -2989,7 +2989,18 @@
|
||||
"exportFiltered": "Export filtered photos",
|
||||
"hint": "Select photos or apply filters to export",
|
||||
"exportSelected_one": "Export {{count}} selected",
|
||||
"exportSelected_other": "Export {{count}} selected"
|
||||
"exportSelected_other": "Export {{count}} selected",
|
||||
"preview": {
|
||||
"titleTxt": "Lightroom filename list",
|
||||
"titleCsv": "CSV export",
|
||||
"helpTxt": "Paste into Lightroom's filename search. The list is comma-separated with no extension so it matches a catalog that holds RAW files.",
|
||||
"helpCsv": "Paste into a spreadsheet (Google Sheets, Excel, Numbers) — the first row is the column header.",
|
||||
"copyButton": "Copy to clipboard",
|
||||
"copiedShort": "Copied",
|
||||
"copied": "Copied to clipboard.",
|
||||
"copyFailed": "Clipboard write blocked. Select the text and copy manually.",
|
||||
"download": "Download as file"
|
||||
}
|
||||
},
|
||||
"photoSort": {
|
||||
"defaultSort": "Default Photo Sort",
|
||||
|
||||
@@ -295,6 +295,35 @@ class PhotosService {
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Same endpoint as `exportPhotos` but returns the text content + filename
|
||||
// instead of triggering a download. Used by the ExportPreviewModal (#631)
|
||||
// for TXT / CSV formats where the admin wants to paste rather than save.
|
||||
// XMP (ZIP archive) and JSON keep using exportPhotos — a textarea is the
|
||||
// wrong UI for binary archives and structured tool input.
|
||||
async exportPhotosAsText(
|
||||
eventId: number,
|
||||
options: ExportOptions
|
||||
): Promise<{ content: string; filename: string }> {
|
||||
const response = await api.post(
|
||||
`/admin/photo-export/${eventId}/export`,
|
||||
options,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = `export_${Date.now()}`;
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/);
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
const blob = response.data as Blob;
|
||||
const content = await blob.text();
|
||||
return { content, filename };
|
||||
}
|
||||
|
||||
async getExportFormats(): Promise<ExportFormat[]> {
|
||||
const response = await api.get('/admin/photo-export/export-formats');
|
||||
return response.data.data;
|
||||
|
||||
Reference in New Issue
Block a user