mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Feature: Email output mode (#603)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user