Feature: Email output mode (#603)

This commit is contained in:
Alex
2026-02-09 19:33:12 +00:00
committed by GitHub
parent 022e50a0ba
commit e09f5f7757
18 changed files with 1208 additions and 36 deletions
+73 -3
View File
@@ -19,6 +19,7 @@ import { SearchSection } from './components/SearchSection';
import { AdvancedFilters } from './components/AdvancedFilters';
import { ResultsSection } from './components/ResultsSection';
import { DetailsModal } from './components/DetailsModal';
import { EmailRecipientModal } from './components/EmailRecipientModal';
import { ReleaseModal } from './components/ReleaseModal';
import { DownloadsSidebar } from './components/DownloadsSidebar';
import { ToastContainer } from './components/ToastContainer';
@@ -29,6 +30,7 @@ import { ConfigSetupBanner } from './components/ConfigSetupBanner';
import { OnboardingModal } from './components/OnboardingModal';
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { UserCancelledError, isUserCancelledError } from './utils/errors';
import { withBasePath } from './utils/basePath';
import { SearchModeProvider } from './contexts/SearchModeContext';
import './styles.css';
@@ -142,6 +144,49 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [configBannerOpen, setConfigBannerOpen] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
const [emailRecipientModalOpen, setEmailRecipientModalOpen] = useState(false);
const emailRecipientPromiseRef = useRef<{
resolve: (nickname: string) => void;
reject: (error: Error) => void;
} | null>(null);
const openEmailRecipientPicker = useCallback(async (): Promise<string> => {
const recipients = config?.email_recipients ?? [];
if (!config || config.books_output_mode !== 'email') {
return '';
}
if (recipients.length === 0) {
throw new Error('No email recipients configured');
}
if (recipients.length === 1) {
return recipients[0]?.nickname ?? '';
}
if (emailRecipientPromiseRef.current) {
throw new Error('Email recipient picker already open');
}
setEmailRecipientModalOpen(true);
return new Promise((resolve, reject) => {
emailRecipientPromiseRef.current = { resolve, reject };
});
}, [config]);
const handleEmailRecipientSelect = useCallback((nickname: string) => {
const pending = emailRecipientPromiseRef.current;
emailRecipientPromiseRef.current = null;
setEmailRecipientModalOpen(false);
pending?.resolve(nickname);
}, []);
const handleEmailRecipientCancel = useCallback(() => {
const pending = emailRecipientPromiseRef.current;
emailRecipientPromiseRef.current = null;
setEmailRecipientModalOpen(false);
pending?.reject(new UserCancelledError());
}, []);
// Expose debug function to trigger onboarding from browser console
useEffect(() => {
@@ -436,11 +481,20 @@ function App() {
// Download book
const handleDownload = async (book: Book): Promise<void> => {
try {
await downloadBook(book.id);
let emailRecipient: string | undefined;
if (config?.books_output_mode === 'email' && contentType === 'ebook') {
emailRecipient = await openEmailRecipientPicker();
}
await downloadBook(book.id, emailRecipient);
await fetchStatus();
} catch (error) {
if (isUserCancelledError(error)) {
// Important: rethrow so buttons can reset their "Queuing..." state.
throw error;
}
console.error('Download failed:', error);
showToast('Failed to queue download', 'error');
showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error');
throw error;
}
};
@@ -491,6 +545,11 @@ function App() {
// Handle download from ReleaseModal
const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => {
try {
let emailRecipient: string | undefined;
if (config?.books_output_mode === 'email' && releaseContentType === 'ebook') {
emailRecipient = await openEmailRecipientPicker();
}
trackRelease(book.id, release.source_id);
await downloadRelease({
@@ -512,11 +571,15 @@ function App() {
series_name: book.series_name,
series_position: book.series_position,
subtitle: book.subtitle,
email_recipient: emailRecipient,
});
await fetchStatus();
} catch (error) {
if (isUserCancelledError(error)) {
throw error;
}
console.error('Release download failed:', error);
showToast('Failed to queue download', 'error');
showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error');
throw error;
}
};
@@ -686,6 +749,13 @@ function App() {
/>
)}
<EmailRecipientModal
isOpen={emailRecipientModalOpen}
recipients={config?.email_recipients ?? []}
onSelect={handleEmailRecipientSelect}
onCancel={handleEmailRecipientCancel}
/>
</main>
<Footer
@@ -23,8 +23,8 @@ const sizeClasses: Record<ButtonSize, string> = {
};
const iconVariantSizeClasses: Record<ButtonSize, string> = {
sm: 'p-px m-0.5 sm:p-1 sm:m-0.5 aspect-square',
md: 'p-0.5 m-0.5 sm:p-1.5 sm:m-0.5 aspect-square',
sm: 'p-2 sm:p-1.5 aspect-square',
md: 'p-2.5 sm:p-2 aspect-square',
};
const primaryIconSizes: Record<ButtonSize, string> = {
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
import { isUserCancelledError } from '../utils/errors';
interface DetailsModalProps {
book: Book | null;
@@ -66,6 +67,9 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
// Don't close here - wait for button state to change
} catch (error) {
setIsQueuing(false);
if (isUserCancelledError(error)) {
return;
}
// Close on error
setTimeout(handleClose, 300);
}
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useState } from 'react';
import { EmailRecipient } from '../types';
interface EmailRecipientModalProps {
isOpen: boolean;
recipients: EmailRecipient[];
onSelect: (nickname: string) => void;
onCancel: () => void;
}
export const EmailRecipientModal = ({ isOpen, recipients, onSelect, onCancel }: EmailRecipientModalProps) => {
const [isClosing, setIsClosing] = useState(false);
const [selectedNickname, setSelectedNickname] = useState<string | null>(null);
// Reset selection when modal opens/closes
useEffect(() => {
if (isOpen) setSelectedNickname(null);
}, [isOpen]);
const handleCancel = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
setTimeout(() => {
onCancel();
setIsClosing(false);
}, 150);
}, [onCancel, isClosing]);
const handleSend = useCallback(() => {
if (isClosing || !selectedNickname) return;
setIsClosing(true);
setTimeout(() => {
onSelect(selectedNickname);
setIsClosing(false);
}, 150);
}, [onSelect, selectedNickname, isClosing]);
// ESC to cancel
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleCancel]);
// Prevent body scroll while open
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
if (!isOpen) return null;
const titleId = 'email-recipient-modal-title';
return (
<div
className="modal-overlay active sm:px-6 sm:py-6"
onClick={(e) => {
if (e.target === e.currentTarget) handleCancel();
}}
>
<div
className={`w-full sm:max-w-md h-full sm:h-auto pointer-events-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex h-full sm:h-auto flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-[var(--border-muted)] bg-[var(--bg)] text-[var(--text)] shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] bg-[var(--bg)] px-5 py-4">
<div className="flex-1 space-y-1">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Output</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug">
Send via Email
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
Choose a recipient for this download.
</p>
</div>
<button
type="button"
onClick={handleCancel}
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
aria-label="Cancel"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6">
{recipients.length === 0 ? (
<div className="rounded-2xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-3 text-sm">
No recipients configured.
</div>
) : (
<div className="space-y-2">
{recipients.map((r) => {
const isSelected = selectedNickname === r.nickname;
return (
<button
key={`${r.nickname}:${r.email}`}
type="button"
onClick={() => setSelectedNickname(isSelected ? null : r.nickname)}
className={`w-full text-left rounded-2xl border px-4 py-3 transition-colors hover-action ${
isSelected
? 'border-sky-500 bg-sky-500/10'
: 'border-[var(--border-muted)] bg-[var(--bg-soft)]'
}`}
>
<div className="flex items-center gap-3">
<div
className={`flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
isSelected ? 'border-sky-500 bg-sky-500' : 'border-gray-400 dark:border-gray-500'
}`}
>
{isSelected && (
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="min-w-0">
<div className="font-medium truncate">{r.nickname}</div>
<div className="text-xs opacity-70 truncate">{r.email}</div>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Desktop footer: small Send + Cancel buttons */}
<footer className="hidden sm:flex border-t border-[var(--border-muted)] bg-[var(--bg)] px-5 py-4 justify-end gap-2">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSend}
disabled={!selectedNickname}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</footer>
{/* Mobile: full-width Send button, slides up when a recipient is selected */}
{selectedNickname && (
<div
className="sm:hidden flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
type="button"
onClick={handleSend}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700"
>
Send
</button>
</div>
)}
</div>
</div>
</div>
);
};
+4 -8
View File
@@ -342,7 +342,7 @@ const ReleaseRow = ({
return (
<div
className="pl-5 pr-2 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform"
className="pl-5 pr-4 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform"
style={{
animationDelay: `${index * 30}ms`,
animationFillMode: 'both',
@@ -1241,13 +1241,9 @@ export const ReleaseModal = ({
const handleDownload = useCallback(
async (release: Release): Promise<void> => {
if (book) {
try {
await onDownload(book, release, contentType);
// Close modal after successful queue
handleClose();
} catch {
// Keep modal open on error so user can try again
}
await onDownload(book, release, contentType);
// Close modal after successful queue
handleClose();
}
},
[book, onDownload, contentType, handleClose]
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, CSSProperties } from 'react';
import { TableFieldConfig, TableFieldColumn } from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
@@ -37,6 +37,19 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
const columns = useMemo(() => field.columns ?? [], [field.columns]);
const rows = useMemo(() => normalizeRows(value ?? [], columns), [value, columns]);
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
// Use fixed width for delete button column to ensure header/data alignment.
const gridTemplate = 'sm:[grid-template-columns:var(--table-cols)]';
const tableCols = useMemo(() => {
if (columns.length === 0) {
return 'minmax(0,1fr) 2rem';
}
const colDefs = columns.map((_, idx) => (idx === 0 ? 'minmax(0,180px)' : 'minmax(0,1fr)'));
return `${colDefs.join(' ')} 2rem`;
}, [columns]);
const updateCell = (rowIndex: number, key: string, cellValue: unknown) => {
const next = rows.map((row, idx) => (idx === rowIndex ? { ...row, [key]: cellValue } : row));
onChange(next);
@@ -74,12 +87,8 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
);
}
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
// Use fixed width for delete button column to ensure header/data alignment.
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_2rem]';
return (
<div className="space-y-3 min-w-0">
<div className="space-y-3 min-w-0" style={{ '--table-cols': tableCols } as CSSProperties}>
<div className={`hidden sm:grid ${gridTemplate} gap-3 items-start min-w-0 text-xs font-medium opacity-70`}>
{columns.map((col) => (
<div key={col.key} className="min-w-0 truncate">
+8 -2
View File
@@ -189,8 +189,13 @@ export const getMetadataBookInfo = async (provider: string, bookId: string): Pro
return transformMetadataToBook(response);
};
export const downloadBook = async (id: string): Promise<void> => {
await fetchJSON(`${API.download}?id=${encodeURIComponent(id)}`);
export const downloadBook = async (id: string, emailRecipient?: string): Promise<void> => {
const params = new URLSearchParams();
params.set('id', id);
if (emailRecipient) {
params.set('email_recipient', emailRecipient);
}
await fetchJSON(`${API.download}?${params.toString()}`);
};
// Download a specific release (from ReleaseModal)
@@ -214,6 +219,7 @@ export const downloadRelease = async (release: {
series_position?: number;
subtitle?: string;
search_author?: string;
email_recipient?: string;
}): Promise<void> => {
await fetchJSON(`${API_BASE}/releases/download`, {
method: 'POST',
+9
View File
@@ -149,6 +149,13 @@ export type MetadataSearchField =
// Content type for search (ebook vs audiobook)
export type ContentType = 'ebook' | 'audiobook';
export type BooksOutputMode = 'folder' | 'booklore' | 'email';
export interface EmailRecipient {
nickname: string;
email: string;
}
export interface AppConfig {
calibre_web_url: string;
audiobook_library_url: string;
@@ -163,6 +170,8 @@ export interface AppConfig {
metadata_sort_options: SortOption[];
metadata_search_fields: MetadataSearchField[];
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
books_output_mode: BooksOutputMode;
email_recipients: EmailRecipient[];
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
download_to_browser: boolean; // Auto-download completed files to browser
settings_enabled: boolean; // Whether config directory is mounted and writable
+11
View File
@@ -0,0 +1,11 @@
export class UserCancelledError extends Error {
constructor(message: string = 'Cancelled') {
super(message);
this.name = 'UserCancelledError';
}
}
export function isUserCancelledError(error: unknown): error is UserCancelledError {
return error instanceof UserCancelledError;
}