feat(transfers): add PicTransfer — cross-event file transfers (#998)

Closes #997.

Send original files from any event as a token-protected download link, with an
optional client-upload channel. Strictly opt-in behind a new `transfers`
feature flag, default OFF.

Migrations 170-172 (transfers, transfer_files, transfer_extra_files,
transfer_uploads, transfer_recipients, transfer_downloads, default settings and
two email templates) — all hasTable/hasColumn-guarded and idempotent, with
destructive statements confined to down().

Backend: transferService (CRUD, 256-bit download token, 6-char upload token,
cross-event ZIP streaming of originals), admin CRUD routes, and two public
token routes. transferCleanupService runs an hourly retention sweep; source-event
photos are never touched. All three routers fail closed via
requireFeatureFlag('transfers').

Review closed two ownership blockers, both the same root cause — permissions
used where ownership was needed:

- photoIds arrived from the request body and were validated only for existence,
  so a scoped admin could bundle any event's originals and hand them out through
  the public download token. filterOwnedPhotoIds now resolves ids to their events
  and gates them through filterOwnedEventIds, on both the create and add-files
  paths.
- The transfer list was unscoped and carried each row's download token, so any
  admin with events.view could read another's token and fetch their originals.
  The list is now scoped by created_by, the token/url fields are stripped from
  the list payload, and a single router.use('/:id', requireTransferOwnership)
  covers all twelve /:id routes, 404ing foreign and missing alike.

The admin photo picker filters its event list to the same rule, so the UI stops
offering picks the API would discard.

Fork-PR workflows had not been approved since the fix commits, so the PR's green
checks were stale against the pre-fix head. Verified by dispatching tests.yml
against the actual head: backend and frontend both green.

Follow-up: neither ownership guard has a regression test yet.

Co-authored-by: Luca-Timo <[email protected]>
This commit is contained in:
Luca
2026-08-09 13:40:03 +02:00
committed by GitHub
co-authored by Luca-Timo
parent e2d8ec86bd
commit 2e495d7c48
23 changed files with 4180 additions and 0 deletions
+13
View File
@@ -56,6 +56,9 @@ import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage';
import { BlockLibraryPage } from './pages/admin/contracts/BlockLibraryPage';
import { PaymentCheckPage } from './pages/public/PaymentCheckPage';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { TransfersPage } from './pages/admin/transfers/TransfersPage';
import { TransferDownloadPage } from './pages/public/TransferDownloadPage';
import { TransferUploadPage } from './pages/public/TransferUploadPage';
import {
CustomerLoginPage,
CustomerDashboardPage,
@@ -239,6 +242,11 @@ function App() {
<Route path="events/:id" element={<EventDetailsPage />} />
<Route path="events/:id/feedback" element={<EventFeedbackPage />} />
<Route path="archives" element={<ArchivesPage />} />
{/* PicTransfer (#997) — cross-event file transfers.
Gated by the `transfers` flag (strictly opt-in). */}
<Route element={<RequireFeature flag="transfers" />}>
<Route path="transfers" element={<TransfersPage />} />
</Route>
{/* Feature-gated surfaces — redirect to /admin/dashboard when flag is off. */}
<Route element={<RequireFeature flag="analytics" />}>
@@ -412,6 +420,11 @@ function App() {
check email. */}
<Route path="/payment-check/:token" element={<PaymentCheckPage />} />
{/* PicTransfer (#997) — recipient download + client upload,
token-only, no auth. */}
<Route path="/transfer/:token" element={<TransferDownloadPage />} />
<Route path="/transfer-upload/:token" element={<TransferUploadPage />} />
{/* Customer surface (#354). Strictly separate provider /
cookie / API surface from /admin/*. The customerPortal
feature flag hides the *admin-side* surfaces (sidebar
@@ -16,6 +16,7 @@ import {
PanelLeftClose,
PanelLeftOpen,
Github,
Send,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -67,6 +68,7 @@ const navigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
{ nameKey: 'navigation.transfers', href: '/admin/transfers', icon: Send, permission: 'events.view', featureFlag: 'transfers' },
{ nameKey: 'navigation.messages', href: '/admin/messages', icon: Mail, permission: 'email.view', featureFlag: 'messaging' },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
@@ -0,0 +1,254 @@
/**
* TransferPhotoPicker — cross-event image picker for PicTransfer (#997).
*
* A modal that lets the admin browse ANY event's photos and pick images to add
* to a transfer. Thumbnails are previewed (recipient page has none); a lightbox
* toggle switches image clicks between "select" and "preview". Selection
* persists as the admin hops between events.
*/
import React, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Check, Image as ImageIcon, Maximize2, Search } from 'lucide-react';
import { Button, Input, Loading } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { eventsService } from '../../services/events.service';
import { photosService, type AdminPhoto } from '../../services/photos.service';
import { useAdminAuth } from '../../contexts/AdminAuthContext';
export interface PickedPhoto {
id: number;
filename: string;
event_id: number;
event_name: string;
thumbnail_url: string;
}
interface TransferPhotoPickerProps {
onClose: () => void;
onConfirm: (photos: PickedPhoto[]) => void;
excludePhotoIds?: number[];
isSaving?: boolean;
}
export const TransferPhotoPicker: React.FC<TransferPhotoPickerProps> = ({
onClose,
onConfirm,
excludePhotoIds = [],
isSaving = false,
}) => {
const { t } = useTranslation();
const { user } = useAdminAuth();
const [eventSearch, setEventSearch] = useState('');
const [selectedEventId, setSelectedEventId] = useState<number | null>(null);
const [selectedEventName, setSelectedEventName] = useState<string>('');
const [lightboxEnabled, setLightboxEnabled] = useState(false);
const [previewPhoto, setPreviewPhoto] = useState<AdminPhoto | null>(null);
// Persist selection (with metadata) across events.
const [selected, setSelected] = useState<Map<number, PickedPhoto>>(new Map());
const excluded = useMemo(() => new Set(excludePhotoIds), [excludePhotoIds]);
const { data: eventsData, isLoading: eventsLoading } = useQuery({
queryKey: ['transfer-picker-events', eventSearch],
queryFn: () => eventsService.getEvents(1, 100, undefined, eventSearch || undefined),
});
// Only offer events the caller may bundle — mirrors the backend's
// filterOwnedEventIds gate (super_admin unrestricted; others get their own
// events plus ownerless legacy ones). Without this the picker would show
// events whose photos the API silently drops on create — a dead control.
// The backend is still the enforcer; this just keeps the UI honest.
const roleName = user?.roleName || user?.role?.name;
const isSuperAdmin = roleName === 'super_admin';
const events = (eventsData?.events || []).filter((ev) => {
if (isSuperAdmin) return true;
const owner = (ev as { created_by?: number | null }).created_by;
return owner == null || owner === user?.id;
});
const { data: photos, isLoading: photosLoading } = useQuery({
queryKey: ['transfer-picker-photos', selectedEventId],
queryFn: () => photosService.getEventPhotos(selectedEventId as number),
enabled: !!selectedEventId,
});
const togglePhoto = (photo: AdminPhoto) => {
if (excluded.has(photo.id)) return;
setSelected((prev) => {
const next = new Map(prev);
if (next.has(photo.id)) {
next.delete(photo.id);
} else {
next.set(photo.id, {
id: photo.id,
filename: photo.original_filename || photo.filename,
event_id: selectedEventId as number,
event_name: selectedEventName,
thumbnail_url: photo.thumbnail_url || '',
});
}
return next;
});
};
const handlePhotoClick = (photo: AdminPhoto) => {
if (lightboxEnabled) setPreviewPhoto(photo);
else togglePhoto(photo);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg bg-white shadow-xl dark:bg-neutral-900">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3 dark:border-neutral-700">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('transfers.picker.title', 'Select images from other events')}
</h2>
<div className="flex items-center gap-2">
<Button
variant={lightboxEnabled ? 'primary' : 'outline'}
size="sm"
leftIcon={<Maximize2 className="h-4 w-4" />}
onClick={() => setLightboxEnabled((v) => !v)}
>
{t('transfers.picker.lightbox', 'Lightbox')}
</Button>
<button onClick={onClose} className="rounded p-1 text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1">
{/* Event list */}
<div className="flex w-64 flex-col border-r border-neutral-200 dark:border-neutral-700">
<div className="p-3">
<Input
leftIcon={<Search className="h-4 w-4" />}
placeholder={t('transfers.picker.searchEvents', 'Search events…')}
value={eventSearch}
onChange={(e) => setEventSearch(e.target.value)}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{eventsLoading ? (
<div className="p-4"><Loading /></div>
) : (
events.map((ev) => (
<button
key={ev.id}
onClick={() => { setSelectedEventId(ev.id); setSelectedEventName(ev.event_name); }}
className={`block w-full truncate px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
selectedEventId === ev.id ? 'bg-primary-50 font-medium text-primary-700 dark:bg-neutral-800' : 'text-neutral-700 dark:text-neutral-300'
}`}
>
{ev.event_name}
</button>
))
)}
</div>
</div>
{/* Photo grid */}
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{!selectedEventId ? (
<div className="flex h-full items-center justify-center text-neutral-400">
<div className="text-center">
<ImageIcon className="mx-auto mb-2 h-10 w-10" />
<p>{t('transfers.picker.pickEvent', 'Pick an event to browse its photos')}</p>
</div>
</div>
) : photosLoading ? (
<Loading />
) : !photos || photos.length === 0 ? (
<div className="flex h-full items-center justify-center text-neutral-400">
{t('transfers.picker.noPhotos', 'No photos in this event')}
</div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{photos.map((photo) => {
const isSelected = selected.has(photo.id);
const isExcluded = excluded.has(photo.id);
return (
<div
key={photo.id}
className={`group relative aspect-square cursor-pointer overflow-hidden rounded-md border-2 ${
isSelected ? 'border-primary-500' : 'border-transparent'
} ${isExcluded ? 'opacity-40' : ''}`}
onClick={() => handlePhotoClick(photo)}
>
{photo.thumbnail_url ? (
<AdminAuthenticatedImage src={photo.thumbnail_url} alt={photo.filename} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-100 dark:bg-neutral-800">
<ImageIcon className="h-6 w-6 text-neutral-400" />
</div>
)}
{isExcluded && (
<span className="absolute inset-x-0 bottom-0 bg-black/60 py-0.5 text-center text-[10px] text-white">
{t('transfers.picker.alreadyAdded', 'Added')}
</span>
)}
{isSelected && (
<span className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-white">
<Check className="h-3 w-3" />
</span>
)}
</div>
);
})}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-neutral-200 px-5 py-3 dark:border-neutral-700">
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('transfers.picker.selectedCount', '{{count}} selected', { count: selected.size })}
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button
onClick={() => onConfirm(Array.from(selected.values()))}
disabled={selected.size === 0}
isLoading={isSaving}
>
{t('transfers.picker.addSelected', 'Add selected')}
</Button>
</div>
</div>
</div>
{/* Simple lightbox preview */}
{previewPhoto && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-6"
onClick={() => setPreviewPhoto(null)}
>
<button className="absolute right-4 top-4 rounded p-2 text-white hover:bg-white/10">
<X className="h-6 w-6" />
</button>
<div className="max-h-full max-w-full" onClick={(e) => e.stopPropagation()}>
<AdminAuthenticatedImage
src={`/admin/photos/${selectedEventId}/photo/${previewPhoto.id}`}
alt={previewPhoto.filename}
className="max-h-[80vh] max-w-full rounded object-contain"
/>
<div className="mt-3 flex items-center justify-center gap-3">
<span className="text-sm text-white/80">{previewPhoto.original_filename || previewPhoto.filename}</span>
<Button
size="sm"
variant={selected.has(previewPhoto.id) ? 'outline' : 'primary'}
onClick={() => togglePhoto(previewPhoto)}
>
{selected.has(previewPhoto.id) ? t('transfers.picker.deselect', 'Deselect') : t('transfers.picker.select', 'Select')}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
@@ -64,6 +64,9 @@ export const DEFAULT_FLAGS: FeatureFlags = {
whatsapp: false,
// Live Slideshow ("Diashow") — opt-in; gates all slideshow admin UI.
slideshow: false,
// PicTransfer — opt-in; gates the Transfers sidebar entry, the
// /admin/transfers area and the public recipient/upload pages.
transfers: false,
// Workflow / automation engine — opt-in; gates the Workflows admin area
// and the engine runtime (triggers/actions/gates).
workflows: false,
@@ -23,6 +23,7 @@ import {
Wallet,
FolderKanban,
MonitorPlay,
Send,
Workflow,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -133,6 +134,20 @@ export const FeaturesTab: React.FC = () => {
enabled={staged.slideshow}
onToggle={(next) => setFlag('slideshow', next)}
/>
<FeatureCard
icon={Send}
title={t('settings.features.transfers.title', 'PicTransfer')}
description={t(
'settings.features.transfers.description',
'Send original files from any event(s) as a secure, token-protected download link, with an optional client-upload code so clients can send you logos and files back. Strictly opt-in.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.transfers.sidebar', 'PicTransfer')}
enabled={staged.transfers}
onToggle={(next) => setFlag('transfers', next)}
/>
</Section>
{/* Automation — the visual workflow engine. Master kill-switch for the
+112
View File
@@ -203,6 +203,7 @@
"settings": "Einstellungen",
"systemHealth": "Systemzustand",
"archives": "Archive",
"transfers": "PicTransfer",
"emailSettings": "E-Mail-Einstellungen",
"branding": "Markenidentität",
"eventTypes": "Veranstaltungstypen",
@@ -215,6 +216,112 @@
"workflows": "Workflows",
"betaTag": "Beta"
},
"transfers": {
"title": "PicTransfer",
"subtitle": "Originaldateien aus beliebigen Events als Download-Link versenden.",
"new": "Neuer Transfer",
"create": "Transfer erstellen",
"created": "Transfer erstellt",
"createFailed": "Transfer konnte nicht erstellt werden",
"empty": "Noch keine Transfers. Erstellen Sie einen, um Dateien zu teilen.",
"untitled": "Unbenannter Transfer",
"linkCopied": "Link in die Zwischenablage kopiert",
"copyFailed": "Link konnte nicht kopiert werden",
"copyLink": "Link kopieren",
"downloadAll": "Alle herunterladen",
"expiresOn": "Läuft ab",
"disableLink": "Link deaktivieren",
"reactivate": "Reaktivieren (14 Tage)",
"reactivated": "Link reaktiviert",
"disabled": "Link deaktiviert",
"filesAdded": "Dateien hinzugefügt",
"deleted": "Transfer gelöscht",
"deleteConfirmTitle": "Transfer löschen?",
"deleteConfirmBody": "Dies entfernt den Link und alle Kunden-Uploads. Die Fotos der Quell-Events sind nicht betroffen.",
"addImages": "Bilder hinzufügen",
"clientUpload": "Kunden-Upload",
"disableUploads": "Deaktivieren",
"enableUploads": "Upload-Link aktivieren",
"uploadEnabled": "Upload-Link aktiviert",
"noUploads": "Der Kunde hat noch keine Dateien hochgeladen.",
"uploadHint": "Aktivieren Sie dies, um dem Kunden einen 6-stelligen Code zu geben, mit dem er Ihnen Dateien (Logos etc.) senden kann.",
"createAndSend": "Erstellen & senden",
"uploadedFiles": "Hochgeladene Dateien",
"addFiles": "Dateien hinzufügen",
"noUploadedFiles": "Keine hochgeladenen Dateien. Fügen Sie Dateien von Ihrem Computer hinzu, um sie in den Download aufzunehmen.",
"sentTo": "Per E-Mail an",
"delivery": {
"link": "Link teilen",
"email": "Per E-Mail senden"
},
"col": {
"title": "Titel",
"files": "Dateien",
"status": "Status",
"downloads": "Downloads",
"expires": "Läuft ab",
"uploads": "Uploads"
},
"status": {
"active": "Aktiv",
"expired": "Abgelaufen",
"deleted": "Gelöscht"
},
"field": {
"title": "Titel",
"titlePlaceholder": "z. B. Hochzeitsfinals für Familie Schmidt",
"message": "Nachricht (optional)",
"messagePlaceholder": "Wird dem Empfänger auf der Download-Seite angezeigt",
"expiresInDays": "Link aktiv für (Tage)",
"maxDownloads": "Max. Downloads (0 = unbegrenzt)",
"allowUploads": "Dem Kunden zusätzlich einen Upload-Link geben (für Logos etc.)",
"files": "Dateien",
"noFiles": "Noch keine Bilder ausgewählt.",
"uploadFiles": "Eigene Dateien hochladen",
"chooseFiles": "Dateien auswählen",
"noUploadFiles": "Optional Dateien von Ihrem Computer hinzufügen, die mitgesendet werden.",
"delivery": "Zustellung",
"recipients": "E-Mail-Adressen der Empfänger",
"recipientsPlaceholder": "[email protected], [email protected]",
"recipientsCount": "{{count}} Empfänger — jeder erhält den Download-Link",
"recipientsHint": "Mehrere Adressen durch Kommas trennen. Jeder Empfänger erhält den Download-Link."
},
"picker": {
"title": "Bilder aus anderen Events auswählen",
"searchEvents": "Events suchen…",
"pickEvent": "Wählen Sie ein Event, um dessen Fotos zu durchsuchen",
"noPhotos": "Keine Fotos in diesem Event",
"select": "Auswählen",
"deselect": "Abwählen",
"alreadyAdded": "Hinzugefügt",
"selectedCount": "{{count}} ausgewählt",
"addSelected": "Auswahl hinzufügen",
"lightbox": "Lightbox"
},
"public": {
"notFoundTitle": "Link nicht gefunden",
"notFoundBody": "Dieser Transfer-Link ist ungültig oder wurde entfernt.",
"expiredTitle": "Dieser Link ist abgelaufen",
"expiredBody": "Bitte fordern Sie beim Absender einen neuen Link an.",
"limitTitle": "Download-Limit erreicht",
"limitBody": "Dieser Transfer hat die maximale Anzahl an Downloads erreicht.",
"availableUntil": "Verfügbar bis {{date}}",
"fileSummary": "{{count}} Dateien",
"downloadFile": "Herunterladen"
},
"upload": {
"unavailableTitle": "Upload-Link nicht verfügbar",
"unavailableBody": "Dieser Upload-Link ist ungültig oder abgelaufen.",
"doneTitle": "Vielen Dank!",
"doneBody": "Ihre Dateien wurden erfolgreich hochgeladen.",
"uploadMore": "Weitere hochladen",
"dropzone": "Zum Auswählen klicken oder Dateien hierher ziehen",
"limits": "Bis zu {{files}} Dateien, je {{mb}} MB",
"tooBig": "Jede Datei darf höchstens {{mb}} MB groß sein",
"send": "{{count}} Dateien hochladen",
"failed": "Upload fehlgeschlagen. Bitte erneut versuchen."
}
},
"workflows": {
"title": "Workflows",
"subtitle": "Visuelle Automatisierungen Auslöser, Bedingungen, Freigaben und Aktionen.",
@@ -1993,6 +2100,11 @@
"title": "Live-Diashow",
"description": "Ein separater Vollbild-„Diashow“-Link pro Event für Beamer bei Live-Events übernimmt neue Uploads automatisch, mit Voreinstellungen je Event-Typ und globalen Wasserzeichen-Vorgaben unter Einstellungen → Diashow."
},
"transfers": {
"title": "PicTransfer",
"description": "Originaldateien aus beliebigen Events als sicheren, Token-geschützten Download-Link versenden mit optionalem Kunden-Upload-Code, über den Kunden Ihnen Logos und Dateien zurücksenden können. Strikt optional.",
"sidebar": "PicTransfer"
},
"workflows": {
"title": "Workflows",
"description": "Visuelle Automatisierungen auf einer Canvas erstellen Auslöser, Bedingungen, Verzweigungen, Schleifen und Freigabe-Gates für Admins. Deine Mahnstufen und Buchungsschritte werden zu bearbeitbaren Abläufen. Strikt optional.",
+112
View File
@@ -200,6 +200,7 @@
"dashboard": "Dashboard",
"events": "Events",
"archives": "Archives",
"transfers": "PicTransfer",
"messages": "Messages",
"settings": "Settings",
"systemHealth": "System health",
@@ -215,6 +216,112 @@
"workflows": "Workflows",
"betaTag": "Beta"
},
"transfers": {
"title": "PicTransfer",
"subtitle": "Send original files from any event as a download link.",
"new": "New transfer",
"create": "Create transfer",
"created": "Transfer created",
"createFailed": "Could not create transfer",
"empty": "No transfers yet. Create one to share files.",
"untitled": "Untitled transfer",
"linkCopied": "Link copied to clipboard",
"copyFailed": "Could not copy link",
"copyLink": "Copy link",
"downloadAll": "Download all",
"expiresOn": "Expires",
"disableLink": "Disable link",
"reactivate": "Re-activate (14 days)",
"reactivated": "Link re-activated",
"disabled": "Link disabled",
"filesAdded": "Files added",
"deleted": "Transfer deleted",
"deleteConfirmTitle": "Delete transfer?",
"deleteConfirmBody": "This removes the link and any client uploads. Source event photos are not affected.",
"addImages": "Add images",
"clientUpload": "Client upload",
"disableUploads": "Disable",
"enableUploads": "Enable upload link",
"uploadEnabled": "Upload link enabled",
"noUploads": "No files uploaded by the client yet.",
"uploadHint": "Enable this to give the client a 6-character code to send you files (logos etc.).",
"createAndSend": "Create & send",
"uploadedFiles": "Uploaded files",
"addFiles": "Add files",
"noUploadedFiles": "No uploaded files. Add files from your computer to include them in the download.",
"sentTo": "Emailed to",
"delivery": {
"link": "Share a link",
"email": "Send by email"
},
"col": {
"title": "Title",
"files": "Files",
"status": "Status",
"downloads": "Downloads",
"expires": "Expires",
"uploads": "Uploads"
},
"status": {
"active": "Active",
"expired": "Expired",
"deleted": "Deleted"
},
"field": {
"title": "Title",
"titlePlaceholder": "e.g. Wedding finals for the Smiths",
"message": "Message (optional)",
"messagePlaceholder": "Shown to the recipient on the download page",
"expiresInDays": "Link active for (days)",
"maxDownloads": "Max downloads (0 = unlimited)",
"allowUploads": "Also give the client an upload link (to send logos etc.)",
"files": "Files",
"noFiles": "No images selected yet.",
"uploadFiles": "Upload your own files",
"chooseFiles": "Choose files",
"noUploadFiles": "Optionally add files from your computer to send along.",
"delivery": "Delivery",
"recipients": "Recipient email addresses",
"recipientsPlaceholder": "[email protected], [email protected]",
"recipientsCount": "{{count}} recipient(s) — each gets the download link",
"recipientsHint": "Separate multiple addresses with commas. Each recipient gets the download link."
},
"picker": {
"title": "Select images from other events",
"searchEvents": "Search events…",
"pickEvent": "Pick an event to browse its photos",
"noPhotos": "No photos in this event",
"select": "Select",
"deselect": "Deselect",
"alreadyAdded": "Added",
"selectedCount": "{{count}} selected",
"addSelected": "Add selected",
"lightbox": "Lightbox"
},
"public": {
"notFoundTitle": "Link not found",
"notFoundBody": "This transfer link is invalid or has been removed.",
"expiredTitle": "This link has expired",
"expiredBody": "Please ask the sender for a new link.",
"limitTitle": "Download limit reached",
"limitBody": "This transfer has reached its maximum number of downloads.",
"availableUntil": "Available until {{date}}",
"fileSummary": "{{count}} files",
"downloadFile": "Download"
},
"upload": {
"unavailableTitle": "Upload link unavailable",
"unavailableBody": "This upload link is invalid or has expired.",
"doneTitle": "Thank you!",
"doneBody": "Your files were uploaded successfully.",
"uploadMore": "Upload more",
"dropzone": "Click to choose files or drag them here",
"limits": "Up to {{files}} files, {{mb}} MB each",
"tooBig": "Each file must be {{mb}} MB or smaller",
"send": "Upload {{count}} files",
"failed": "Upload failed. Please try again."
}
},
"workflows": {
"title": "Workflows",
"subtitle": "Visual automations — triggers, conditions, gates and actions.",
@@ -1538,6 +1645,11 @@
"title": "Live Slideshow",
"description": "A separate fullscreen \"Diashow\" link per event for projectors at live events — auto-picks-up new uploads, with per-event-type presets and global watermark defaults under Settings → Slideshow."
},
"transfers": {
"title": "PicTransfer",
"description": "Send original files from any event(s) as a secure, token-protected download link, with an optional client-upload code so clients can send you logos and files back. Strictly opt-in.",
"sidebar": "PicTransfer"
},
"workflows": {
"title": "Workflows",
"description": "Build visual automations on a canvas — triggers, conditions, branches, loops and admin approval gates. Your reminder ladder and booking steps become editable flows. Strictly opt-in.",
@@ -0,0 +1,655 @@
/**
* Admin → PicTransfer page (#997).
*
* List of transfers + a create flow (with the cross-event image picker) + a
* detail panel to manage files, the recipient link, the client-upload link and
* retention. Recipient downloads always contain ORIGINAL files.
*/
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import {
Plus, Send, Link2, Download, Trash2, Upload, X, Copy, Image as ImageIcon,
Clock, Ban, RefreshCw, Mail, Paperclip, FileText,
} from 'lucide-react';
import { Button, Input, Card, CardContent, Loading, useConfirm } from '../../../components/common';
import { AdminAuthenticatedImage } from '../../../components/admin/AdminAuthenticatedImage';
import { TransferPhotoPicker, type PickedPhoto } from '../../../components/admin/TransferPhotoPicker';
import { useMutationWithToast } from '../../../hooks/useMutationWithToast';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { transfersService } from '../../../services/transfers.service';
function formatBytes(bytes: number | null | undefined): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function recipientUrl(token: string): string {
return `${window.location.origin}/transfer/${token}`;
}
function uploadUrl(uploadToken: string): string {
return `${window.location.origin}/transfer-upload/${uploadToken}`;
}
const STATUS_STYLES: Record<string, string> = {
active: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
expired: 'bg-neutral-200 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300',
deleted: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
};
export const TransfersPage: React.FC = () => {
const { t } = useTranslation();
const confirm = useConfirm();
const { formatDateTime } = useLocalizedDate();
const fmtDate = (d: string | null) => (d ? formatDateTime(d) : '—');
const [showCreate, setShowCreate] = useState(false);
const [detailId, setDetailId] = useState<number | null>(null);
const { data: transfers, isLoading, refetch } = useQuery({
queryKey: ['admin-transfers'],
queryFn: () => transfersService.list(),
});
const copyLink = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t('transfers.linkCopied', 'Link copied to clipboard'));
} catch {
toast.error(t('transfers.copyFailed', 'Could not copy link'));
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold text-neutral-900 dark:text-neutral-100">
<Send className="h-6 w-6" /> {t('transfers.title', 'PicTransfer')}
</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{t('transfers.subtitle', 'Send original files from any event as a download link.')}
</p>
</div>
<Button leftIcon={<Plus className="h-4 w-4" />} onClick={() => setShowCreate(true)}>
{t('transfers.new', 'New transfer')}
</Button>
</div>
{isLoading ? (
<Loading />
) : !transfers || transfers.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-neutral-500">
<Send className="mx-auto mb-3 h-10 w-10 text-neutral-300" />
<p>{t('transfers.empty', 'No transfers yet. Create one to share files.')}</p>
</CardContent>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 text-left text-neutral-500 dark:border-neutral-700">
<th className="px-4 py-3 font-medium">{t('transfers.col.title', 'Title')}</th>
<th className="px-4 py-3 font-medium">{t('transfers.col.files', 'Files')}</th>
<th className="px-4 py-3 font-medium">{t('transfers.col.status', 'Status')}</th>
<th className="px-4 py-3 font-medium">{t('transfers.col.downloads', 'Downloads')}</th>
<th className="px-4 py-3 font-medium">{t('transfers.col.expires', 'Expires')}</th>
<th className="px-4 py-3 font-medium">{t('transfers.col.uploads', 'Uploads')}</th>
</tr>
</thead>
<tbody>
{transfers.map((tr) => (
<tr
key={tr.id}
className="cursor-pointer border-b border-neutral-100 hover:bg-neutral-50 dark:border-neutral-800 dark:hover:bg-neutral-800/50"
onClick={() => setDetailId(tr.id)}
>
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
{tr.title || t('transfers.untitled', 'Untitled transfer')}
</td>
<td className="px-4 py-3">{tr.file_count}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_STYLES[tr.status] || ''}`}>
{t(`transfers.status.${tr.status}`, tr.status)}
</span>
</td>
<td className="px-4 py-3">
{tr.download_count}{tr.max_downloads ? ` / ${tr.max_downloads}` : ''}
</td>
<td className="px-4 py-3 text-neutral-500 dark:text-neutral-400">{fmtDate(tr.expires_at)}</td>
<td className="px-4 py-3">{tr.allow_uploads ? tr.upload_count : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{showCreate && (
<CreateTransferModal
onClose={() => setShowCreate(false)}
onCreated={() => { setShowCreate(false); refetch(); }}
/>
)}
{detailId !== null && (
<TransferDetailModal
transferId={detailId}
onClose={() => { setDetailId(null); refetch(); }}
onCopy={copyLink}
confirm={confirm}
/>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Create modal
// ---------------------------------------------------------------------------
const CreateTransferModal: React.FC<{ onClose: () => void; onCreated: () => void }> = ({ onClose, onCreated }) => {
const { t } = useTranslation();
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const [expiresInDays, setExpiresInDays] = useState('14');
const [maxDownloads, setMaxDownloads] = useState('');
const [allowUploads, setAllowUploads] = useState(false);
const [picked, setPicked] = useState<PickedPhoto[]>([]);
const [showPicker, setShowPicker] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [deliveryMethod, setDeliveryMethod] = useState<'link' | 'email'>('link');
const [emails, setEmails] = useState('');
// Split the free-text recipient field on comma / semicolon / whitespace and
// keep only well-formed addresses. Used both to send and to gate the button.
const parsedEmails = emails
.split(/[,;\s]+/)
.map((e) => e.trim())
.filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
const createMutation = useMutationWithToast({
mutationFn: () => transfersService.create({
title: title.trim(),
message: message.trim() || null,
expiresInDays: parseInt(expiresInDays, 10) || 14,
maxDownloads: maxDownloads ? parseInt(maxDownloads, 10) : null,
allowUploads,
photoIds: picked.map((p) => p.id),
files,
deliveryMethod,
recipientEmails: deliveryMethod === 'email' ? parsedEmails : [],
}),
successMessage: t('transfers.created', 'Transfer created'),
errorMessage: t('transfers.createFailed', 'Could not create transfer'),
onSuccess: onCreated,
});
const addFilesToList = (list: FileList | null) => {
if (!list || !list.length) return;
setFiles((prev) => [...prev, ...Array.from(list)]);
};
const addPicked = (photos: PickedPhoto[]) => {
setPicked((prev) => {
const map = new Map(prev.map((p) => [p.id, p]));
photos.forEach((p) => map.set(p.id, p));
return Array.from(map.values());
});
setShowPicker(false);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg bg-white shadow-xl dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3 dark:border-neutral-700">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('transfers.new', 'New transfer')}</h2>
<button onClick={onClose} className="rounded p-1 text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800"><X className="h-5 w-5" /></button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<Input label={t('transfers.field.title', 'Title')} value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t('transfers.field.titlePlaceholder', 'e.g. Wedding finals for the Smiths')} />
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('transfers.field.message', 'Message (optional)')}</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-800"
placeholder={t('transfers.field.messagePlaceholder', 'Shown to the recipient on the download page')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input type="number" min={1} label={t('transfers.field.expiresInDays', 'Link active for (days)')} value={expiresInDays} onChange={(e) => setExpiresInDays(e.target.value)} />
<Input type="number" min={0} label={t('transfers.field.maxDownloads', 'Max downloads (0 = unlimited)')} value={maxDownloads} onChange={(e) => setMaxDownloads(e.target.value)} placeholder="0" />
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input type="checkbox" checked={allowUploads} onChange={(e) => setAllowUploads(e.target.checked)} className="rounded" />
{t('transfers.field.allowUploads', 'Also give the client an upload link (to send logos etc.)')}
</label>
{/* Picker entry + selection preview */}
<div className="rounded-md border border-neutral-200 p-3 dark:border-neutral-700">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('transfers.field.files', 'Files')} · {picked.length}
</span>
<Button size="sm" variant="outline" leftIcon={<ImageIcon className="h-4 w-4" />} onClick={() => setShowPicker(true)}>
{t('transfers.picker.title', 'Select images from other events')}
</Button>
</div>
{picked.length === 0 ? (
<p className="text-sm text-neutral-400">{t('transfers.field.noFiles', 'No images selected yet.')}</p>
) : (
<div className="grid grid-cols-6 gap-2">
{picked.slice(0, 18).map((p) => (
<div key={p.id} className="relative aspect-square overflow-hidden rounded">
{p.thumbnail_url ? (
<AdminAuthenticatedImage src={p.thumbnail_url} alt={p.filename} className="h-full w-full object-cover" />
) : <div className="h-full w-full bg-neutral-100 dark:bg-neutral-800" />}
<button
onClick={() => setPicked((prev) => prev.filter((x) => x.id !== p.id))}
className="absolute right-0.5 top-0.5 rounded-full bg-black/60 p-0.5 text-white"
><X className="h-3 w-3" /></button>
</div>
))}
{picked.length > 18 && (
<div className="flex aspect-square items-center justify-center rounded bg-neutral-100 text-xs text-neutral-500 dark:bg-neutral-800">
+{picked.length - 18}
</div>
)}
</div>
)}
</div>
{/* Upload your own files (deliverables not tied to an event) */}
<div className="rounded-md border border-neutral-200 p-3 dark:border-neutral-700">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('transfers.field.uploadFiles', 'Upload your own files')} · {files.length}
</span>
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1.5 text-sm text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600 dark:text-neutral-300 dark:hover:bg-neutral-800">
<Upload className="h-4 w-4" />
{t('transfers.field.chooseFiles', 'Choose files')}
<input
type="file"
multiple
className="hidden"
onChange={(e) => { addFilesToList(e.target.files); e.target.value = ''; }}
/>
</label>
</div>
{files.length === 0 ? (
<p className="text-sm text-neutral-400">{t('transfers.field.noUploadFiles', 'Optionally add files from your computer to send along.')}</p>
) : (
<ul className="divide-y divide-neutral-100 dark:divide-neutral-800">
{files.map((f, idx) => (
<li key={`${f.name}-${idx}`} className="flex items-center justify-between py-1.5 text-sm">
<span className="flex min-w-0 items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-neutral-400" />
<span className="truncate text-neutral-700 dark:text-neutral-300">{f.name}</span>
</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, i) => i !== idx))}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-700"
><X className="h-3.5 w-3.5" /></button>
</li>
))}
</ul>
)}
</div>
{/* Delivery: copy a link yourself, or email it to recipients */}
<div className="rounded-md border border-neutral-200 p-3 dark:border-neutral-700">
<span className="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('transfers.field.delivery', 'Delivery')}
</span>
<div className="flex gap-2">
<Button
type="button"
variant={deliveryMethod === 'link' ? 'primary' : 'outline'}
className="flex-1"
leftIcon={<Link2 className="h-4 w-4" />}
onClick={() => setDeliveryMethod('link')}
>
{t('transfers.delivery.link', 'Share a link')}
</Button>
<Button
type="button"
variant={deliveryMethod === 'email' ? 'primary' : 'outline'}
className="flex-1"
leftIcon={<Mail className="h-4 w-4" />}
onClick={() => setDeliveryMethod('email')}
>
{t('transfers.delivery.email', 'Send by email')}
</Button>
</div>
{deliveryMethod === 'email' && (
<div className="mt-3">
<label className="mb-1 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('transfers.field.recipients', 'Recipient email addresses')}
</label>
<textarea
value={emails}
onChange={(e) => setEmails(e.target.value)}
rows={2}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-800"
placeholder={t('transfers.field.recipientsPlaceholder', '[email protected], [email protected]')}
/>
<p className="mt-1 text-xs text-neutral-400">
{parsedEmails.length > 0
? t('transfers.field.recipientsCount', '{{count}} recipient(s) — each gets the download link', { count: parsedEmails.length })
: t('transfers.field.recipientsHint', 'Separate multiple addresses with commas. Each recipient gets the download link.')}
</p>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 px-5 py-3 dark:border-neutral-700">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button
onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending}
disabled={
(picked.length === 0 && files.length === 0 && !allowUploads)
|| (deliveryMethod === 'email' && parsedEmails.length === 0)
}
>
{deliveryMethod === 'email'
? t('transfers.createAndSend', 'Create & send')
: t('transfers.create', 'Create transfer')}
</Button>
</div>
</div>
{showPicker && (
<TransferPhotoPicker
onClose={() => setShowPicker(false)}
onConfirm={addPicked}
excludePhotoIds={picked.map((p) => p.id)}
/>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Detail modal
// ---------------------------------------------------------------------------
interface DetailProps {
transferId: number;
onClose: () => void;
onCopy: (text: string) => void;
confirm: ReturnType<typeof useConfirm>;
}
const TransferDetailModal: React.FC<DetailProps> = ({ transferId, onClose, onCopy, confirm }) => {
const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const fmtDate = (d: string | null) => (d ? formatDateTime(d) : '—');
const [showPicker, setShowPicker] = useState(false);
const { data: transfer, isLoading, refetch } = useQuery({
queryKey: ['admin-transfer', transferId],
queryFn: () => transfersService.get(transferId),
});
const addFilesMutation = useMutationWithToast({
mutationFn: (photoIds: number[]) => transfersService.addFiles(transferId, photoIds),
successMessage: t('transfers.filesAdded', 'Files added'),
onSuccess: () => refetch(),
});
const removeFileMutation = useMutationWithToast({
mutationFn: (fileId: number) => transfersService.removeFile(transferId, fileId),
onSuccess: () => refetch(),
});
const disableMutation = useMutationWithToast({
mutationFn: () => transfersService.update(transferId, { isActive: false }),
successMessage: t('transfers.disabled', 'Link disabled'),
onSuccess: () => refetch(),
});
const reactivateMutation = useMutationWithToast({
mutationFn: () => transfersService.update(transferId, { isActive: true, expiresInDays: 14 }),
successMessage: t('transfers.reactivated', 'Link re-activated'),
onSuccess: () => refetch(),
});
const enableUploadsMutation = useMutationWithToast({
mutationFn: () => transfersService.enableUploads(transferId),
successMessage: t('transfers.uploadEnabled', 'Upload link enabled'),
onSuccess: () => refetch(),
});
const disableUploadsMutation = useMutationWithToast({
mutationFn: () => transfersService.disableUploads(transferId),
onSuccess: () => refetch(),
});
const deleteMutation = useMutationWithToast({
mutationFn: () => transfersService.remove(transferId),
successMessage: t('transfers.deleted', 'Transfer deleted'),
onSuccess: onClose,
});
const uploadFilesMutation = useMutationWithToast({
mutationFn: (list: File[]) => transfersService.uploadFiles(transferId, list),
successMessage: t('transfers.filesAdded', 'Files added'),
onSuccess: () => refetch(),
});
const removeExtraFileMutation = useMutationWithToast({
mutationFn: (extraId: number) => transfersService.removeExtraFile(transferId, extraId),
onSuccess: () => refetch(),
});
const handleDelete = async () => {
const ok = await confirm({
title: t('transfers.deleteConfirmTitle', 'Delete transfer?'),
message: t('transfers.deleteConfirmBody', 'This removes the link and any client uploads. Source event photos are not affected.'),
variant: 'danger',
});
if (ok) deleteMutation.mutate();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[92vh] w-full max-w-3xl flex-col overflow-hidden rounded-lg bg-white shadow-xl dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3 dark:border-neutral-700">
<h2 className="truncate text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{transfer?.title || t('transfers.untitled', 'Untitled transfer')}
</h2>
<button onClick={onClose} className="rounded p-1 text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800"><X className="h-5 w-5" /></button>
</div>
{isLoading || !transfer ? (
<div className="p-8"><Loading /></div>
) : (
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-5">
{/* Prominent recipient link + download-all, up top */}
<div className="rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-700 dark:bg-neutral-800/50">
<div className="flex flex-wrap items-center gap-2">
<Input readOnly value={recipientUrl(transfer.token)} className="flex-1 min-w-[220px]" />
<Button variant="outline" leftIcon={<Copy className="h-4 w-4" />} onClick={() => onCopy(recipientUrl(transfer.token))}>
{t('transfers.copyLink', 'Copy link')}
</Button>
<a href={transfersService.adminDownloadUrl(transfer.id)}>
<Button leftIcon={<Download className="h-4 w-4" />}>{t('transfers.downloadAll', 'Download all')}</Button>
</a>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-sm text-neutral-600 dark:text-neutral-400">
<span className="flex items-center gap-1"><Clock className="h-4 w-4" /> {t('transfers.expiresOn', 'Expires')}: {fmtDate(transfer.expires_at)}</span>
<span>{t('transfers.col.downloads', 'Downloads')}: {transfer.download_count}{transfer.max_downloads ? ` / ${transfer.max_downloads}` : ''}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_STYLES[transfer.status] || ''}`}>{t(`transfers.status.${transfer.status}`, transfer.status)}</span>
</div>
<div className="mt-3 flex gap-2">
{transfer.is_active ? (
<Button size="sm" variant="outline" leftIcon={<Ban className="h-4 w-4" />} onClick={() => disableMutation.mutate()} isLoading={disableMutation.isPending}>
{t('transfers.disableLink', 'Disable link')}
</Button>
) : (
<Button size="sm" variant="outline" leftIcon={<RefreshCw className="h-4 w-4" />} onClick={() => reactivateMutation.mutate()} isLoading={reactivateMutation.isPending}>
{t('transfers.reactivate', 'Re-activate (14 days)')}
</Button>
)}
<Button size="sm" variant="ghost" className="text-red-600" leftIcon={<Trash2 className="h-4 w-4" />} onClick={handleDelete}>
{t('common.delete', 'Delete')}
</Button>
</div>
</div>
{/* Files */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">{t('transfers.field.files', 'Files')} · {transfer.file_count}</h3>
<Button size="sm" variant="outline" leftIcon={<ImageIcon className="h-4 w-4" />} onClick={() => setShowPicker(true)}>
{t('transfers.addImages', 'Add images')}
</Button>
</div>
{transfer.files && transfer.files.length > 0 ? (
<div className="grid grid-cols-4 gap-3 sm:grid-cols-6">
{transfer.files.map((f) => (
<div key={f.file_id} className="group relative aspect-square overflow-hidden rounded">
<AdminAuthenticatedImage src={f.thumbnail_url} alt={f.filename} className="h-full w-full object-cover" />
<button
onClick={() => removeFileMutation.mutate(f.file_id)}
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 transition group-hover:opacity-100"
title={t('common.remove', 'Remove')}
><X className="h-3 w-3" /></button>
<span className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-1 py-0.5 text-[10px] text-white" title={f.event_name}>{f.event_name}</span>
</div>
))}
</div>
) : (
<p className="text-sm text-neutral-400">{t('transfers.field.noFiles', 'No images selected yet.')}</p>
)}
</div>
{/* Admin-uploaded deliverable files */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold text-neutral-700 dark:text-neutral-300">
<Paperclip className="h-4 w-4" /> {t('transfers.uploadedFiles', 'Uploaded files')} · {transfer.extra_files?.length || 0}
</h3>
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1.5 text-sm text-neutral-700 hover:bg-neutral-50 dark:border-neutral-600 dark:text-neutral-300 dark:hover:bg-neutral-800">
<Upload className="h-4 w-4" />
{t('transfers.addFiles', 'Add files')}
<input
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) uploadFilesMutation.mutate(Array.from(e.target.files));
e.target.value = '';
}}
/>
</label>
</div>
{transfer.extra_files && transfer.extra_files.length > 0 ? (
<ul className="divide-y divide-neutral-100 dark:divide-neutral-800">
{transfer.extra_files.map((f) => (
<li key={f.id} className="flex items-center justify-between py-2 text-sm">
<span className="flex min-w-0 items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-neutral-400" />
<span className="truncate text-neutral-700 dark:text-neutral-300">{f.filename}</span>
</span>
<span className="flex shrink-0 items-center gap-3 text-neutral-500">
<span>{formatBytes(f.size_bytes)}</span>
<a href={transfersService.adminExtraFileDownloadUrl(transferId, f.id)} className="text-primary-600 hover:underline">
<Download className="h-4 w-4" />
</a>
<button
onClick={() => removeExtraFileMutation.mutate(f.id)}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-red-600 dark:hover:bg-neutral-700"
title={t('common.remove', 'Remove')}
><X className="h-3.5 w-3.5" /></button>
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-neutral-400">{t('transfers.noUploadedFiles', 'No uploaded files. Add files from your computer to include them in the download.')}</p>
)}
</div>
{/* Email recipients (when delivered by email) */}
{transfer.recipients && transfer.recipients.length > 0 && (
<div className="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-neutral-700 dark:text-neutral-300">
<Mail className="h-4 w-4" /> {t('transfers.sentTo', 'Emailed to')}
</h3>
<div className="flex flex-wrap gap-2">
{transfer.recipients.map((r) => (
<span key={r.id} className="rounded-full bg-neutral-100 px-2.5 py-1 text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{r.email}
</span>
))}
</div>
</div>
)}
{/* Client uploads */}
<div className="rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<div className="mb-2 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold text-neutral-700 dark:text-neutral-300">
<Upload className="h-4 w-4" /> {t('transfers.clientUpload', 'Client upload')}
</h3>
{transfer.allow_uploads ? (
<Button size="sm" variant="ghost" className="text-red-600" onClick={() => disableUploadsMutation.mutate()}>
{t('transfers.disableUploads', 'Disable')}
</Button>
) : (
<Button size="sm" variant="outline" onClick={() => enableUploadsMutation.mutate()} isLoading={enableUploadsMutation.isPending}>
{t('transfers.enableUploads', 'Enable upload link')}
</Button>
)}
</div>
{transfer.allow_uploads && transfer.upload_token ? (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded bg-neutral-100 px-3 py-1.5 font-mono text-lg tracking-widest dark:bg-neutral-800">{transfer.upload_token}</div>
<Input readOnly value={uploadUrl(transfer.upload_token)} className="flex-1 min-w-[200px]" />
<Button variant="outline" size="sm" leftIcon={<Copy className="h-4 w-4" />} onClick={() => onCopy(uploadUrl(transfer.upload_token as string))}>
{t('transfers.copyLink', 'Copy link')}
</Button>
</div>
{transfer.uploads && transfer.uploads.length > 0 ? (
<ul className="mt-3 divide-y divide-neutral-100 dark:divide-neutral-800">
{transfer.uploads.map((u) => (
<li key={u.id} className="flex items-center justify-between py-2 text-sm">
<span className="truncate">{u.original_filename}</span>
<span className="flex items-center gap-3 text-neutral-500">
<span>{formatBytes(u.size_bytes)}</span>
<a href={transfersService.adminUploadDownloadUrl(transferId, u.id)} className="text-primary-600 hover:underline">
<Download className="h-4 w-4" />
</a>
</span>
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-neutral-400">{t('transfers.noUploads', 'No files uploaded by the client yet.')}</p>
)}
</>
) : (
<p className="text-sm text-neutral-400">
{t('transfers.uploadHint', 'Enable this to give the client a 6-character code to send you files (logos etc.).')}
</p>
)}
</div>
</div>
)}
</div>
{showPicker && transfer && (
<TransferPhotoPicker
onClose={() => setShowPicker(false)}
onConfirm={(photos) => { addFilesMutation.mutate(photos.map((p) => p.id)); setShowPicker(false); }}
excludePhotoIds={(transfer.files || []).map((f) => f.photo_id)}
isSaving={addFilesMutation.isPending}
/>
)}
</div>
);
};
@@ -0,0 +1,139 @@
/**
* Public recipient download page for PicTransfer (#997).
*
* Token-only (no auth). By design there are NO thumbnails — just filenames,
* sizes and a prominent "Download all" button, plus per-file download.
* Files served are always ORIGINALS.
*
* Styling reads the branding theme CSS variables (`--color-*`) rather than
* Tailwind neutral/`dark:` utilities: this is a public page, so it never gets
* the admin `.dark` class — the only theming that reaches it is the instance
* branding (colours + light/dark) applied by GlobalThemeProvider. Reading the
* vars keeps it on-brand and correct in both light and dark.
*/
import React from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Download, FileDown, Clock, PackageOpen, AlertCircle } from 'lucide-react';
import { Button, Loading } from '../../components/common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { transfersService } from '../../services/transfers.service';
function formatBytes(bytes: number | null | undefined): string {
if (!bytes) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
export const TransferDownloadPage: React.FC = () => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { token } = useParams<{ token: string }>();
const { data, isLoading, isError } = useQuery({
queryKey: ['public-transfer', token],
queryFn: () => transfersService.getPublic(token as string),
enabled: !!token,
retry: false,
});
const wrap = (children: React.ReactNode) => (
<div
className="flex min-h-screen items-center justify-center p-4"
style={{ backgroundColor: 'var(--color-background)', color: 'var(--color-text)' }}
>
<div
className="w-full max-w-lg rounded-lg border shadow-sm"
style={{ backgroundColor: 'var(--color-surface)', borderColor: 'var(--color-surface-border)' }}
>
{children}
</div>
</div>
);
const muted = { color: 'var(--color-muted-text)' } as const;
if (isLoading) return wrap(<div className="p-6"><Loading /></div>);
if (isError || !data) {
return wrap(
<div className="p-8 text-center">
<AlertCircle className="mx-auto mb-3 h-12 w-12" style={muted} />
<h1 className="text-xl font-semibold">{t('transfers.public.notFoundTitle', 'Link not found')}</h1>
<p className="mt-2" style={muted}>{t('transfers.public.notFoundBody', 'This transfer link is invalid or has been removed.')}</p>
</div>,
);
}
if (!data.downloadable) {
const isLimit = data.status === 'limit_reached';
return wrap(
<div className="p-8 text-center">
<Clock className="mx-auto mb-3 h-12 w-12" style={muted} />
<h1 className="text-xl font-semibold">
{isLimit ? t('transfers.public.limitTitle', 'Download limit reached') : t('transfers.public.expiredTitle', 'This link has expired')}
</h1>
<p className="mt-2" style={muted}>
{isLimit
? t('transfers.public.limitBody', 'This transfer has reached its maximum number of downloads.')
: t('transfers.public.expiredBody', 'Please ask the sender for a new link.')}
</p>
</div>,
);
}
return wrap(
<div className="p-6">
<div className="mb-5 text-center">
<PackageOpen className="mx-auto mb-2 h-10 w-10" style={{ color: 'var(--color-accent)' }} />
<h1 className="text-2xl font-bold">{data.title}</h1>
{data.message && <p className="mt-2 whitespace-pre-line" style={muted}>{data.message}</p>}
<p className="mt-2 text-sm" style={muted}>
{t('transfers.public.fileSummary', '{{count}} files', { count: data.file_count || 0 })}
{data.total_bytes ? ` · ${formatBytes(data.total_bytes)}` : ''}
</p>
</div>
{/* Prominent download-all */}
<a href={transfersService.publicDownloadAllUrl(token as string)} className="block">
<Button size="lg" leftIcon={<Download className="h-5 w-5" />} className="w-full">
{t('transfers.downloadAll', 'Download all')}
</Button>
</a>
{data.files && data.files.length > 0 && (
<ul className="mt-5 border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
{data.files.map((f) => (
<li
key={f.file_id}
className="flex items-center justify-between border-b py-2.5 text-sm"
style={{ borderColor: 'var(--color-surface-border)' }}
>
<span className="mr-3 truncate">{f.filename}</span>
<span className="flex shrink-0 items-center gap-3" style={muted}>
{f.size_bytes ? <span>{formatBytes(f.size_bytes)}</span> : null}
<a
href={transfersService.publicFileUrl(token as string, f.file_id)}
className="rounded p-1.5 hover:opacity-70"
style={{ color: 'var(--color-accent)' }}
title={t('transfers.public.downloadFile', 'Download')}
>
<FileDown className="h-4 w-4" />
</a>
</span>
</li>
))}
</ul>
)}
{data.expires_at && (
<p className="mt-5 text-center text-xs" style={muted}>
{t('transfers.public.availableUntil', 'Available until {{date}}', { date: format(data.expires_at) })}
</p>
)}
</div>,
);
};
@@ -0,0 +1,189 @@
/**
* Public client-upload page for PicTransfer (#997).
*
* Token-only (6-char code, no auth). Lets a client send files back to the
* photographer (logos etc.). Reached via /transfer-upload/:token.
*
* Like the recipient download page, styling reads the branding theme CSS
* variables (`--color-*`) rather than Tailwind `dark:` utilities — a public
* page never gets the admin `.dark` class, so the branding theme (applied by
* GlobalThemeProvider) is what must drive colours and light/dark here.
*/
import React, { useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { UploadCloud, CheckCircle, AlertCircle, X, File as FileIcon } from 'lucide-react';
import { Button, Loading } from '../../components/common';
import { transfersService } from '../../services/transfers.service';
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
export const TransferUploadPage: React.FC = () => {
const { t } = useTranslation();
const { token } = useParams<{ token: string }>();
const inputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<File[]>([]);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const [done, setDone] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ['transfer-upload-info', token],
queryFn: () => transfersService.getUploadInfo(token as string),
enabled: !!token,
retry: false,
});
const muted = { color: 'var(--color-muted-text)' } as const;
const wrap = (children: React.ReactNode) => (
<div
className="flex min-h-screen items-center justify-center p-4"
style={{ backgroundColor: 'var(--color-background)', color: 'var(--color-text)' }}
>
<div
className="w-full max-w-lg rounded-lg border shadow-sm"
style={{ backgroundColor: 'var(--color-surface)', borderColor: 'var(--color-surface-border)' }}
>
{children}
</div>
</div>
);
if (isLoading) return wrap(<div className="p-6"><Loading /></div>);
if (isError || !data) {
return wrap(
<div className="p-8 text-center">
<AlertCircle className="mx-auto mb-3 h-12 w-12" style={muted} />
<h1 className="text-xl font-semibold">{t('transfers.upload.unavailableTitle', 'Upload link unavailable')}</h1>
<p className="mt-2" style={muted}>{t('transfers.upload.unavailableBody', 'This upload link is invalid or has expired.')}</p>
</div>,
);
}
const addFiles = (list: FileList | null) => {
if (!list) return;
const incoming = Array.from(list);
setFiles((prev) => {
const merged = [...prev, ...incoming].slice(0, data.max_files);
return merged;
});
};
const handleUpload = async () => {
if (!files.length) return;
const tooBig = files.find((f) => f.size > data.max_size_mb * 1024 * 1024);
if (tooBig) {
toast.error(t('transfers.upload.tooBig', 'Each file must be {{mb}} MB or smaller', { mb: data.max_size_mb }));
return;
}
setUploading(true);
setProgress(0);
try {
await transfersService.upload(token as string, files, setProgress);
setDone(true);
} catch (err) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
|| t('transfers.upload.failed', 'Upload failed. Please try again.');
toast.error(msg);
} finally {
setUploading(false);
}
};
if (done) {
return wrap(
<div className="p-8 text-center">
<CheckCircle className="mx-auto mb-3 h-12 w-12 text-green-500" />
<h1 className="text-xl font-semibold">{t('transfers.upload.doneTitle', 'Thank you!')}</h1>
<p className="mt-2" style={muted}>{t('transfers.upload.doneBody', 'Your files were uploaded successfully.')}</p>
<Button className="mt-5" variant="outline" onClick={() => { setDone(false); setFiles([]); setProgress(0); }}>
{t('transfers.upload.uploadMore', 'Upload more')}
</Button>
</div>,
);
}
return wrap(
<div className="p-6">
<div className="mb-5 text-center">
<UploadCloud className="mx-auto mb-2 h-10 w-10" style={{ color: 'var(--color-accent)' }} />
<h1 className="text-2xl font-bold">{data.title}</h1>
{data.message && <p className="mt-2 whitespace-pre-line" style={muted}>{data.message}</p>}
<p className="mt-2 text-sm" style={muted}>
{t('transfers.upload.limits', 'Up to {{files}} files, {{mb}} MB each', { files: data.max_files, mb: data.max_size_mb })}
</p>
</div>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex w-full flex-col items-center justify-center rounded-lg border-2 border-dashed py-10 transition hover:opacity-80"
style={{ borderColor: 'var(--color-surface-border)', color: 'var(--color-muted-text)' }}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); addFiles(e.dataTransfer.files); }}
>
<UploadCloud className="mb-2 h-8 w-8" />
<span className="text-sm">{t('transfers.upload.dropzone', 'Click to choose files or drag them here')}</span>
</button>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(e) => { addFiles(e.target.files); if (inputRef.current) inputRef.current.value = ''; }}
/>
{files.length > 0 && (
<ul className="mt-4 border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
{files.map((f, idx) => (
<li
key={`${f.name}-${idx}`}
className="flex items-center justify-between border-b py-2 text-sm"
style={{ borderColor: 'var(--color-surface-border)' }}
>
<span className="flex min-w-0 items-center gap-2">
<FileIcon className="h-4 w-4 shrink-0" style={muted} />
<span className="truncate">{f.name}</span>
</span>
<span className="flex shrink-0 items-center gap-3" style={muted}>
<span>{formatBytes(f.size)}</span>
{!uploading && (
<button onClick={() => setFiles((prev) => prev.filter((_, i) => i !== idx))} className="rounded p-1 hover:opacity-70">
<X className="h-4 w-4" />
</button>
)}
</span>
</li>
))}
</ul>
)}
{uploading && (
<div className="mt-4 h-2 w-full overflow-hidden rounded-full" style={{ backgroundColor: 'var(--color-surface-border)' }}>
<div className="h-full transition-all" style={{ width: `${progress}%`, backgroundColor: 'var(--color-accent-dark)' }} />
</div>
)}
<Button
className="mt-5 w-full"
size="lg"
leftIcon={<UploadCloud className="h-5 w-5" />}
onClick={handleUpload}
disabled={files.length === 0}
isLoading={uploading}
>
{t('transfers.upload.send', 'Upload {{count}} files', { count: files.length })}
</Button>
</div>,
);
};
@@ -67,6 +67,11 @@ export type FeatureKey =
// Live Slideshow ("Diashow") — per-event fullscreen kiosk link + presets +
// global watermark settings tab. Strictly opt-in; gates all slideshow UI.
| 'slideshow'
// PicTransfer (migration 170) — cross-event file transfers:
// a token-protected recipient download link plus an optional client-upload
// channel. Strictly opt-in; gates the sidebar entry, the /admin/transfers
// area and every transfer route (admin + public).
| 'transfers'
// Workflow / automation engine — admin-configurable visual flows (triggers,
// conditions, branches, loops, approval gates) built on a canvas. Strictly
// opt-in; gates the Workflows admin area and the engine runtime.
+240
View File
@@ -0,0 +1,240 @@
/**
* Transfers API client (PicTransfer, #997).
*
* Three surfaces share one file:
* - Admin CRUD under /admin/transfers/* (cookie auth).
* - Public recipient download under /public/transfer/:token (token in URL).
* - Public client upload under /public/transfer-upload/:token (6-char token).
*/
import { api } from '../config/api';
import { getApiBaseUrl } from '../utils/url';
export interface TransferFile {
file_id: number;
photo_id: number;
filename: string;
type: string;
size_bytes: number | null;
event_id: number;
event_name: string;
event_slug: string;
thumbnail_url: string;
}
export interface TransferUpload {
id: number;
original_filename: string;
size_bytes: number | null;
mime_type: string | null;
uploader_ip: string | null;
uploaded_at: string;
}
/** An admin-uploaded deliverable file (not a referenced gallery photo). */
export interface TransferExtraFile {
id: number;
filename: string;
size_bytes: number | null;
mime_type: string | null;
}
export interface TransferRecipient {
id: number;
email: string;
last_sent_at: string | null;
}
export interface Transfer {
id: number;
token: string;
title: string;
message: string | null;
expires_at: string;
max_downloads: number | null;
download_count: number;
downloads_remaining: number | null;
is_active: boolean;
disabled_at: string | null;
grace_days: number;
deleted_at: string | null;
allow_uploads: boolean;
delivery_method: 'link' | 'email';
upload_token: string | null;
upload_expires_at: string | null;
created_at: string;
updated_at: string;
status: 'active' | 'expired' | 'deleted';
download_url: string;
upload_url: string | null;
file_count: number;
upload_count: number;
files?: TransferFile[];
extra_files?: TransferExtraFile[];
recipients?: TransferRecipient[];
uploads?: TransferUpload[];
}
export interface CreateTransferInput {
title?: string;
message?: string | null;
expiresInDays?: number;
maxDownloads?: number | null;
graceDays?: number;
allowUploads?: boolean;
uploadExpiresInDays?: number;
photoIds?: number[];
/** 'link' (default) or 'email' — email the download link to recipientEmails. */
deliveryMethod?: 'link' | 'email';
recipientEmails?: string[];
/** The operator's own files to include in the transfer as deliverables. */
files?: File[];
}
export interface UpdateTransferInput {
title?: string;
message?: string | null;
maxDownloads?: number | null;
graceDays?: number;
expiresInDays?: number;
expiresAt?: string;
isActive?: boolean;
}
// --- Public shapes ---
export interface PublicTransferFile {
// Prefixed on the server: `p<id>` = gallery photo, `x<id>` = uploaded file.
file_id: string;
filename: string;
size_bytes: number | null;
}
export interface PublicTransferView {
title: string;
message?: string | null;
status: 'active' | 'expired' | 'limit_reached';
downloadable: boolean;
expires_at: string;
file_count?: number;
total_bytes?: number;
downloads_remaining?: number | null;
files?: PublicTransferFile[];
}
export interface UploadInfo {
title: string;
message: string | null;
expires_at: string;
max_size_mb: number;
max_files: number;
allowed_mime: string[];
}
export const transfersService = {
// --- Admin ---
async list(search = ''): Promise<Transfer[]> {
const res = await api.get('/admin/transfers', { params: search ? { q: search } : {} });
return res.data.transfers;
},
async get(id: number): Promise<Transfer> {
const res = await api.get(`/admin/transfers/${id}`);
return res.data.transfer;
},
async create(input: CreateTransferInput, onProgress?: (pct: number) => void): Promise<Transfer> {
// multipart: the operator's own files ride along with the form fields.
const form = new FormData();
if (input.title != null) form.append('title', input.title);
if (input.message != null) form.append('message', input.message);
if (input.expiresInDays != null) form.append('expiresInDays', String(input.expiresInDays));
if (input.maxDownloads != null) form.append('maxDownloads', String(input.maxDownloads));
if (input.graceDays != null) form.append('graceDays', String(input.graceDays));
form.append('allowUploads', String(!!input.allowUploads));
if (input.uploadExpiresInDays != null) form.append('uploadExpiresInDays', String(input.uploadExpiresInDays));
form.append('photoIds', JSON.stringify(input.photoIds || []));
form.append('deliveryMethod', input.deliveryMethod || 'link');
form.append('recipientEmails', JSON.stringify(input.recipientEmails || []));
(input.files || []).forEach((f) => form.append('files', f));
const res = await api.post('/admin/transfers', form, {
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100));
},
});
return res.data.transfer;
},
/** Add deliverable files to an existing transfer. */
async uploadFiles(id: number, files: File[], onProgress?: (pct: number) => void): Promise<Transfer> {
const form = new FormData();
files.forEach((f) => form.append('files', f));
const res = await api.post(`/admin/transfers/${id}/upload-files`, form, {
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100));
},
});
return res.data.transfer;
},
async removeExtraFile(id: number, extraId: number): Promise<Transfer> {
const res = await api.delete(`/admin/transfers/${id}/extra-files/${extraId}`);
return res.data.transfer;
},
adminExtraFileDownloadUrl(id: number, extraId: number): string {
return `${getApiBaseUrl()}/admin/transfers/${id}/extra-files/${extraId}/download`;
},
async update(id: number, input: UpdateTransferInput): Promise<Transfer> {
const res = await api.patch(`/admin/transfers/${id}`, input);
return res.data.transfer;
},
async remove(id: number): Promise<void> {
await api.delete(`/admin/transfers/${id}`);
},
async addFiles(id: number, photoIds: number[]): Promise<Transfer> {
const res = await api.post(`/admin/transfers/${id}/files`, { photoIds });
return res.data.transfer;
},
async removeFile(id: number, fileId: number): Promise<Transfer> {
const res = await api.delete(`/admin/transfers/${id}/files/${fileId}`);
return res.data.transfer;
},
async enableUploads(id: number, uploadExpiresInDays?: number): Promise<Transfer> {
const res = await api.post(`/admin/transfers/${id}/upload-link`, { uploadExpiresInDays });
return res.data.transfer;
},
async disableUploads(id: number): Promise<Transfer> {
const res = await api.delete(`/admin/transfers/${id}/upload-link`);
return res.data.transfer;
},
/** Absolute API URL for the admin ZIP download (cookie auth → usable as href). */
adminDownloadUrl(id: number): string {
return `${getApiBaseUrl()}/admin/transfers/${id}/download`;
},
adminUploadDownloadUrl(id: number, uploadId: number): string {
return `${getApiBaseUrl()}/admin/transfers/${id}/uploads/${uploadId}/download`;
},
// --- Public recipient ---
async getPublic(token: string): Promise<PublicTransferView> {
const res = await api.get(`/public/transfer/${token}`);
return res.data.transfer;
},
publicDownloadAllUrl(token: string): string {
return `${getApiBaseUrl()}/public/transfer/${token}/download`;
},
publicFileUrl(token: string, fileId: string): string {
return `${getApiBaseUrl()}/public/transfer/${token}/download/${fileId}`;
},
// --- Public client upload ---
async getUploadInfo(token: string): Promise<UploadInfo> {
const res = await api.get(`/public/transfer-upload/${token}`);
return res.data.transfer;
},
async upload(token: string, files: File[], onProgress?: (pct: number) => void): Promise<{ uploaded: number }> {
const form = new FormData();
files.forEach((f) => form.append('files', f));
const res = await api.post(`/public/transfer-upload/${token}`, form, {
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100));
},
});
return res.data;
},
};