feat(admin/exports): inline preview modal with copy-to-clipboard (#631)

Follow-up to #623. The Lightroom TXT export now shows the filename list
in a modal with a "Copy to clipboard" button instead of triggering a
.txt file download — saves the "open file → select all → copy" dance
admins were doing anyway. CSV export takes the same path (paste straight
into Sheets / Excel).

The modal keeps a "Download as file" button so admins who want the file
(sharing with colleagues, archiving, post-processing tooling) aren't
worse off than before — fully additive.

XMP (ZIP archive) and JSON exports keep their direct download path. A
textarea preview is the wrong UI for a binary archive, and JSON is
structured tool input where the file form is the natural mode.

Implementation:

- ExportPreviewModal — readonly textarea, copy + download buttons,
  monospace font for filename lists, click-to-select-all on the textarea
  for browsers that block clipboard writes (older Safari, hardened
  sandboxes — the catch falls through to a "select and copy manually"
  toast instead of silent failure).
- photosService.exportPhotosAsText — same backend endpoint as
  exportPhotos but resolves the blob.text() and returns
  { content, filename } instead of triggering a download. Preserves
  the existing exportPhotos for the XMP / JSON paths.
- PhotoExportMenu — PREVIEW_FORMATS = ['txt', 'csv']; non-preview
  formats keep the direct-download flow unchanged.
- EN + DE i18n entries.

No backend changes. No new endpoints. No breaking changes for callers
of photosService.exportPhotos.
This commit is contained in:
Paul Nothaft
2026-06-17 23:35:19 +02:00
parent 9d3daa1f93
commit 27b5f7e4b6
6 changed files with 228 additions and 6 deletions
@@ -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>
);
};
+1
View File
@@ -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';
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+29
View File
@@ -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;