From 27b5f7e4b68e43345cd99dd5cc77308dcd7ec98b Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 17 Jun 2026 23:35:19 +0200 Subject: [PATCH] feat(admin/exports): inline preview modal with copy-to-clipboard (#631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/admin/ExportPreviewModal.tsx | 129 ++++++++++++++++++ .../src/components/admin/PhotoExportMenu.tsx | 49 ++++++- frontend/src/components/admin/index.ts | 1 + frontend/src/i18n/locales/de.json | 13 +- frontend/src/i18n/locales/en.json | 13 +- frontend/src/services/photos.service.ts | 29 ++++ 6 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/admin/ExportPreviewModal.tsx diff --git a/frontend/src/components/admin/ExportPreviewModal.tsx b/frontend/src/components/admin/ExportPreviewModal.tsx new file mode 100644 index 00000000..ca36af2d --- /dev/null +++ b/frontend/src/components/admin/ExportPreviewModal.tsx @@ -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 = ({ + 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 ( +
+ +
+

+ {t(titleKey, titleDefault)} +

+ +
+ +

+ {t(helpKey, helpDefault)} +

+ +