Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
/**
* Component: Admin Account Setup Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
interface AdminAccountStepProps {
adminUsername: string;
adminPassword: string;
onUpdate: (field: string, value: string) => void;
onNext: () => void;
onBack: () => void;
}
export function AdminAccountStep({
adminUsername,
adminPassword,
onUpdate,
onNext,
onBack,
}: AdminAccountStepProps) {
const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({});
const validate = () => {
const newErrors: { username?: string; password?: string; confirm?: string } = {};
// Validate username
if (!adminUsername || adminUsername.length < 3) {
newErrors.username = 'Username must be at least 3 characters';
}
// Validate password
if (!adminPassword || adminPassword.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
// Validate password confirmation
if (adminPassword !== confirmPassword) {
newErrors.confirm = 'Passwords do not match';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleNext = () => {
if (validate()) {
onNext();
}
};
return (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-3xl font-bold text-white mb-2">Create Admin Account</h2>
<p className="text-gray-400">
Set up your administrator account to manage the application
</p>
</div>
<div className="space-y-4">
{/* Username */}
<div>
<label htmlFor="adminUsername" className="block text-sm font-medium text-gray-300 mb-2">
Username
</label>
<input
type="text"
id="adminUsername"
value={adminUsername}
onChange={(e) => onUpdate('adminUsername', e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="admin"
autoComplete="username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-400">{errors.username}</p>
)}
<p className="mt-1 text-xs text-gray-500">
This will be your local admin username (minimum 3 characters)
</p>
</div>
{/* Password */}
<div>
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
type="password"
id="adminPassword"
value={adminPassword}
onChange={(e) => onUpdate('adminPassword', e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
autoComplete="new-password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-400">{errors.password}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Choose a strong password (minimum 8 characters)
</p>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
autoComplete="new-password"
/>
{errors.confirm && (
<p className="mt-1 text-sm text-red-400">{errors.confirm}</p>
)}
</div>
{/* Info Box */}
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<svg className="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="text-sm text-blue-300">
<p className="font-medium mb-1">About Admin Accounts</p>
<p className="text-blue-400">
This local admin account is separate from media server authentication. Use it to access
admin settings and manage the application.
</p>
</div>
</div>
</div>
</div>
{/* Navigation */}
<div className="flex gap-3 pt-4">
<Button
onClick={onBack}
variant="outline"
className="flex-1"
>
Back
</Button>
<Button
onClick={handleNext}
className="flex-1 bg-orange-600 hover:bg-orange-700"
>
Next
</Button>
</div>
</div>
);
}
+264
View File
@@ -0,0 +1,264 @@
/**
* Component: Audiobookshelf Configuration Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface AudiobookshelfStepProps {
absUrl: string;
absApiToken: string;
absLibraryId: string;
onUpdate: (field: string, value: string) => void;
onNext: () => void;
onBack: () => void;
}
interface Library {
id: string;
name: string;
itemCount: number;
}
export function AudiobookshelfStep({
absUrl,
absApiToken,
absLibraryId,
onUpdate,
onNext,
onBack,
}: AudiobookshelfStepProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message?: string;
libraries?: Library[];
} | null>(null);
const [libraries, setLibraries] = useState<Library[]>([]);
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/setup/test-abs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl: absUrl, apiToken: absApiToken }),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({
success: true,
message: 'Connection successful!',
libraries: data.libraries || [],
});
setLibraries(data.libraries || []);
} else {
setTestResult({
success: false,
message: data.error || 'Connection failed',
});
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed',
});
} finally {
setTesting(false);
}
};
const handleNext = () => {
if (!testResult?.success) {
setTestResult({
success: false,
message: 'Please test the connection before proceeding',
});
return;
}
if (!absLibraryId) {
setTestResult({
success: false,
message: 'Please select an audiobook library',
});
return;
}
onNext();
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configure Audiobookshelf
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Enter your Audiobookshelf server details and API token.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Server URL
</label>
<Input
type="url"
placeholder="http://audiobookshelf:13378"
value={absUrl}
onChange={(e) => onUpdate('absUrl', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The URL where your Audiobookshelf server is running (include port)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Token
</label>
<Input
type="password"
placeholder="Your API token"
value={absApiToken}
onChange={(e) => onUpdate('absApiToken', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Find this in Audiobookshelf Settings Users Your User API Token
</p>
</div>
<Button
onClick={testConnection}
loading={testing}
disabled={!absUrl || !absApiToken}
variant="outline"
className="w-full"
>
Test Connection
</Button>
{testResult && (
<div
className={`rounded-lg p-4 ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex gap-3">
<svg
className={`w-6 h-6 flex-shrink-0 ${
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
{testResult.success ? (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
)}
</svg>
<div>
<h3
className={`text-sm font-medium ${
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3>
<p
className={`text-sm mt-1 ${
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p>
</div>
</div>
</div>
)}
{libraries.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Audiobook Library
</label>
<select
value={absLibraryId}
onChange={(e) => onUpdate('absLibraryId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">Select a library...</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name} ({lib.itemCount} items)
</option>
))}
</select>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Select the library containing your audiobooks
</p>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
About API Token
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
You can generate an API token in Audiobookshelf by going to Settings Users
selecting your user and copying the API Token.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={handleNext}>Next</Button>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Component: Authentication Method Selection Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { Button } from '@/components/ui/Button';
interface AuthMethodStepProps {
value: 'oidc' | 'manual' | 'both';
onChange: (value: 'oidc' | 'manual' | 'both') => void;
onNext: () => void;
onBack: () => void;
}
export function AuthMethodStep({
value,
onChange,
onNext,
onBack,
}: AuthMethodStepProps) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Choose Authentication Method
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Select how users will authenticate to access ReadMeABook.
</p>
</div>
<div className="space-y-4">
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'oidc'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="authMethod"
value="oidc"
checked={value === 'oidc'}
onChange={() => onChange('oidc')}
className="sr-only"
/>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
OIDC Provider
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Use Authentik, Keycloak, or other OIDC-compatible identity provider for
single sign-on.
</p>
</div>
</label>
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'manual'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="authMethod"
value="manual"
checked={value === 'manual'}
onChange={() => onChange('manual')}
className="sr-only"
/>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Manual Registration
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Users create accounts with username and password. Optional admin approval.
</p>
</div>
</label>
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'both'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="authMethod"
value="both"
checked={value === 'both'}
onChange={() => onChange('both')}
className="sr-only"
/>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">Both</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Enable OIDC as primary authentication with password-based registration as a
fallback option.
</p>
</div>
</label>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Recommendation
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
OIDC is recommended for better security and centralized user management. Choose
"Both" if you want to provide a fallback option for users without OIDC access.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onNext}>Next</Button>
</div>
</div>
);
}
@@ -0,0 +1,130 @@
/**
* Component: Backend Selection Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { Button } from '@/components/ui/Button';
interface BackendSelectionStepProps {
value: 'plex' | 'audiobookshelf';
onChange: (value: 'plex' | 'audiobookshelf') => void;
onNext: () => void;
onBack: () => void;
}
export function BackendSelectionStep({
value,
onChange,
onNext,
onBack,
}: BackendSelectionStepProps) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Choose Your Library Backend
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Select which media server you'll use to manage your audiobook library.
</p>
</div>
<div className="space-y-4">
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'plex'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="backend"
value="plex"
checked={value === 'plex'}
onChange={() => onChange('plex')}
className="sr-only"
/>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-orange-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white text-2xl font-bold">P</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Plex Media Server
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Use Plex for library management. Authentication via Plex OAuth.
</p>
</div>
</div>
</label>
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'audiobookshelf'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="backend"
value="audiobookshelf"
checked={value === 'audiobookshelf'}
onChange={() => onChange('audiobookshelf')}
className="sr-only"
/>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white text-2xl font-bold">A</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Audiobookshelf
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Use Audiobookshelf for library management. Choose OIDC or password
authentication.
</p>
</div>
</div>
</label>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-yellow-900 dark:text-yellow-100">
Important Note
</p>
<p className="text-sm text-yellow-800 dark:text-yellow-200 mt-1">
This choice cannot be changed after setup. To switch backends, you'll need
to reset the application.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onNext}>Next</Button>
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Component: BookDate Setup Step (Setup Wizard)
* Documentation: documentation/features/bookdate-prd.md
*/
'use client';
import { useState } from 'react';
interface BookDateStepProps {
bookdateProvider: string;
bookdateApiKey: string;
bookdateModel: string;
bookdateConfigured: boolean;
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onSkip: () => void;
onBack: () => void;
}
interface ModelOption {
id: string;
name: string;
}
export function BookDateStep({
bookdateProvider,
bookdateApiKey,
bookdateModel,
bookdateConfigured,
onUpdate,
onNext,
onSkip,
onBack,
}: BookDateStepProps) {
const [testing, setTesting] = useState(false);
const [tested, setTested] = useState(bookdateConfigured);
const [models, setModels] = useState<ModelOption[]>([]);
const [error, setError] = useState<string | null>(null);
const handleTestConnection = async () => {
if (!bookdateApiKey.trim()) {
setError('Please enter an API key');
return;
}
setTesting(true);
setError(null);
try {
const response = await fetch('/api/bookdate/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: bookdateProvider,
apiKey: bookdateApiKey,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Connection test failed');
}
setModels(data.models || []);
setTested(true);
onUpdate('bookdateConfigured', true);
// Auto-select first model if none selected
if (!bookdateModel && data.models?.length > 0) {
onUpdate('bookdateModel', data.models[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection test failed');
setTested(false);
onUpdate('bookdateConfigured', false);
} finally {
setTesting(false);
}
};
const handleNext = () => {
if (tested && bookdateModel) {
onNext();
} else {
setError('Please test connection and select a model');
}
};
const canProceed = tested && bookdateModel;
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
BookDate Setup (Optional)
</h2>
<p className="text-gray-600 dark:text-gray-400">
Configure AI-powered audiobook recommendations. You can skip this step and set it up
later in Settings.
</p>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
{/* AI Provider Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
AI Provider
</label>
<select
value={bookdateProvider}
onChange={(e) => {
onUpdate('bookdateProvider', e.target.value);
setTested(false);
setModels([]);
onUpdate('bookdateConfigured', false);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
>
<option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option>
</select>
</div>
{/* API Key Input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Key
</label>
<input
type="password"
value={bookdateApiKey}
onChange={(e) => {
onUpdate('bookdateApiKey', e.target.value);
setTested(false);
setModels([]);
onUpdate('bookdateConfigured', false);
}}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Your API key is stored securely and only used for recommendations
</p>
</div>
{/* Test Connection Button */}
<button
onClick={handleTestConnection}
disabled={!bookdateApiKey.trim() || testing}
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors"
>
{testing ? 'Testing...' : 'Test Connection & Fetch Models'}
</button>
{/* Model Selection (shown after successful test) */}
{tested && models.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Model
</label>
<select
value={bookdateModel}
onChange={(e) => onUpdate('bookdateModel', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
>
<option value="">-- Choose a model --</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
)}
{/* Info about per-user preferences */}
{tested && bookdateModel && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-300">
<strong>Note:</strong> Library scope and custom prompt preferences can be configured per-user after setup.
Users can adjust these in their BookDate preferences (settings icon on the BookDate page).
</p>
</div>
)}
{/* Navigation Buttons */}
<div className="flex gap-4 pt-4">
<button
onClick={onBack}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Back
</button>
<button
onClick={onSkip}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Skip for now
</button>
<button
onClick={handleNext}
disabled={!canProceed}
className="flex-1 px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors"
>
Next
</button>
</div>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Component: Setup Wizard Download Client Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface DownloadClientStepProps {
downloadClient: 'qbittorrent' | 'transmission';
downloadClientUrl: string;
downloadClientUsername: string;
downloadClientPassword: string;
onUpdate: (field: string, value: string) => void;
onNext: () => void;
onBack: () => void;
}
export function DownloadClientStep({
downloadClient,
downloadClientUrl,
downloadClientUsername,
downloadClientPassword,
onUpdate,
onNext,
onBack,
}: DownloadClientStepProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
version?: string;
} | null>(null);
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/setup/test-download-client', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: downloadClient,
url: downloadClientUrl,
username: downloadClientUsername,
password: downloadClientPassword,
}),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({
success: true,
message: `Connected successfully! ${data.version ? `Version: ${data.version}` : ''}`,
version: data.version,
});
} else {
setTestResult({
success: false,
message: data.error || 'Connection failed',
});
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed',
});
} finally {
setTesting(false);
}
};
const handleNext = () => {
if (!testResult?.success) {
setTestResult({
success: false,
message: 'Please test the connection before proceeding',
});
return;
}
onNext();
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configure Download Client
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Choose and configure your torrent download client.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Download Client
</label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => onUpdate('downloadClient', 'qbittorrent')}
className={`p-4 border-2 rounded-lg text-left transition-colors ${
downloadClient === 'qbittorrent'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
>
<div className="font-semibold text-gray-900 dark:text-gray-100">qBittorrent</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Recommended - Full feature support
</div>
</button>
<button
type="button"
onClick={() => onUpdate('downloadClient', 'transmission')}
className={`p-4 border-2 rounded-lg text-left transition-colors ${
downloadClient === 'transmission'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400'
}`}
>
<div className="font-semibold text-gray-900 dark:text-gray-100">Transmission</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Coming soon
</div>
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{downloadClient === 'qbittorrent' ? 'qBittorrent' : 'Transmission'} URL
</label>
<Input
type="url"
placeholder={downloadClient === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:9091'}
value={downloadClientUrl}
onChange={(e) => onUpdate('downloadClientUrl', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The URL where your download client is running (include port)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<Input
type="text"
placeholder="admin"
value={downloadClientUsername}
onChange={(e) => onUpdate('downloadClientUsername', e.target.value)}
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password
</label>
<Input
type="password"
placeholder="Enter password"
value={downloadClientPassword}
onChange={(e) => onUpdate('downloadClientPassword', e.target.value)}
autoComplete="current-password"
/>
</div>
<Button
onClick={testConnection}
loading={testing}
disabled={!downloadClientUrl || !downloadClientUsername || !downloadClientPassword}
variant="outline"
className="w-full"
>
Test Connection
</Button>
{testResult && (
<div
className={`rounded-lg p-4 ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex gap-3">
<svg
className={`w-6 h-6 flex-shrink-0 ${
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
{testResult.success ? (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
)}
</svg>
<div>
<h3
className={`text-sm font-medium ${
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3>
<p
className={`text-sm mt-1 ${
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p>
</div>
</div>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{downloadClient === 'qbittorrent' ? 'qBittorrent Setup' : 'Transmission Setup'}
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
{downloadClient === 'qbittorrent'
? 'Make sure Web UI is enabled in qBittorrent settings (Tools → Options → Web UI)'
: 'Transmission support is coming soon. Please use qBittorrent for now.'}
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={handleNext}>Next</Button>
</div>
</div>
);
}
+412
View File
@@ -0,0 +1,412 @@
/**
* Component: Setup Wizard Finalize Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
interface FinalizeStepProps {
hasAdminTokens: boolean; // True if admin was created and tokens exist
onComplete: () => void;
onBack: () => void;
}
interface JobStatus {
id: string;
name: string;
description: string;
status: 'pending' | 'running' | 'completed' | 'error';
error?: string;
}
export function FinalizeStep({ hasAdminTokens, onComplete, onBack }: FinalizeStepProps) {
const [jobs, setJobs] = useState<JobStatus[]>([
{
id: 'audible_refresh',
name: 'Audible Data Refresh',
description: 'Fetches popular and new release audiobooks from Audible to populate your browse catalog',
status: 'pending',
},
{
id: 'plex_library_scan',
name: 'Library Scan',
description: 'Scans your media library to discover audiobooks you already have',
status: 'pending',
},
]);
const [isComplete, setIsComplete] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
// Auto-start jobs based on setup mode
useEffect(() => {
if (!hasAdminTokens) {
// OIDC-only mode - no admin user created during setup
console.log('[Setup] OIDC-only mode detected - skipping initial jobs');
setIsComplete(true);
} else if (!hasStarted) {
// Normal mode - admin user created, run jobs
console.log('[Setup] Normal mode detected - running initial jobs');
setHasStarted(true);
runJobs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasAdminTokens, hasStarted]);
const pollJobStatus = async (jobId: string, accessToken: string): Promise<'completed' | 'failed'> => {
console.log(`[Setup] Starting to poll job status for jobId: ${jobId}`);
return new Promise((resolve) => {
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/admin/job-status/${jobId}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error(`[Setup] Failed to fetch job status: ${response.status} ${response.statusText}`);
throw new Error('Failed to fetch job status');
}
const data = await response.json();
const jobStatus = data.job.status;
console.log(`[Setup] Job ${jobId} status: ${jobStatus}`);
if (jobStatus === 'completed') {
console.log(`[Setup] Job ${jobId} completed successfully`);
clearInterval(pollInterval);
resolve('completed');
} else if (jobStatus === 'failed') {
console.log(`[Setup] Job ${jobId} failed`);
clearInterval(pollInterval);
resolve('failed');
}
// Otherwise keep polling (pending, active, stuck)
} catch (error) {
console.error('[Setup] Error polling job status:', error);
clearInterval(pollInterval);
resolve('failed');
}
}, 2000); // Poll every 2 seconds
});
};
const runJobs = async () => {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
console.error('No access token found');
setJobs(prev => prev.map(job => ({
...job,
status: 'error',
error: 'Authentication required',
})));
return;
}
// Get all scheduled jobs to find the IDs
let scheduledJobs: any[] = [];
try {
const response = await fetch('/api/admin/jobs', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch scheduled jobs');
}
const data = await response.json();
scheduledJobs = data.jobs;
} catch (error) {
console.error('Failed to fetch scheduled jobs:', error);
setJobs(prev => prev.map(job => ({
...job,
status: 'error',
error: 'Failed to fetch job configuration',
})));
return;
}
// Run each job sequentially
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
// Update status to running
setJobs(prev => prev.map((j, idx) =>
idx === i ? { ...j, status: 'running' } : j
));
// Find the scheduled job by type
const scheduledJob = scheduledJobs.find((sj: any) => sj.type === job.id);
if (!scheduledJob) {
console.error(`Scheduled job not found for type: ${job.id}`);
setJobs(prev => prev.map((j, idx) =>
idx === i ? { ...j, status: 'error', error: 'Job configuration not found' } : j
));
continue;
}
try {
// Trigger the job
const response = await fetch(`/api/admin/jobs/${scheduledJob.id}/trigger`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to trigger job');
}
const triggerData = await response.json();
const jobId = triggerData.jobId;
console.log(`[Setup] Job triggered: ${job.name}, jobId: ${jobId}`);
// Validate we received a jobId
if (!jobId) {
throw new Error('No job ID returned from trigger endpoint');
}
// Poll job status until completed or failed
const finalStatus = await pollJobStatus(jobId, accessToken);
console.log(`[Setup] Job ${job.name} finished with status: ${finalStatus}`);
// Update job status based on polling result
if (finalStatus === 'completed') {
setJobs(prev => prev.map((j, idx) =>
idx === i ? { ...j, status: 'completed' } : j
));
} else {
setJobs(prev => prev.map((j, idx) =>
idx === i ? { ...j, status: 'error', error: 'Job failed to complete' } : j
));
}
// Add a small delay between jobs
if (i < jobs.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
} catch (error) {
console.error(`Failed to run job ${job.name}:`, error);
setJobs(prev => prev.map((j, idx) =>
idx === i ? {
...j,
status: 'error',
error: error instanceof Error ? error.message : 'Failed to run job'
} : j
));
}
}
// All jobs complete (or failed)
setIsComplete(true);
};
const allJobsCompleted = jobs.every(job => job.status === 'completed' || job.status === 'error');
// OIDC-only mode UI (no admin tokens)
if (!hasAdminTokens) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Setup Complete!
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Your ReadMeABook instance is configured and ready to use.
</p>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6 border-2 border-blue-200 dark:border-blue-800">
<div className="flex gap-4">
<svg
className="w-8 h-8 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div className="flex-1">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
Next Steps: Complete Your First Login
</h3>
<div className="space-y-2 text-sm text-blue-800 dark:text-blue-200">
<p>
Since you're using OIDC authentication, you'll need to complete your first login to proceed:
</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>Click "Finish Setup" below to complete the wizard</li>
<li>You'll be redirected to the login page</li>
<li>Log in with your OIDC provider (the first user will automatically become an admin)</li>
<li>Initial library scans will run automatically after your first login</li>
</ol>
<p className="mt-3 font-medium">
The following jobs will run automatically on your first login:
</p>
<ul className="list-disc list-inside ml-2 space-y-1">
<li><strong>Audible Data Refresh</strong> - Populate your browse catalog with audiobooks</li>
<li><strong>Library Scan</strong> - Discover audiobooks you already have</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onComplete} size="lg">
Finish Setup
</Button>
</div>
</div>
);
}
// Normal mode UI (with admin user and job execution)
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Initializing Your Library
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Running initial setup jobs to populate your audiobook catalog.
</p>
</div>
<div className="space-y-4">
{jobs.map((job, index) => (
<div
key={job.id}
className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 border-2 border-gray-200 dark:border-gray-800"
>
<div className="flex items-start gap-4">
{/* Status Icon */}
<div className="flex-shrink-0 mt-1">
{job.status === 'pending' && (
<div className="w-6 h-6 rounded-full border-2 border-gray-300 dark:border-gray-600" />
)}
{job.status === 'running' && (
<div className="w-6 h-6 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
)}
{job.status === 'completed' && (
<svg
className="w-6 h-6 text-green-600 dark:text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
)}
{job.status === 'error' && (
<svg
className="w-6 h-6 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)}
</div>
{/* Job Info */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{job.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{job.description}
</p>
{job.status === 'running' && (
<p className="text-sm text-blue-600 dark:text-blue-400 mt-2 font-medium">
Running...
</p>
)}
{job.status === 'completed' && (
<p className="text-sm text-green-600 dark:text-green-400 mt-2 font-medium">
Completed successfully
</p>
)}
{job.status === 'error' && (
<p className="text-sm text-red-600 dark:text-red-400 mt-2 font-medium">
Error: {job.error}
</p>
)}
</div>
</div>
</div>
))}
</div>
{isComplete && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Initial setup complete!
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
These jobs will run automatically on a schedule to keep your catalog fresh.
You can manage their schedules in the admin settings.
</p>
</div>
</div>
</div>
)}
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline" disabled={!allJobsCompleted}>
Back
</Button>
<Button
onClick={onComplete}
disabled={!allJobsCompleted}
size="lg"
>
Finish Setup
</Button>
</div>
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
/**
* Component: OIDC Configuration Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface OIDCConfigStepProps {
oidcProviderName: string;
oidcIssuerUrl: string;
oidcClientId: string;
oidcClientSecret: string;
onUpdate: (field: string, value: string) => void;
onNext: () => void;
onBack: () => void;
}
export function OIDCConfigStep({
oidcProviderName,
oidcIssuerUrl,
oidcClientId,
oidcClientSecret,
onUpdate,
onNext,
onBack,
}: OIDCConfigStepProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/setup/test-oidc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
issuerUrl: oidcIssuerUrl,
clientId: oidcClientId,
clientSecret: oidcClientSecret,
}),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({
success: true,
message: 'OIDC discovery successful! Provider configuration validated.',
});
} else {
setTestResult({
success: false,
message: data.error || 'OIDC discovery failed',
});
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed',
});
} finally {
setTesting(false);
}
};
const handleNext = () => {
if (!testResult?.success) {
setTestResult({
success: false,
message: 'Please test the OIDC configuration before proceeding',
});
return;
}
onNext();
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configure OIDC Provider
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Enter your OIDC provider details for single sign-on authentication.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Provider Name
</label>
<Input
type="text"
placeholder="Authentik"
value={oidcProviderName}
onChange={(e) => onUpdate('oidcProviderName', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Display name for the login button (e.g., "Authentik", "Keycloak", "SSO")
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Issuer URL
</label>
<Input
type="url"
placeholder="https://auth.example.com/application/o/readmeabook/"
value={oidcIssuerUrl}
onChange={(e) => onUpdate('oidcIssuerUrl', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The OIDC issuer URL from your identity provider configuration
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client ID
</label>
<Input
type="text"
placeholder="readmeabook"
value={oidcClientId}
onChange={(e) => onUpdate('oidcClientId', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The OAuth2 client ID from your OIDC provider
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Secret
</label>
<Input
type="password"
placeholder="Enter client secret"
value={oidcClientSecret}
onChange={(e) => onUpdate('oidcClientSecret', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The OAuth2 client secret from your OIDC provider
</p>
</div>
<Button
onClick={testConnection}
loading={testing}
disabled={!oidcIssuerUrl || !oidcClientId || !oidcClientSecret}
variant="outline"
className="w-full"
>
Test OIDC Configuration
</Button>
{testResult && (
<div
className={`rounded-lg p-4 ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex gap-3">
<svg
className={`w-6 h-6 flex-shrink-0 ${
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
{testResult.success ? (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
)}
</svg>
<div>
<h3
className={`text-sm font-medium ${
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3>
<p
className={`text-sm mt-1 ${
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p>
</div>
</div>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Configuration Tips
</p>
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-1 space-y-1">
<li> The redirect URI will be: {typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '[Your Domain]/api/auth/oidc/callback'}</li>
<li> Configure this redirect URI in your OIDC provider settings</li>
<li> Required scopes: openid, profile, email</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={handleNext}>Next</Button>
</div>
</div>
);
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Component: Setup Wizard Paths Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface PathsStepProps {
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
onUpdate: (field: string, value: string | boolean) => void;
onNext: () => void;
onBack: () => void;
}
export function PathsStep({
downloadDir,
mediaDir,
metadataTaggingEnabled,
onUpdate,
onNext,
onBack,
}: PathsStepProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
downloadDirValid?: boolean;
mediaDirValid?: boolean;
} | null>(null);
const testPaths = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/setup/test-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
downloadDir,
mediaDir,
}),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({
success: true,
message: data.message || 'Directories are ready and writable!',
downloadDirValid: data.downloadDirValid,
mediaDirValid: data.mediaDirValid,
});
} else {
setTestResult({
success: false,
message: data.error || 'Path validation failed',
downloadDirValid: data.downloadDirValid,
mediaDirValid: data.mediaDirValid,
});
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Path validation failed',
});
} finally {
setTesting(false);
}
};
const handleNext = () => {
if (!testResult?.success) {
setTestResult({
success: false,
message: 'Please validate the paths before proceeding',
});
return;
}
onNext();
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configure Directory Paths
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Set up the directories for downloads and your media library.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Download Directory
</label>
<Input
type="text"
placeholder="/downloads"
value={downloadDir}
onChange={(e) => onUpdate('downloadDir', e.target.value)}
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Where torrent files will be downloaded (will be created if it doesn't exist)
</p>
{testResult && typeof testResult.downloadDirValid !== 'undefined' && (
<div className="flex items-center gap-2 mt-2">
{testResult.downloadDirValid ? (
<>
<svg
className="w-5 h-5 text-green-600 dark:text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-green-700 dark:text-green-300">
Directory is ready and writable
</span>
</>
) : (
<>
<svg
className="w-5 h-5 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-red-700 dark:text-red-300">
Path invalid or parent mount not writable
</span>
</>
)}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Media Directory
</label>
<Input
type="text"
placeholder="/media/audiobooks"
value={mediaDir}
onChange={(e) => onUpdate('mediaDir', e.target.value)}
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Where organized audiobooks will be stored (will be created if it doesn't exist)
</p>
{testResult && typeof testResult.mediaDirValid !== 'undefined' && (
<div className="flex items-center gap-2 mt-2">
{testResult.mediaDirValid ? (
<>
<svg
className="w-5 h-5 text-green-600 dark:text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-green-700 dark:text-green-300">
Directory is ready and writable
</span>
</>
) : (
<>
<svg
className="w-5 h-5 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-red-700 dark:text-red-300">
Path invalid or parent mount not writable
</span>
</>
)}
</div>
)}
</div>
{/* Metadata Tagging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="metadata-tagging"
checked={metadataTaggingEnabled}
onChange={(e) => onUpdate('metadataTaggingEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="metadata-tagging"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Auto-tag audio files with metadata
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Automatically write correct title, author, and narrator metadata to m4b and mp3 files
during file organization. This significantly improves Plex matching accuracy for audiobooks
with missing or incorrect metadata. Recommended: enabled.
</p>
</div>
</div>
</div>
<Button
onClick={testPaths}
loading={testing}
disabled={!downloadDir || !mediaDir}
variant="outline"
className="w-full"
>
Validate Paths
</Button>
{testResult && (
<div
className={`rounded-lg p-4 ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex gap-3">
<svg
className={`w-6 h-6 flex-shrink-0 ${
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
{testResult.success ? (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
)}
</svg>
<div>
<h3
className={`text-sm font-medium ${
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3>
<p
className={`text-sm mt-1 ${
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p>
</div>
</div>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
About directory structure
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Audiobooks will be organized as: Media Directory / Author / Title / files.
Directories will be created automatically if they don't exist. Validation ensures
the parent mount is accessible and writable.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={handleNext}>Next</Button>
</div>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
/**
* Component: Setup Wizard Plex Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface PlexStepProps {
plexUrl: string;
plexToken: string;
plexLibraryId: string;
onUpdate: (field: string, value: string) => void;
onNext: () => void;
onBack: () => void;
}
interface PlexLibrary {
id: string;
title: string;
type: string;
}
export function PlexStep({
plexUrl,
plexToken,
plexLibraryId,
onUpdate,
onNext,
onBack,
}: PlexStepProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
libraries?: PlexLibrary[];
} | null>(null);
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/setup/test-plex', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: plexUrl, token: plexToken }),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({
success: true,
message: `Connected to ${data.serverName || 'Plex server'} successfully!`,
libraries: data.libraries || [],
});
setLibraries(data.libraries || []);
} else {
setTestResult({
success: false,
message: data.error || 'Connection failed',
});
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed',
});
} finally {
setTesting(false);
}
};
const handleNext = () => {
if (!testResult?.success) {
setTestResult({
success: false,
message: 'Please test the connection before proceeding',
});
return;
}
if (!plexLibraryId) {
setTestResult({
success: false,
message: 'Please select an audiobook library',
});
return;
}
onNext();
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configure Plex Media Server
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Connect to your Plex Media Server to access your audiobook library.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Plex Server URL
</label>
<Input
type="url"
placeholder="http://localhost:32400"
value={plexUrl}
onChange={(e) => onUpdate('plexUrl', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The URL where your Plex server is running (include port)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Plex Token
</label>
<Input
type="password"
placeholder="Enter your Plex authentication token"
value={plexToken}
onChange={(e) => onUpdate('plexToken', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
<a
href="https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
How to find your Plex authentication token
</a>
</p>
</div>
<Button
onClick={testConnection}
loading={testing}
disabled={!plexUrl || !plexToken}
variant="outline"
className="w-full"
>
Test Connection
</Button>
{testResult && (
<div
className={`rounded-lg p-4 ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex gap-3">
<svg
className={`w-6 h-6 flex-shrink-0 ${
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
{testResult.success ? (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
)}
</svg>
<div>
<h3
className={`text-sm font-medium ${
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3>
<p
className={`text-sm mt-1 ${
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p>
</div>
</div>
</div>
)}
{libraries.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Audiobook Library
</label>
<select
value={plexLibraryId}
onChange={(e) => onUpdate('plexLibraryId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">Select a library...</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.title} ({lib.type})
</option>
))}
</select>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Select the Plex library containing your audiobooks
</p>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
About Plex Token
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Your Plex authentication token is required to connect to your Plex server.
Ensure you have access to your Plex server before proceeding.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={handleNext}>Next</Button>
</div>
</div>
);
}
+429
View File
@@ -0,0 +1,429 @@
/**
* Component: Setup Wizard Prowlarr Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface ProwlarrStepProps {
prowlarrUrl: string;
prowlarrApiKey: string;
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
}
interface IndexerInfo {
id: number;
name: string;
protocol: string;
supportsRss: boolean;
}
interface SelectedIndexer {
id: number;
name: string;
priority: number;
seedingTimeMinutes: number;
rssEnabled: boolean;
}
export function ProwlarrStep({
prowlarrUrl,
prowlarrApiKey,
onUpdate,
onNext,
onBack,
}: ProwlarrStepProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
indexerCount?: number;
} | null>(null);
const [availableIndexers, setAvailableIndexers] = useState<IndexerInfo[]>([]);
const [selectedIndexers, setSelectedIndexers] = useState<Record<number, SelectedIndexer>>({});
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/setup/test-prowlarr', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: prowlarrUrl, apiKey: prowlarrApiKey }),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({
success: true,
message: `Connected successfully! Found ${data.indexerCount || 0} configured indexers.`,
indexerCount: data.indexerCount,
});
setAvailableIndexers(data.indexers || []);
// Auto-select all indexers with default priority of 10, seeding time of 0 (unlimited), and RSS enabled if supported
const autoSelected: Record<number, SelectedIndexer> = {};
data.indexers.forEach((indexer: IndexerInfo) => {
autoSelected[indexer.id] = {
id: indexer.id,
name: indexer.name,
priority: 10,
seedingTimeMinutes: 0,
rssEnabled: indexer.supportsRss, // Enable RSS by default if supported
};
});
setSelectedIndexers(autoSelected);
onUpdate('prowlarrIndexers', Object.values(autoSelected));
} else {
setTestResult({
success: false,
message: data.error || 'Connection failed',
});
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed',
});
} finally {
setTesting(false);
}
};
const toggleIndexer = (indexer: IndexerInfo) => {
setSelectedIndexers((prev) => {
const newSelected = { ...prev };
if (newSelected[indexer.id]) {
delete newSelected[indexer.id];
} else {
newSelected[indexer.id] = {
id: indexer.id,
name: indexer.name,
priority: 10, // Default priority
seedingTimeMinutes: 0, // Default: unlimited seeding
rssEnabled: indexer.supportsRss, // Enable RSS by default if supported
};
}
onUpdate('prowlarrIndexers', Object.values(newSelected));
return newSelected;
});
};
const updatePriority = (indexerId: number, priority: number) => {
setSelectedIndexers((prev) => {
const newSelected = { ...prev };
if (newSelected[indexerId]) {
newSelected[indexerId] = {
...newSelected[indexerId],
priority: Math.max(1, Math.min(25, priority)), // Clamp between 1-25
};
}
onUpdate('prowlarrIndexers', Object.values(newSelected));
return newSelected;
});
};
const updateSeedingTime = (indexerId: number, value: string) => {
setSelectedIndexers((prev) => {
const newSelected = { ...prev };
if (newSelected[indexerId]) {
const seedingTimeMinutes = value === '' ? 0 : parseInt(value);
newSelected[indexerId] = {
...newSelected[indexerId],
seedingTimeMinutes: isNaN(seedingTimeMinutes) ? 0 : Math.max(0, seedingTimeMinutes),
};
}
onUpdate('prowlarrIndexers', Object.values(newSelected));
return newSelected;
});
};
const toggleRss = (indexerId: number) => {
setSelectedIndexers((prev) => {
const newSelected = { ...prev };
if (newSelected[indexerId]) {
newSelected[indexerId] = {
...newSelected[indexerId],
rssEnabled: !newSelected[indexerId].rssEnabled,
};
}
onUpdate('prowlarrIndexers', Object.values(newSelected));
return newSelected;
});
};
const handleNext = () => {
if (!testResult?.success) {
setTestResult({
success: false,
message: 'Please test the connection before proceeding',
});
return;
}
if (Object.keys(selectedIndexers).length === 0) {
setTestResult({
success: false,
message: 'Please select at least one indexer',
});
return;
}
onNext();
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configure Prowlarr
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Connect to Prowlarr to search for audiobooks across multiple indexers.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Prowlarr URL
</label>
<Input
type="url"
placeholder="http://localhost:9696"
value={prowlarrUrl}
onChange={(e) => onUpdate('prowlarrUrl', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The URL where Prowlarr is running (include port)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Key
</label>
<Input
type="password"
placeholder="Enter your Prowlarr API key"
value={prowlarrApiKey}
onChange={(e) => onUpdate('prowlarrApiKey', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Find this in Prowlarr Settings General Security API Key
</p>
</div>
<Button
onClick={testConnection}
loading={testing}
disabled={!prowlarrUrl || !prowlarrApiKey}
variant="outline"
className="w-full"
>
Test Connection
</Button>
{testResult && (
<div
className={`rounded-lg p-4 ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex gap-3">
<svg
className={`w-6 h-6 flex-shrink-0 ${
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
{testResult.success ? (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
)}
</svg>
<div>
<h3
className={`text-sm font-medium ${
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3>
<p
className={`text-sm mt-1 ${
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p>
</div>
</div>
</div>
)}
{/* Indexer Selection */}
{availableIndexers.length > 0 && (
<div className="space-y-3">
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
Select Indexers & Configure (Priority: 1-25, Seeding Time, RSS)
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Higher priority indexers (closer to 25) will be preferred when ranking search results.
Seeding time is in minutes (0 = unlimited). Files will be kept until the seeding requirement is met.
Enable RSS to automatically monitor indexer feeds for new releases matching your missing list (default: every 15 minutes, configurable in scheduled jobs settings).
</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
{availableIndexers.map((indexer) => (
<div
key={indexer.id}
className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<input
type="checkbox"
id={`indexer-${indexer.id}`}
checked={!!selectedIndexers[indexer.id]}
onChange={() => toggleIndexer(indexer)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label
htmlFor={`indexer-${indexer.id}`}
className="flex-1 text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
{indexer.name}
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
({indexer.protocol})
</span>
</label>
{selectedIndexers[indexer.id] && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label
htmlFor={`priority-${indexer.id}`}
className="text-xs text-gray-600 dark:text-gray-400"
>
Priority:
</label>
<input
id={`priority-${indexer.id}`}
type="number"
min="1"
max="25"
value={selectedIndexers[indexer.id].priority}
onChange={(e) =>
updatePriority(indexer.id, parseInt(e.target.value) || 10)
}
className="w-16 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
<div className="flex items-center gap-2">
<label
htmlFor={`seeding-${indexer.id}`}
className="text-xs text-gray-600 dark:text-gray-400"
>
Seeding (min):
</label>
<input
id={`seeding-${indexer.id}`}
type="number"
min="0"
step="1"
value={selectedIndexers[indexer.id].seedingTimeMinutes}
onChange={(e) =>
updateSeedingTime(indexer.id, e.target.value)
}
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="0 = ∞"
/>
</div>
{indexer.supportsRss && (
<div className="flex items-center gap-2">
<label
htmlFor={`rss-${indexer.id}`}
className="text-xs text-gray-600 dark:text-gray-400"
>
RSS:
</label>
<input
id={`rss-${indexer.id}`}
type="checkbox"
checked={selectedIndexers[indexer.id].rssEnabled}
onChange={() => toggleRss(indexer.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
</div>
)}
</div>
)}
</div>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Selected: {Object.keys(selectedIndexers).length} of {availableIndexers.length} indexers
</p>
</div>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
About Prowlarr Indexers
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Prowlarr searches across multiple torrent indexers. Select which indexers to use and assign priorities to control
how search results are ranked. Make sure you have at least one indexer configured in Prowlarr before proceeding.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={handleNext}>Next</Button>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
/**
* Component: Registration Settings Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { Button } from '@/components/ui/Button';
interface RegistrationSettingsStepProps {
requireAdminApproval: boolean;
onUpdate: (field: string, value: boolean) => void;
onNext: () => void;
onBack: () => void;
}
export function RegistrationSettingsStep({
requireAdminApproval,
onUpdate,
onNext,
onBack,
}: RegistrationSettingsStepProps) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Registration Settings
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Configure how manual user registration will work.
</p>
</div>
<div className="space-y-6">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900 dark:text-gray-100">
User Registration
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manual registration is enabled. Users can create accounts with username
and password.
</p>
</div>
<div className="bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 px-3 py-1 rounded-full text-sm font-medium">
Enabled
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
Require Admin Approval
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
New user accounts must be approved by an administrator before they can
log in. Recommended for additional security.
</p>
</div>
<button
onClick={() => onUpdate('requireAdminApproval', !requireAdminApproval)}
className={`
relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${requireAdminApproval ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'}
`}
>
<span
className={`
pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0
transition duration-200 ease-in-out
${requireAdminApproval ? 'translate-x-5' : 'translate-x-0'}
`}
/>
</button>
</div>
</div>
{requireAdminApproval && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Admin Approval Workflow
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
When a user registers, their account will be created with "pending
approval" status. They won't be able to log in until you approve their
account in the admin panel.
</p>
</div>
</div>
</div>
)}
{!requireAdminApproval && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-yellow-900 dark:text-yellow-100">
Auto-Approval Enabled
</p>
<p className="text-sm text-yellow-800 dark:text-yellow-200 mt-1">
Users will be automatically approved and can log in immediately after
registration. Consider enabling admin approval for better security.
</p>
</div>
</div>
</div>
)}
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
Rate Limiting
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Registration is automatically rate-limited to 5 attempts per hour per IP
address to prevent abuse.
</p>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onNext}>Next</Button>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Component: Setup Wizard Review Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { Button } from '@/components/ui/Button';
interface ReviewStepProps {
config: {
plexUrl: string;
plexLibraryId: string;
prowlarrUrl: string;
downloadClient: 'qbittorrent' | 'transmission';
downloadClientUrl: string;
downloadDir: string;
mediaDir: string;
};
loading: boolean;
error: string | null;
onComplete: () => void;
onBack: () => void;
}
export function ReviewStep({ config, loading, error, onComplete, onBack }: ReviewStepProps) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Review Configuration
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Please review your configuration before completing setup.
</p>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<div>
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{error}</p>
</div>
</div>
</div>
)}
<div className="space-y-4">
{/* Plex Configuration */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Plex Media Server
</h3>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
{config.plexUrl}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Library ID:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
{config.plexLibraryId}
</dd>
</div>
</dl>
</div>
{/* Prowlarr Configuration */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Prowlarr (Indexer)
</h3>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
{config.prowlarrUrl}
</dd>
</div>
</dl>
</div>
{/* Download Client Configuration */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Download Client
</h3>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Type:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 capitalize">
{config.downloadClient}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
{config.downloadClientUrl}
</dd>
</div>
</dl>
</div>
{/* Paths Configuration */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Directory Paths
</h3>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Download Directory:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 font-mono">
{config.downloadDir}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-600 dark:text-gray-400">Media Directory:</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 font-mono">
{config.mediaDir}
</dd>
</div>
</dl>
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Ready to complete setup
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Click "Complete Setup" to save your configuration and start using ReadMeABook.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline" disabled={loading}>
Back
</Button>
<Button onClick={onComplete} loading={loading} size="lg">
Complete Setup
</Button>
</div>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Component: Setup Wizard Welcome Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { Button } from '@/components/ui/Button';
interface WelcomeStepProps {
onNext: () => void;
}
export function WelcomeStep({ onNext }: WelcomeStepProps) {
return (
<div className="space-y-6">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div
className="w-20 h-20 rounded-full flex items-center justify-center p-4"
style={{ backgroundColor: '#f7f4f3' }}
>
<img
src="/rmab_32x32.png"
alt="ReadMeABook Logo"
className="w-full h-full object-contain"
/>
</div>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Welcome to ReadMeABook!
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
Let's get your audiobook automation system configured. This setup wizard will guide you
through connecting your external services and configuring directory paths.
</p>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6 space-y-4">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100">
What you'll need:
</h3>
<ul className="space-y-3">
<li className="flex items-start gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<div>
<strong className="text-gray-900 dark:text-gray-100">Plex Media Server</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Your Plex server URL and authentication token
</p>
</div>
</li>
<li className="flex items-start gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<div>
<strong className="text-gray-900 dark:text-gray-100">Prowlarr</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Indexer aggregator for searching torrents (URL and API key)
</p>
</div>
</li>
<li className="flex items-start gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<div>
<strong className="text-gray-900 dark:text-gray-100">
qBittorrent or Transmission
</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Download client for managing torrent downloads (URL and credentials)
</p>
</div>
</li>
<li className="flex items-start gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<div>
<strong className="text-gray-900 dark:text-gray-100">Directory Paths</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Download directory and media library directory paths
</p>
</div>
</li>
</ul>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-yellow-900 dark:text-yellow-100">
Setup Time: 5-10 minutes
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
Make sure all external services are running and accessible before proceeding.
</p>
</div>
</div>
</div>
<div className="flex justify-end pt-4">
<Button onClick={onNext} size="lg">
Get Started
<svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Button>
</div>
</div>
);
}