mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Added onboarding + release fixes (#433)
This commit is contained in:
@@ -26,6 +26,7 @@ import { Footer } from './components/Footer';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { SettingsModal } from './components/settings';
|
||||
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 { SearchModeProvider } from './contexts/SearchModeContext';
|
||||
@@ -117,6 +118,16 @@ function App() {
|
||||
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||
|
||||
// Expose debug function to trigger onboarding from browser console
|
||||
useEffect(() => {
|
||||
(window as unknown as { showOnboarding: () => void }).showOnboarding = () => setOnboardingOpen(true);
|
||||
return () => {
|
||||
delete (window as unknown as { showOnboarding?: () => void }).showOnboarding;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [featureNoticeDismissed, setFeatureNoticeDismissed] = useState(() => {
|
||||
return localStorage.getItem('cwa-bd-prowlarr-irc-notice-dismissed') === 'true';
|
||||
});
|
||||
@@ -249,6 +260,11 @@ function App() {
|
||||
prevSearchModeRef.current = cfg.search_mode;
|
||||
setConfig(cfg);
|
||||
|
||||
// Show onboarding modal on first run (settings enabled but not completed yet)
|
||||
if (mode === 'initial' && cfg.settings_enabled && !cfg.onboarding_complete) {
|
||||
setOnboardingOpen(true);
|
||||
}
|
||||
|
||||
// Determine the default sort based on search mode
|
||||
const defaultSort = cfg.search_mode === 'universal'
|
||||
? (cfg.metadata_default_sort || 'relevance')
|
||||
@@ -702,6 +718,14 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Onboarding wizard shown on first run */}
|
||||
<OnboardingModal
|
||||
isOpen={onboardingOpen}
|
||||
onClose={() => setOnboardingOpen(false)}
|
||||
onComplete={() => loadConfig('settings-saved')}
|
||||
onShowToast={showToast}
|
||||
/>
|
||||
|
||||
</SearchModeProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border-muted)] bg-[var(--bg)] px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-200"
|
||||
>
|
||||
View on {isMetadata ? providerDisplay : "Anna's Archive"}
|
||||
View on {isMetadata ? providerDisplay : "Source"}
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
getOnboarding,
|
||||
saveOnboarding,
|
||||
skipOnboarding,
|
||||
executeSettingsAction,
|
||||
OnboardingStep,
|
||||
} from '../services/api';
|
||||
import {
|
||||
SettingsField,
|
||||
TextFieldConfig,
|
||||
PasswordFieldConfig,
|
||||
CheckboxFieldConfig,
|
||||
SelectFieldConfig,
|
||||
MultiSelectFieldConfig,
|
||||
HeadingFieldConfig,
|
||||
ActionButtonConfig,
|
||||
ActionResult,
|
||||
} from '../types/settings';
|
||||
import { FieldWrapper } from './settings/shared';
|
||||
import {
|
||||
TextField,
|
||||
PasswordField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
HeadingField,
|
||||
ActionButton,
|
||||
} from './settings/fields';
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
}
|
||||
|
||||
// Check if a field should be visible based on showWhen condition
|
||||
function isFieldVisible(
|
||||
field: SettingsField,
|
||||
values: Record<string, unknown>
|
||||
): boolean {
|
||||
const showWhen = field.showWhen;
|
||||
if (!showWhen) return true;
|
||||
|
||||
const currentValue = values[showWhen.field];
|
||||
|
||||
// Handle notEmpty condition
|
||||
if (showWhen.notEmpty) {
|
||||
if (Array.isArray(currentValue)) {
|
||||
return currentValue.length > 0;
|
||||
}
|
||||
return currentValue !== undefined && currentValue !== null && currentValue !== '';
|
||||
}
|
||||
|
||||
// Handle array of allowed values or single value
|
||||
return Array.isArray(showWhen.value)
|
||||
? showWhen.value.includes(currentValue as string)
|
||||
: currentValue === showWhen.value;
|
||||
}
|
||||
|
||||
// Check if a step should be visible based on its showWhen conditions (all must be true)
|
||||
function isStepVisible(
|
||||
step: OnboardingStep,
|
||||
values: Record<string, unknown>
|
||||
): boolean {
|
||||
if (!step.showWhen || step.showWhen.length === 0) return true;
|
||||
|
||||
// All conditions must be true (AND logic)
|
||||
return step.showWhen.every((condition) => {
|
||||
const currentValue = values[condition.field];
|
||||
return currentValue === condition.value;
|
||||
});
|
||||
}
|
||||
|
||||
// Render the appropriate field component based on type
|
||||
const renderField = (
|
||||
field: SettingsField,
|
||||
value: unknown,
|
||||
onChange: (value: unknown) => void,
|
||||
onAction: () => Promise<ActionResult>,
|
||||
isDisabled: boolean
|
||||
) => {
|
||||
switch (field.type) {
|
||||
case 'TextField':
|
||||
return (
|
||||
<TextField
|
||||
field={field as TextFieldConfig}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'PasswordField':
|
||||
return (
|
||||
<PasswordField
|
||||
field={field as PasswordFieldConfig}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'CheckboxField':
|
||||
return (
|
||||
<CheckboxField
|
||||
field={field as CheckboxFieldConfig}
|
||||
value={(value as boolean) ?? false}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'SelectField':
|
||||
return (
|
||||
<SelectField
|
||||
field={field as SelectFieldConfig}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'MultiSelectField':
|
||||
return (
|
||||
<MultiSelectField
|
||||
field={field as MultiSelectFieldConfig}
|
||||
value={(value as string[]) ?? []}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'ActionButton':
|
||||
return (
|
||||
<ActionButton
|
||||
field={field as ActionButtonConfig}
|
||||
onAction={onAction}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'HeadingField':
|
||||
return <HeadingField field={field as HeadingFieldConfig} />;
|
||||
default:
|
||||
return <div>Unknown field type</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export const OnboardingModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
onShowToast,
|
||||
}: OnboardingModalProps) => {
|
||||
const [steps, setSteps] = useState<OnboardingStep[]>([]);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch onboarding config on mount
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const fetchOnboarding = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const config = await getOnboarding();
|
||||
setSteps(config.steps);
|
||||
setValues(config.values);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch onboarding config:', err);
|
||||
setError('Failed to load setup wizard');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOnboarding();
|
||||
}, [isOpen]);
|
||||
|
||||
// Get visible steps based on current values
|
||||
const visibleSteps = useMemo(() => {
|
||||
return steps.filter((step) => isStepVisible(step, values));
|
||||
}, [steps, values]);
|
||||
|
||||
// Get current step
|
||||
const currentStep = visibleSteps[currentStepIndex];
|
||||
|
||||
// Get visible fields for current step
|
||||
const visibleFields = useMemo(() => {
|
||||
if (!currentStep) return [];
|
||||
return currentStep.fields.filter((field) => isFieldVisible(field, values));
|
||||
}, [currentStep, values]);
|
||||
|
||||
// Handle field value changes
|
||||
const handleChange = useCallback((key: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// Handle next step
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStepIndex < visibleSteps.length - 1) {
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
}
|
||||
}, [currentStepIndex, visibleSteps.length]);
|
||||
|
||||
// Handle previous step
|
||||
const handleBack = useCallback(() => {
|
||||
if (currentStepIndex > 0) {
|
||||
setCurrentStepIndex(currentStepIndex - 1);
|
||||
}
|
||||
}, [currentStepIndex]);
|
||||
|
||||
// Handle close with animation
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsClosing(false);
|
||||
onClose();
|
||||
}, 150);
|
||||
}, [onClose]);
|
||||
|
||||
// Handle skip
|
||||
const handleSkip = useCallback(async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await skipOnboarding();
|
||||
onShowToast?.('Setup skipped - using defaults', 'info');
|
||||
handleClose();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to skip onboarding:', err);
|
||||
onShowToast?.('Failed to skip setup', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [handleClose, onComplete, onShowToast]);
|
||||
|
||||
// Handle finish (save and complete)
|
||||
const handleFinish = useCallback(async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const result = await saveOnboarding(values);
|
||||
if (result.success) {
|
||||
onShowToast?.('Setup complete!', 'success');
|
||||
handleClose();
|
||||
onComplete();
|
||||
} else {
|
||||
onShowToast?.(result.message || 'Failed to save settings', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save onboarding:', err);
|
||||
onShowToast?.('Failed to save settings', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [values, handleClose, onComplete, onShowToast]);
|
||||
|
||||
// Handle action button (e.g., test connection)
|
||||
const handleAction = useCallback(
|
||||
async (fieldKey: string): Promise<ActionResult> => {
|
||||
if (!currentStep) {
|
||||
return { success: false, message: 'No current step' };
|
||||
}
|
||||
try {
|
||||
// Pass current values so actions can use them (e.g., API key for test connection)
|
||||
return await executeSettingsAction(currentStep.tab, fieldKey, values);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : 'Action failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
[currentStep, values]
|
||||
);
|
||||
|
||||
// Handle ESC key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen && !isClosing) return null;
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative rounded-xl p-8 shadow-2xl"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Loading setup wizard...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={handleClose} />
|
||||
<div
|
||||
className="relative rounded-xl p-8 shadow-2xl max-w-md"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-red-500">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm">{error}</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
const isLastStep = currentStepIndex === visibleSteps.length - 1;
|
||||
const progress = ((currentStepIndex + 1) / visibleSteps.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
|
||||
${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`relative w-full max-w-xl rounded-xl
|
||||
border border-[var(--border-muted)] shadow-2xl
|
||||
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Setup Wizard"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-muted)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-sky-500/20 text-sky-500 text-sm font-medium">
|
||||
{currentStepIndex + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{currentStep?.title || 'Setup'}</h2>
|
||||
<p className="text-xs opacity-60">
|
||||
Step {currentStepIndex + 1} of {visibleSteps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-[var(--bg-soft)]">
|
||||
<div
|
||||
className="h-full bg-sky-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5 space-y-5 min-h-[280px]">
|
||||
{visibleFields.map((field) => {
|
||||
const isDisabled = 'fromEnv' in field ? (field.fromEnv ?? false) : false;
|
||||
return (
|
||||
<FieldWrapper key={field.key} field={field}>
|
||||
{renderField(
|
||||
field,
|
||||
values[field.key],
|
||||
(v) => handleChange(field.key, v),
|
||||
() => handleAction(field.key),
|
||||
isDisabled
|
||||
)}
|
||||
</FieldWrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-[var(--border-muted)] flex items-center justify-between h-[68px]">
|
||||
<div>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={isSaving || !isFirstStep}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium
|
||||
${isFirstStep ? 'opacity-60 hover:opacity-100 transition-opacity' : 'invisible'}`}
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isFirstStep && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={isSaving}
|
||||
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
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLastStep ? (
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium
|
||||
bg-sky-600 text-white
|
||||
hover:bg-sky-700 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Finish Setup'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium
|
||||
bg-sky-600 text-white
|
||||
hover:bg-sky-700 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center gap-1"
|
||||
>
|
||||
Next
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -805,7 +805,7 @@ export const ReleaseModal = ({
|
||||
// Fallback: assume direct_download is available (for ebooks)
|
||||
setAvailableSources([{
|
||||
name: 'direct_download',
|
||||
display_name: "Anna's Archive",
|
||||
display_name: "Direct Download",
|
||||
enabled: true,
|
||||
supported_content_types: ['ebook']
|
||||
}]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Direct download mode sort options (Anna's Archive)
|
||||
// Direct download mode sort options
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: '', label: 'Most relevant' },
|
||||
{ value: 'newest', label: 'Newest (publication year)' },
|
||||
@@ -12,7 +12,7 @@ export const SORT_OPTIONS = [
|
||||
// Note: Metadata mode sort options are now dynamic per provider
|
||||
// They come from the /api/config endpoint as metadata_sort_options
|
||||
|
||||
// Direct download mode content type options (Anna's Archive)
|
||||
// Direct download mode content type options
|
||||
export const CONTENT_OPTIONS = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'book_nonfiction', label: 'Book (non-fiction)' },
|
||||
|
||||
@@ -193,9 +193,9 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
|
||||
} else {
|
||||
console.error('Search failed:', error);
|
||||
const message = error instanceof Error ? error.message : 'Search failed';
|
||||
const friendly = message.includes("Anna's Archive") || message.includes('Network restricted')
|
||||
const friendly = message.includes('Network restricted') || message.includes('Unable to reach')
|
||||
? message
|
||||
: "Unable to reach Anna's Archive. Network may be restricted or mirrors blocked.";
|
||||
: "Unable to reach download source. Network may be restricted or mirrors blocked.";
|
||||
showToast(friendly, 'error');
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -29,42 +29,72 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function for JSON fetch with credentials
|
||||
async function fetchJSON<T>(url: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
credentials: 'include', // Enable cookies for session
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...opts.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Try to parse error message from response body
|
||||
let errorMessage = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const errorData = await res.json();
|
||||
// Prefer user-friendly 'message' field, fall back to 'error'
|
||||
if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
// Custom error class for request timeouts
|
||||
export class TimeoutError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
// Default request timeout in milliseconds (30 seconds)
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
// Utility function for JSON fetch with credentials and timeout
|
||||
async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
credentials: 'include', // Enable cookies for session
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...opts.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Try to parse error message from response body
|
||||
let errorMessage = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const errorData = await res.json();
|
||||
// Prefer user-friendly 'message' field, fall back to 'error'
|
||||
if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch (e) {
|
||||
// Log parse failure for debugging - server may have returned non-JSON (e.g., HTML error page)
|
||||
console.warn(`Failed to parse error response from ${url}:`, e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
// Provide helpful message for gateway/proxy errors
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) {
|
||||
errorMessage = `Server unavailable (${res.status}). If using a reverse proxy, check its configuration.`;
|
||||
}
|
||||
|
||||
// Throw appropriate error based on status code
|
||||
if (res.status === 401) {
|
||||
throw new AuthenticationError(errorMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
// Log parse failure for debugging - server may have returned non-JSON (e.g., HTML error page)
|
||||
console.warn(`Failed to parse error response from ${url}:`, e instanceof Error ? e.message : e);
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Throw appropriate error based on status code
|
||||
if (res.status === 401) {
|
||||
throw new AuthenticationError(errorMessage);
|
||||
return res.json();
|
||||
} catch (error) {
|
||||
// Handle abort/timeout errors
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new TimeoutError('Request timed out. Check your network connection or proxy configuration.');
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// API functions
|
||||
@@ -236,6 +266,47 @@ export const executeSettingsAction = async (
|
||||
});
|
||||
};
|
||||
|
||||
// Onboarding API functions
|
||||
|
||||
export interface OnboardingStepCondition {
|
||||
field: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
tab: string;
|
||||
fields: import('../types/settings').SettingsField[];
|
||||
showWhen?: OnboardingStepCondition[]; // Array of conditions (all must be true)
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingConfig {
|
||||
steps: OnboardingStep[];
|
||||
values: Record<string, unknown>;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export const getOnboarding = async (): Promise<OnboardingConfig> => {
|
||||
return fetchJSON<OnboardingConfig>(`${API_BASE}/onboarding`);
|
||||
};
|
||||
|
||||
export const saveOnboarding = async (
|
||||
values: Record<string, unknown>
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
return fetchJSON<{ success: boolean; message: string }>(`${API_BASE}/onboarding`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
};
|
||||
|
||||
export const skipOnboarding = async (): Promise<{ success: boolean; message: string }> => {
|
||||
return fetchJSON<{ success: boolean; message: string }>(`${API_BASE}/onboarding/skip`, {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
|
||||
// Release source API functions
|
||||
|
||||
// Get available release sources from plugin registry
|
||||
|
||||
@@ -162,7 +162,8 @@ export interface AppConfig {
|
||||
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
|
||||
default_sort: string; // Default sort for direct mode (Anna's Archive)
|
||||
onboarding_complete: boolean; // Whether the user has completed initial setup
|
||||
default_sort: string; // Default sort for direct mode
|
||||
metadata_default_sort: string; // Default sort for universal mode (from metadata provider)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export const buildSearchQuery = ({
|
||||
return queryParts.join('&');
|
||||
}
|
||||
|
||||
// Direct mode: include all Anna's Archive filters
|
||||
// Direct mode: include all filters
|
||||
if (showAdvanced) {
|
||||
const { isbn, author, title, content, formats, lang } = advancedFilters;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user