Files
picpeak/frontend/src/components/admin/ExportPreviewModal.tsx
T
Paul Nothaft 27b5f7e4b6 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.
2026-06-17 23:35:19 +02:00

130 lines
4.8 KiB
TypeScript

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>
);
};