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:
@@ -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 { useMutation } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service';
|
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 {
|
interface PhotoExportMenuProps {
|
||||||
eventId: number;
|
eventId: number;
|
||||||
@@ -47,6 +54,11 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<{
|
||||||
|
format: 'txt' | 'csv';
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const exportMutation = useMutation({
|
||||||
mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options),
|
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 handleExport = (format: 'txt' | 'csv' | 'xmp' | 'json') => {
|
||||||
const options: ExportOptions = {
|
const options: ExportOptions = {
|
||||||
format,
|
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;
|
const hasSelection = selectedPhotoIds.length > 0;
|
||||||
@@ -108,13 +139,14 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isDisabled = disabled || (!hasSelection && !hasFilters);
|
const isDisabled = disabled || (!hasSelection && !hasFilters);
|
||||||
|
const isWorking = exportMutation.isPending || previewMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
disabled={isDisabled || exportMutation.isPending}
|
disabled={isDisabled || isWorking}
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
|
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
|
||||||
transition-colors
|
transition-colors
|
||||||
@@ -124,7 +156,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{exportMutation.isPending ? (
|
{isWorking ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
@@ -162,7 +194,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
<button
|
<button
|
||||||
key={format.value}
|
key={format.value}
|
||||||
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
|
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"
|
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" />
|
<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')}
|
{t('export.hint', 'Select photos or apply filters to export')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<ExportPreviewModal
|
||||||
|
format={preview.format}
|
||||||
|
content={preview.content}
|
||||||
|
filename={preview.filename}
|
||||||
|
onClose={() => setPreview(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export { PhotoFilters } from './PhotoFilters';
|
|||||||
export { PasswordResetModal } from './PasswordResetModal';
|
export { PasswordResetModal } from './PasswordResetModal';
|
||||||
export { PublishGalleryDialog } from './PublishGalleryDialog';
|
export { PublishGalleryDialog } from './PublishGalleryDialog';
|
||||||
export { DuplicateEventDialog } from './DuplicateEventDialog';
|
export { DuplicateEventDialog } from './DuplicateEventDialog';
|
||||||
|
export { ExportPreviewModal } from './ExportPreviewModal';
|
||||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||||
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||||
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||||
|
|||||||
@@ -2920,7 +2920,18 @@
|
|||||||
"exportFiltered": "Gefilterte Fotos exportieren",
|
"exportFiltered": "Gefilterte Fotos exportieren",
|
||||||
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren",
|
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren",
|
||||||
"exportSelected_one": "{{count}} ausgewähltes 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": {
|
"clientAccess": {
|
||||||
"title": "Kundenzugang",
|
"title": "Kundenzugang",
|
||||||
|
|||||||
@@ -2989,7 +2989,18 @@
|
|||||||
"exportFiltered": "Export filtered photos",
|
"exportFiltered": "Export filtered photos",
|
||||||
"hint": "Select photos or apply filters to export",
|
"hint": "Select photos or apply filters to export",
|
||||||
"exportSelected_one": "Export {{count}} selected",
|
"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": {
|
"photoSort": {
|
||||||
"defaultSort": "Default Photo Sort",
|
"defaultSort": "Default Photo Sort",
|
||||||
|
|||||||
@@ -295,6 +295,35 @@ class PhotosService {
|
|||||||
window.URL.revokeObjectURL(url);
|
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[]> {
|
async getExportFormats(): Promise<ExportFormat[]> {
|
||||||
const response = await api.get('/admin/photo-export/export-formats');
|
const response = await api.get('/admin/photo-export/export-formats');
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user