Initial release v1.0.0
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SettingsSection, SettingsRow, SettingsInput, SettingsToggle } from "../ui";
|
||||
import { SystemSettings } from "../../types";
|
||||
import { InlineStatus, StatusType } from "@/components/ui/InlineStatus";
|
||||
|
||||
interface AIServicesSectionProps {
|
||||
settings: SystemSettings;
|
||||
onUpdate: (updates: Partial<SystemSettings>) => void;
|
||||
onTest: (service: string) => Promise<{ success: boolean; version?: string; error?: string }>;
|
||||
isTesting: boolean;
|
||||
}
|
||||
|
||||
export function AIServicesSection({ settings, onUpdate, onTest, isTesting }: AIServicesSectionProps) {
|
||||
const [testStatus, setTestStatus] = useState<StatusType>("idle");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestStatus("loading");
|
||||
setTestMessage("Testing...");
|
||||
const result = await onTest("fanart");
|
||||
if (result.success) {
|
||||
setTestStatus("success");
|
||||
setTestMessage("Connected");
|
||||
} else {
|
||||
setTestStatus("error");
|
||||
setTestMessage(result.error || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="ai-services"
|
||||
title="Artwork Services"
|
||||
description="Enhance your library with high-quality artwork"
|
||||
>
|
||||
{/* Fanart.tv */}
|
||||
<SettingsRow
|
||||
label="Enable Fanart.tv"
|
||||
description="Enhanced artist and album artwork"
|
||||
htmlFor="fanart-enabled"
|
||||
>
|
||||
<SettingsToggle
|
||||
id="fanart-enabled"
|
||||
checked={settings.fanartEnabled}
|
||||
onChange={(checked) => onUpdate({ fanartEnabled: checked })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{settings.fanartEnabled && (
|
||||
<>
|
||||
<SettingsRow label="API Key">
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={settings.fanartApiKey}
|
||||
onChange={(v) => onUpdate({ fanartApiKey: v })}
|
||||
placeholder="Enter Fanart.tv API key"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isTesting || !settings.fanartApiKey}
|
||||
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full
|
||||
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testStatus === "loading" ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
<InlineStatus
|
||||
status={testStatus}
|
||||
message={testMessage}
|
||||
onClear={() => setTestStatus("idle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAPIKeys } from '@/features/settings/hooks/useAPIKeys';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Copy, Trash2 } from 'lucide-react';
|
||||
import { InlineStatus, StatusType } from "@/components/ui/InlineStatus";
|
||||
|
||||
export const APIKeysSection: React.FC = () => {
|
||||
const {
|
||||
apiKeys,
|
||||
loadingApiKeys,
|
||||
generatedApiKey,
|
||||
showCreateApiKeyDialog,
|
||||
setShowCreateApiKeyDialog,
|
||||
loadApiKeys,
|
||||
createApiKey,
|
||||
revokeApiKey,
|
||||
clearGeneratedKey,
|
||||
} = useAPIKeys();
|
||||
|
||||
const [newApiKeyName, setNewApiKeyName] = useState('');
|
||||
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [createStatus, setCreateStatus] = useState<StatusType>("idle");
|
||||
const [createMessage, setCreateMessage] = useState("");
|
||||
const [revokeStatus, setRevokeStatus] = useState<StatusType>("idle");
|
||||
const [revokeMessage, setRevokeMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadApiKeys();
|
||||
}, []);
|
||||
|
||||
const handleCreateApiKey = async () => {
|
||||
if (!newApiKeyName.trim()) {
|
||||
setCreateStatus("error");
|
||||
setCreateMessage("Name required");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateStatus("loading");
|
||||
const result = await createApiKey(newApiKeyName);
|
||||
if (result.success) {
|
||||
setCreateStatus("success");
|
||||
setCreateMessage("Created");
|
||||
setNewApiKeyName('');
|
||||
setShowCreateApiKeyDialog(false);
|
||||
} else {
|
||||
setCreateStatus("error");
|
||||
setCreateMessage(result.error || "Failed");
|
||||
}
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const handleRevokeApiKey = async () => {
|
||||
if (!confirmRevoke) return;
|
||||
|
||||
setRevokeStatus("loading");
|
||||
const result = await revokeApiKey(confirmRevoke);
|
||||
if (result.success) {
|
||||
setRevokeStatus("success");
|
||||
setRevokeMessage("Revoked");
|
||||
setConfirmRevoke(null);
|
||||
} else {
|
||||
setRevokeStatus("error");
|
||||
setRevokeMessage(result.error || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyKey = () => {
|
||||
if (generatedApiKey) {
|
||||
navigator.clipboard.writeText(generatedApiKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissKey = () => {
|
||||
clearGeneratedKey();
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="api-keys" className="bg-[#111] rounded-lg p-6 scroll-mt-8">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">API Keys</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Manage API keys for programmatic access to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Generated Key Display */}
|
||||
{generatedApiKey && (
|
||||
<div className="bg-yellow-900/20 border border-yellow-700/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-yellow-200 mb-2">
|
||||
Your new API key
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={generatedApiKey}
|
||||
className="flex-1 bg-black/50 border border-yellow-700/50 rounded px-3 py-2 text-sm text-white font-mono"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCopyKey}
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-yellow-200 mt-2">
|
||||
Save this key now, you won't be able to see it again
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleDismissKey}
|
||||
variant="ghost"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Button */}
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => setShowCreateApiKeyDialog(true)}
|
||||
variant="primary"
|
||||
>
|
||||
Generate New API Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* API Keys Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1c1c1c]">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">
|
||||
Device Name
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">
|
||||
Key Preview
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">
|
||||
Created
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">
|
||||
Last Used
|
||||
</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-400">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loadingApiKeys ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-sm text-gray-400">
|
||||
Loading API keys...
|
||||
</td>
|
||||
</tr>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-sm text-gray-400">
|
||||
No API keys yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
apiKeys.map((key) => (
|
||||
<tr
|
||||
key={key.id}
|
||||
className="border-b border-[#1c1c1c] hover:bg-[#0a0a0a]"
|
||||
>
|
||||
<td className="py-3 px-4 text-sm text-white">
|
||||
{key.name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-400 font-mono">
|
||||
{key.keyPreview}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-400">
|
||||
{formatDate(key.createdAt)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-400">
|
||||
{key.lastUsedAt ? formatDate(key.lastUsedAt) : 'Never'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm">
|
||||
<Button
|
||||
onClick={() => setConfirmRevoke(key.id)}
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Revoke
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
{showCreateApiKeyDialog && (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
setShowCreateApiKeyDialog(false);
|
||||
setNewApiKeyName('');
|
||||
setCreateStatus("idle");
|
||||
}}
|
||||
title="Generate New API Key"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Device Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newApiKeyName}
|
||||
onChange={(e) => setNewApiKeyName(e.target.value)}
|
||||
placeholder="e.g., My Laptop, Production Server"
|
||||
className="w-full bg-[#0a0a0a] border border-[#1c1c1c] rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end items-center gap-3">
|
||||
<InlineStatus
|
||||
status={createStatus}
|
||||
message={createMessage}
|
||||
onClear={() => setCreateStatus("idle")}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCreateApiKeyDialog(false);
|
||||
setNewApiKeyName('');
|
||||
setCreateStatus("idle");
|
||||
}}
|
||||
variant="ghost"
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateApiKey}
|
||||
variant="primary"
|
||||
disabled={!newApiKeyName.trim() || creating}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Revoke Confirmation Modal */}
|
||||
{confirmRevoke && (
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
setConfirmRevoke(null);
|
||||
setRevokeStatus("idle");
|
||||
}}
|
||||
title="Revoke API Key"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-300">
|
||||
Are you sure you want to revoke this API key? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end items-center gap-3">
|
||||
<InlineStatus
|
||||
status={revokeStatus}
|
||||
message={revokeMessage}
|
||||
onClear={() => setRevokeStatus("idle")}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setConfirmRevoke(null);
|
||||
setRevokeStatus("idle");
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRevokeApiKey}
|
||||
variant="primary"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SettingsSection, SettingsRow, SettingsInput } from "../ui";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTwoFactor } from "../../hooks/useTwoFactor";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { InlineStatus, StatusType } from "@/components/ui/InlineStatus";
|
||||
|
||||
export function AccountSection() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Password change state
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||
const [passwordStatus, setPasswordStatus] = useState<StatusType>("idle");
|
||||
const [passwordMessage, setPasswordMessage] = useState("");
|
||||
|
||||
// 2FA state
|
||||
const {
|
||||
twoFactorEnabled,
|
||||
settingUpTwoFactor,
|
||||
twoFactorQR,
|
||||
twoFactorSecret,
|
||||
recoveryCodes,
|
||||
showRecoveryCodes,
|
||||
load2FAStatus,
|
||||
setup2FA,
|
||||
enable2FA,
|
||||
disable2FA,
|
||||
cancel2FASetup,
|
||||
closeRecoveryCodes,
|
||||
} = useTwoFactor();
|
||||
|
||||
const [twoFactorToken, setTwoFactorToken] = useState("");
|
||||
const [disablePassword, setDisablePassword] = useState("");
|
||||
const [disableToken, setDisableToken] = useState("");
|
||||
const [showDisableFlow, setShowDisableFlow] = useState(false);
|
||||
const [tfaStatus, setTfaStatus] = useState<StatusType>("idle");
|
||||
const [tfaMessage, setTfaMessage] = useState("");
|
||||
|
||||
// Handle password change
|
||||
const handleChangePassword = async () => {
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setPasswordStatus("error");
|
||||
setPasswordMessage("All fields required");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setPasswordStatus("error");
|
||||
setPasswordMessage("Min 6 characters");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordStatus("error");
|
||||
setPasswordMessage("Passwords don't match");
|
||||
return;
|
||||
}
|
||||
|
||||
setChangingPassword(true);
|
||||
setPasswordStatus("loading");
|
||||
try {
|
||||
await api.post("/auth/change-password", {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
setPasswordStatus("success");
|
||||
setPasswordMessage("Changed");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTimeout(() => setShowPasswordForm(false), 1500);
|
||||
} catch (error: any) {
|
||||
setPasswordStatus("error");
|
||||
setPasswordMessage(error.response?.data?.message || "Failed");
|
||||
} finally {
|
||||
setChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle 2FA verification
|
||||
const handleVerify2FA = async () => {
|
||||
setTfaStatus("loading");
|
||||
try {
|
||||
await enable2FA(twoFactorToken);
|
||||
setTfaStatus("success");
|
||||
setTfaMessage("Enabled");
|
||||
setTwoFactorToken("");
|
||||
} catch (error: any) {
|
||||
setTfaStatus("error");
|
||||
setTfaMessage(error.message || "Invalid code");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle 2FA disable
|
||||
const handleDisable2FA = async () => {
|
||||
setTfaStatus("loading");
|
||||
try {
|
||||
await disable2FA(disablePassword, disableToken);
|
||||
setTfaStatus("success");
|
||||
setTfaMessage("Disabled");
|
||||
setDisablePassword("");
|
||||
setDisableToken("");
|
||||
setShowDisableFlow(false);
|
||||
} catch (error: any) {
|
||||
setTfaStatus("error");
|
||||
setTfaMessage(error.message || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection id="account" title="Account">
|
||||
{/* Username Display */}
|
||||
<SettingsRow label="Username" description={`Logged in as ${user?.username}`}>
|
||||
<span className="text-sm text-gray-400">{user?.role}</span>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Change Password */}
|
||||
<SettingsRow
|
||||
label="Password"
|
||||
description="Change your account password"
|
||||
>
|
||||
{!showPasswordForm ? (
|
||||
<button
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
className="text-sm text-white hover:underline"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowPasswordForm(false)}
|
||||
className="text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
{showPasswordForm && (
|
||||
<div className="py-4 space-y-3 border-t border-b border-white/5">
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={setCurrentPassword}
|
||||
placeholder="Current password"
|
||||
/>
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
placeholder="New password (min 6 characters)"
|
||||
/>
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
disabled={changingPassword || !currentPassword || !newPassword || newPassword !== confirmPassword}
|
||||
className="px-4 py-2 bg-white text-black text-sm font-medium rounded-full
|
||||
hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed transition-transform"
|
||||
>
|
||||
{changingPassword ? "Changing..." : "Change Password"}
|
||||
</button>
|
||||
<InlineStatus
|
||||
status={passwordStatus}
|
||||
message={passwordMessage}
|
||||
onClear={() => setPasswordStatus("idle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-Factor Authentication */}
|
||||
<SettingsRow
|
||||
label="Two-factor authentication"
|
||||
description={twoFactorEnabled ? "Enabled" : "Add extra security to your account"}
|
||||
>
|
||||
{!settingUpTwoFactor && !showDisableFlow && (
|
||||
twoFactorEnabled ? (
|
||||
<button
|
||||
onClick={() => setShowDisableFlow(true)}
|
||||
className="text-sm text-red-400 hover:text-red-300"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={setup2FA}
|
||||
className="text-sm text-white hover:underline"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
||||
{/* 2FA Setup Flow */}
|
||||
{settingUpTwoFactor && (
|
||||
<div className="py-4 space-y-4 border-t border-b border-white/5">
|
||||
<p className="text-sm text-gray-400">
|
||||
Scan the QR code with your authenticator app, then enter the code below.
|
||||
</p>
|
||||
|
||||
{twoFactorQR && (
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<img src={twoFactorQR} alt="2FA QR Code" className="w-40 h-40" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFactorSecret && (
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-1">Manual entry code:</p>
|
||||
<code className="text-sm text-white bg-[#282828] px-3 py-1 rounded font-mono">
|
||||
{twoFactorSecret}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
type="text"
|
||||
value={twoFactorToken}
|
||||
onChange={(v) => setTwoFactorToken(v.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="Enter 6-digit code"
|
||||
/>
|
||||
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleVerify2FA}
|
||||
disabled={twoFactorToken.length !== 6}
|
||||
className="px-4 py-2 bg-white text-black text-sm font-medium rounded-full
|
||||
hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed transition-transform"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { cancel2FASetup(); setTwoFactorToken(""); }}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<InlineStatus
|
||||
status={tfaStatus}
|
||||
message={tfaMessage}
|
||||
onClear={() => setTfaStatus("idle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2FA Disable Flow */}
|
||||
{showDisableFlow && (
|
||||
<div className="py-4 space-y-3 border-t border-b border-white/5">
|
||||
<p className="text-sm text-yellow-500">
|
||||
Enter your password and current code to disable 2FA.
|
||||
</p>
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={disablePassword}
|
||||
onChange={setDisablePassword}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<SettingsInput
|
||||
type="text"
|
||||
value={disableToken}
|
||||
onChange={(v) => setDisableToken(v.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="6-digit code"
|
||||
/>
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleDisable2FA}
|
||||
disabled={!disablePassword || disableToken.length !== 6}
|
||||
className="px-4 py-2 bg-red-500 text-white text-sm font-medium rounded-full
|
||||
hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowDisableFlow(false); setDisablePassword(""); setDisableToken(""); }}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<InlineStatus
|
||||
status={tfaStatus}
|
||||
message={tfaMessage}
|
||||
onClear={() => setTfaStatus("idle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Recovery Codes Modal */}
|
||||
<Modal isOpen={showRecoveryCodes} onClose={closeRecoveryCodes} title="Recovery Codes">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
|
||||
<p className="text-sm text-red-300">
|
||||
Save these codes! You'll need them if you lose your authenticator.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{recoveryCodes.map((code, i) => (
|
||||
<code key={i} className="text-sm text-white bg-[#282828] px-3 py-2 rounded font-mono">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(recoveryCodes.join("\n"))}
|
||||
className="px-4 py-2 bg-[#333] text-white text-sm rounded-full hover:bg-[#404040]"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={closeRecoveryCodes}
|
||||
className="px-4 py-2 bg-white text-black text-sm font-medium rounded-full hover:scale-105 transition-transform"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SettingsSection, SettingsRow, SettingsInput, SettingsToggle } from "../ui";
|
||||
import { SystemSettings } from "../../types";
|
||||
import { InlineStatus, StatusType } from "@/components/ui/InlineStatus";
|
||||
|
||||
interface AudiobookshelfSectionProps {
|
||||
settings: SystemSettings;
|
||||
onUpdate: (updates: Partial<SystemSettings>) => void;
|
||||
onTest: (service: string) => Promise<{ success: boolean; version?: string; error?: string }>;
|
||||
isTesting: boolean;
|
||||
}
|
||||
|
||||
export function AudiobookshelfSection({ settings, onUpdate, onTest, isTesting }: AudiobookshelfSectionProps) {
|
||||
const [testStatus, setTestStatus] = useState<StatusType>("idle");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestStatus("loading");
|
||||
setTestMessage("Testing...");
|
||||
const result = await onTest("audiobookshelf");
|
||||
if (result.success) {
|
||||
setTestStatus("success");
|
||||
setTestMessage(result.version ? `v${result.version}` : "Connected");
|
||||
} else {
|
||||
setTestStatus("error");
|
||||
setTestMessage(result.error || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="audiobookshelf"
|
||||
title="Media Servers"
|
||||
description="Connect to external media servers for audiobooks and podcasts"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Enable Audiobookshelf"
|
||||
description="Connect for audiobooks and podcasts"
|
||||
htmlFor="abs-enabled"
|
||||
>
|
||||
<SettingsToggle
|
||||
id="abs-enabled"
|
||||
checked={settings.audiobookshelfEnabled}
|
||||
onChange={(checked) => onUpdate({ audiobookshelfEnabled: checked })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{settings.audiobookshelfEnabled && (
|
||||
<>
|
||||
<SettingsRow label="Server URL">
|
||||
<SettingsInput
|
||||
value={settings.audiobookshelfUrl}
|
||||
onChange={(v) => onUpdate({ audiobookshelfUrl: v })}
|
||||
placeholder="http://localhost:13378"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label="API Key">
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={settings.audiobookshelfApiKey}
|
||||
onChange={(v) => onUpdate({ audiobookshelfApiKey: v })}
|
||||
placeholder="Enter API key"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isTesting || !settings.audiobookshelfUrl || !settings.audiobookshelfApiKey}
|
||||
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full
|
||||
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testStatus === "loading" ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
<InlineStatus
|
||||
status={testStatus}
|
||||
message={testMessage}
|
||||
onClear={() => setTestStatus("idle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
331
frontend/features/settings/components/sections/CacheSection.tsx
Normal file
331
frontend/features/settings/components/sections/CacheSection.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SettingsSection, SettingsRow, SettingsToggle } from "../ui";
|
||||
import { SystemSettings } from "../../types";
|
||||
import { api } from "@/lib/api";
|
||||
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle, Loader2, User, Heart, Activity } from "lucide-react";
|
||||
|
||||
interface CacheSectionProps {
|
||||
settings: SystemSettings;
|
||||
onUpdate: (updates: Partial<SystemSettings>) => void;
|
||||
}
|
||||
|
||||
// Progress bar component
|
||||
function ProgressBar({
|
||||
progress,
|
||||
color = "bg-[#ecb200]",
|
||||
showPercentage = true
|
||||
}: {
|
||||
progress: number;
|
||||
color?: string;
|
||||
showPercentage?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${color} transition-all duration-500 ease-out`}
|
||||
style={{ width: `${Math.min(100, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showPercentage && (
|
||||
<span className="text-xs text-white/50 w-10 text-right">{progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Enrichment stage component
|
||||
function EnrichmentStage({
|
||||
icon: Icon,
|
||||
label,
|
||||
description,
|
||||
completed,
|
||||
total,
|
||||
progress,
|
||||
isBackground = false,
|
||||
failed = 0,
|
||||
processing = 0,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
description: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
progress: number;
|
||||
isBackground?: boolean;
|
||||
failed?: number;
|
||||
processing?: number;
|
||||
}) {
|
||||
const isComplete = progress === 100;
|
||||
const hasActivity = processing > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<div className={`mt-0.5 p-1.5 rounded-lg ${isComplete ? 'bg-green-500/20' : 'bg-white/5'}`}>
|
||||
{isComplete ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : hasActivity ? (
|
||||
<Loader2 className="w-4 h-4 text-[#ecb200] animate-spin" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4 text-white/40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">{label}</span>
|
||||
{isBackground && !isComplete && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-white/10 text-white/50">
|
||||
background
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-white/40 mt-0.5">{description}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
color={isComplete ? "bg-green-500" : isBackground ? "bg-purple-500" : "bg-[#ecb200]"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-[10px] text-white/30">
|
||||
<span>{completed} / {total}</span>
|
||||
{processing > 0 && <span className="text-[#ecb200]">{processing} processing</span>}
|
||||
{failed > 0 && <span className="text-red-400">{failed} failed</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CacheSection({ settings, onUpdate }: CacheSectionProps) {
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [clearingCaches, setClearingCaches] = useState(false);
|
||||
const [reEnriching, setReEnriching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch enrichment progress
|
||||
const { data: enrichmentProgress, refetch: refetchProgress } = useQuery({
|
||||
queryKey: ["enrichment-progress"],
|
||||
queryFn: () => api.getEnrichmentProgress(),
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
staleTime: 2000,
|
||||
});
|
||||
|
||||
const refreshNotifications = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["unread-notification-count"] });
|
||||
window.dispatchEvent(new CustomEvent("notifications-changed"));
|
||||
};
|
||||
|
||||
const handleSyncAndEnrich = async () => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (settings.autoEnrichMetadata) {
|
||||
await api.post("/audiobooks/sync", {});
|
||||
}
|
||||
await api.post("/podcasts/sync-covers", {});
|
||||
await api.startLibraryEnrichment();
|
||||
refreshNotifications();
|
||||
refetchProgress();
|
||||
} catch (err) {
|
||||
console.error("Sync error:", err);
|
||||
setError("Failed to sync");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFullEnrichment = async () => {
|
||||
setReEnriching(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.triggerFullEnrichment();
|
||||
refreshNotifications();
|
||||
refetchProgress();
|
||||
} catch (err) {
|
||||
console.error("Full enrichment error:", err);
|
||||
setError("Failed to start full enrichment");
|
||||
} finally {
|
||||
setReEnriching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCaches = async () => {
|
||||
setClearingCaches(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.clearAllCaches();
|
||||
refreshNotifications();
|
||||
} catch (err) {
|
||||
setError("Failed to clear caches");
|
||||
} finally {
|
||||
setClearingCaches(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection id="cache" title="Cache & Automation">
|
||||
{/* Enrichment Progress */}
|
||||
{enrichmentProgress && (
|
||||
<div className="mb-6 p-4 bg-white/5 rounded-lg border border-white/10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-white">Library Enrichment</h3>
|
||||
{enrichmentProgress.coreComplete && !enrichmentProgress.isFullyComplete && (
|
||||
<span className="text-xs text-purple-400 flex items-center gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Audio analysis running
|
||||
</span>
|
||||
)}
|
||||
{enrichmentProgress.isFullyComplete && (
|
||||
<span className="text-xs text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<EnrichmentStage
|
||||
icon={User}
|
||||
label="Artist Metadata"
|
||||
description="Bios, images, and similar artists from Last.fm"
|
||||
completed={enrichmentProgress.artists.completed}
|
||||
total={enrichmentProgress.artists.total}
|
||||
progress={enrichmentProgress.artists.progress}
|
||||
failed={enrichmentProgress.artists.failed}
|
||||
/>
|
||||
|
||||
<EnrichmentStage
|
||||
icon={Heart}
|
||||
label="Mood Tags"
|
||||
description="Vibes and mood data from Last.fm"
|
||||
completed={enrichmentProgress.trackTags.enriched}
|
||||
total={enrichmentProgress.trackTags.total}
|
||||
progress={enrichmentProgress.trackTags.progress}
|
||||
/>
|
||||
|
||||
<EnrichmentStage
|
||||
icon={Activity}
|
||||
label="Audio Analysis"
|
||||
description="BPM, key, energy, and danceability from audio files"
|
||||
completed={enrichmentProgress.audioAnalysis.completed}
|
||||
total={enrichmentProgress.audioAnalysis.total}
|
||||
progress={enrichmentProgress.audioAnalysis.progress}
|
||||
processing={enrichmentProgress.audioAnalysis.processing}
|
||||
failed={enrichmentProgress.audioAnalysis.failed}
|
||||
isBackground={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-white/10">
|
||||
<button
|
||||
onClick={handleSyncAndEnrich}
|
||||
disabled={syncing || reEnriching}
|
||||
className="px-3 py-1.5 text-xs bg-white text-black font-medium rounded-full
|
||||
hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed transition-transform"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync New"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFullEnrichment}
|
||||
disabled={syncing || reEnriching}
|
||||
className="px-3 py-1.5 text-xs bg-[#333] text-white rounded-full
|
||||
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{reEnriching ? "Starting..." : "Re-enrich All"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cache Sizes */}
|
||||
<SettingsRow
|
||||
label="User cache size"
|
||||
description="Maximum storage for offline content"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={512}
|
||||
max={20480}
|
||||
step={512}
|
||||
value={settings.maxCacheSizeMb}
|
||||
onChange={(e) => onUpdate({ maxCacheSizeMb: parseInt(e.target.value) })}
|
||||
className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
/>
|
||||
<span className="text-sm text-white w-16 text-right">
|
||||
{(settings.maxCacheSizeMb / 1024).toFixed(1)} GB
|
||||
</span>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Transcode cache size"
|
||||
description="Server restart required for changes"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={50}
|
||||
value={settings.transcodeCacheMaxGb}
|
||||
onChange={(e) => onUpdate({ transcodeCacheMaxGb: parseInt(e.target.value) })}
|
||||
className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
|
||||
/>
|
||||
<span className="text-sm text-white w-16 text-right">
|
||||
{settings.transcodeCacheMaxGb} GB
|
||||
</span>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Automation */}
|
||||
<SettingsRow
|
||||
label="Auto sync library"
|
||||
description="Automatically sync library changes"
|
||||
htmlFor="auto-sync"
|
||||
>
|
||||
<SettingsToggle
|
||||
id="auto-sync"
|
||||
checked={settings.autoSync}
|
||||
onChange={(checked) => onUpdate({ autoSync: checked })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Auto enrich metadata"
|
||||
description="Automatically enrich metadata for new content"
|
||||
htmlFor="auto-enrich"
|
||||
>
|
||||
<SettingsToggle
|
||||
id="auto-enrich"
|
||||
checked={settings.autoEnrichMetadata}
|
||||
onChange={(checked) => onUpdate({ autoEnrichMetadata: checked })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Cache Actions */}
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<button
|
||||
onClick={handleClearCaches}
|
||||
disabled={clearingCaches}
|
||||
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full w-fit
|
||||
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{clearingCaches ? "Clearing..." : "Clear All Caches"}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsSection, SettingsRow, SettingsSelect } from "../ui";
|
||||
import { SystemSettings } from "../../types";
|
||||
|
||||
interface DownloadPreferencesSectionProps {
|
||||
settings: SystemSettings;
|
||||
onUpdate: (updates: Partial<SystemSettings>) => void;
|
||||
}
|
||||
|
||||
export function DownloadPreferencesSection({
|
||||
settings,
|
||||
onUpdate,
|
||||
}: DownloadPreferencesSectionProps) {
|
||||
return (
|
||||
<SettingsSection
|
||||
id="download-preferences"
|
||||
title="Download Preferences"
|
||||
description="Configure how music is downloaded for playlists and discovery"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Primary Download Source"
|
||||
description="Choose how to download music for imported playlists"
|
||||
>
|
||||
<SettingsSelect
|
||||
value={settings.downloadSource || "soulseek"}
|
||||
onChange={(v) =>
|
||||
onUpdate({ downloadSource: v as "soulseek" | "lidarr" })
|
||||
}
|
||||
options={[
|
||||
{ value: "soulseek", label: "Soulseek (Per-track)" },
|
||||
{ value: "lidarr", label: "Lidarr (Full albums)" },
|
||||
]}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{settings.downloadSource === "soulseek" && (
|
||||
<SettingsRow
|
||||
label="When Soulseek Fails"
|
||||
description="What to do if a track can't be found on Soulseek"
|
||||
>
|
||||
<SettingsSelect
|
||||
value={settings.soulseekFallback || "none"}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
soulseekFallback: v as "none" | "lidarr",
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: "none", label: "Skip track" },
|
||||
{
|
||||
value: "lidarr",
|
||||
label: "Download full album via Lidarr",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SettingsSection, SettingsRow, SettingsInput, SettingsToggle } from "../ui";
|
||||
import { SystemSettings } from "../../types";
|
||||
import { InlineStatus, StatusType } from "@/components/ui/InlineStatus";
|
||||
|
||||
interface LidarrSectionProps {
|
||||
settings: SystemSettings;
|
||||
onUpdate: (updates: Partial<SystemSettings>) => void;
|
||||
onTest: (service: string) => Promise<{ success: boolean; version?: string; error?: string }>;
|
||||
isTesting: boolean;
|
||||
}
|
||||
|
||||
export function LidarrSection({ settings, onUpdate, onTest, isTesting }: LidarrSectionProps) {
|
||||
const [testStatus, setTestStatus] = useState<StatusType>("idle");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestStatus("loading");
|
||||
setTestMessage("Testing...");
|
||||
const result = await onTest("lidarr");
|
||||
if (result.success) {
|
||||
setTestStatus("success");
|
||||
setTestMessage(result.version ? `v${result.version}` : "Connected");
|
||||
} else {
|
||||
setTestStatus("error");
|
||||
setTestMessage(result.error || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="lidarr"
|
||||
title="Download Services"
|
||||
description="Automate music downloads and library management"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Enable Lidarr"
|
||||
description="Connect to Lidarr for music automation"
|
||||
htmlFor="lidarr-enabled"
|
||||
>
|
||||
<SettingsToggle
|
||||
id="lidarr-enabled"
|
||||
checked={settings.lidarrEnabled}
|
||||
onChange={(checked) => onUpdate({ lidarrEnabled: checked })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{settings.lidarrEnabled && (
|
||||
<>
|
||||
<SettingsRow label="Lidarr URL">
|
||||
<SettingsInput
|
||||
value={settings.lidarrUrl}
|
||||
onChange={(v) => onUpdate({ lidarrUrl: v })}
|
||||
placeholder="http://localhost:8686"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label="API Key">
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={settings.lidarrApiKey}
|
||||
onChange={(v) => onUpdate({ lidarrApiKey: v })}
|
||||
placeholder="Enter API key"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isTesting || !settings.lidarrUrl || !settings.lidarrApiKey}
|
||||
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full
|
||||
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testStatus === "loading" ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
<InlineStatus
|
||||
status={testStatus}
|
||||
message={testMessage}
|
||||
onClear={() => setTestStatus("idle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
import { Smartphone, QrCode, ArrowRight } from "lucide-react";
|
||||
|
||||
export function LinkDeviceSection() {
|
||||
return (
|
||||
<div
|
||||
id="link-device"
|
||||
className="bg-[#111] rounded-lg p-6 border border-[#1c1c1c]"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-purple-500/10">
|
||||
<Smartphone className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">Link Mobile Device</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Connect your phone without typing passwords
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Generate a QR code or 6-digit code to quickly link your mobile device.
|
||||
No need to type your server URL or password on your phone.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/device"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 rounded-lg transition-colors border border-purple-500/30"
|
||||
>
|
||||
<QrCode className="w-4 h-4" />
|
||||
<span>Link a Device</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsSection, SettingsRow, SettingsSelect } from "../ui";
|
||||
import { UserSettings } from "../../types";
|
||||
|
||||
interface PlaybackSectionProps {
|
||||
value: UserSettings["playbackQuality"];
|
||||
onChange: (quality: UserSettings["playbackQuality"]) => void;
|
||||
}
|
||||
|
||||
const qualityOptions = [
|
||||
{ value: "original", label: "Original (Lossless)" },
|
||||
{ value: "high", label: "High (320 kbps)" },
|
||||
{ value: "medium", label: "Medium (192 kbps)" },
|
||||
{ value: "low", label: "Low (128 kbps)" },
|
||||
];
|
||||
|
||||
export function PlaybackSection({ value, onChange }: PlaybackSectionProps) {
|
||||
return (
|
||||
<SettingsSection id="playback" title="Playback">
|
||||
<SettingsRow
|
||||
label="Streaming quality"
|
||||
description="Higher quality uses more bandwidth"
|
||||
>
|
||||
<SettingsSelect
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as UserSettings["playbackQuality"])}
|
||||
options={qualityOptions}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SettingsSection, SettingsRow, SettingsInput } from "../ui";
|
||||
import { SystemSettings } from "../../types";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { InlineStatus, StatusType } from "@/components/ui/InlineStatus";
|
||||
|
||||
interface SoulseekSectionProps {
|
||||
settings: SystemSettings;
|
||||
onUpdate: (updates: Partial<SystemSettings>) => void;
|
||||
onTest: (service: string) => Promise<{ success: boolean; version?: string; error?: string }>;
|
||||
isTesting: boolean;
|
||||
}
|
||||
|
||||
export function SoulseekSection({ settings, onUpdate, onTest, isTesting }: SoulseekSectionProps) {
|
||||
const [testStatus, setTestStatus] = useState<StatusType>("idle");
|
||||
const [testMessage, setTestMessage] = useState("");
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestStatus("loading");
|
||||
setTestMessage("Connecting...");
|
||||
const result = await onTest("soulseek");
|
||||
if (result.success) {
|
||||
setTestStatus("success");
|
||||
setTestMessage("Connected to Soulseek");
|
||||
} else {
|
||||
setTestStatus("error");
|
||||
setTestMessage(result.error || "Connection failed");
|
||||
}
|
||||
};
|
||||
|
||||
const hasCredentials = settings.soulseekUsername && settings.soulseekPassword;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="soulseek"
|
||||
title="Soulseek"
|
||||
description="Configure direct Soulseek connection for P2P music downloads"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Soulseek Username"
|
||||
description={
|
||||
<span className="flex items-center gap-1.5">
|
||||
Your Soulseek account username
|
||||
<a
|
||||
href="https://www.slsknet.org/news/node/1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[#ecb200] hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Create Account
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<SettingsInput
|
||||
value={settings.soulseekUsername || ""}
|
||||
onChange={(v) => onUpdate({ soulseekUsername: v })}
|
||||
placeholder="your_username"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Soulseek Password"
|
||||
description="Your Soulseek account password"
|
||||
>
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={settings.soulseekPassword || ""}
|
||||
onChange={(v) => onUpdate({ soulseekPassword: v })}
|
||||
placeholder="your_password"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<div className="pt-2 space-y-2">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isTesting || !hasCredentials}
|
||||
className="px-4 py-1.5 text-sm bg-[#333] text-white rounded-full
|
||||
hover:bg-[#404040] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testStatus === "loading" ? "Connecting..." : "Test Connection"}
|
||||
</button>
|
||||
<InlineStatus
|
||||
status={testStatus}
|
||||
message={testMessage}
|
||||
onClear={() => setTestStatus("idle")}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-white/40">
|
||||
Downloads will be saved to your Singles folder automatically
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsSection, SettingsRow, SettingsInput } from "../ui";
|
||||
import { SystemSettings } from "../../types";
|
||||
|
||||
interface StoragePathsSectionProps {
|
||||
settings: SystemSettings;
|
||||
onUpdate: (updates: Partial<SystemSettings>) => void;
|
||||
onTest: (service: string) => Promise<{ success: boolean; version?: string; error?: string }>;
|
||||
isTesting: boolean;
|
||||
}
|
||||
|
||||
export function StoragePathsSection({ settings, onUpdate }: StoragePathsSectionProps) {
|
||||
return (
|
||||
<SettingsSection
|
||||
id="storage"
|
||||
title="Storage"
|
||||
description="Configure storage paths for your music library"
|
||||
>
|
||||
<SettingsRow
|
||||
label="Music library path"
|
||||
description="Path to your music library"
|
||||
>
|
||||
<SettingsInput
|
||||
value={settings.musicPath}
|
||||
onChange={(v) => onUpdate({ musicPath: v })}
|
||||
placeholder="/music"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Download path"
|
||||
description="Path for new downloads"
|
||||
>
|
||||
<SettingsInput
|
||||
value={settings.downloadPath}
|
||||
onChange={(v) => onUpdate({ downloadPath: v })}
|
||||
placeholder="/downloads"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { SettingsSection, SettingsRow, SettingsInput, SettingsSelect } from "../ui";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { api } from "@/lib/api";
|
||||
import { InlineStatus, StatusType } from "@/components/ui/InlineStatus";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
role: "user" | "admin";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function UserManagementSection() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newRole, setNewRole] = useState<"user" | "admin">("user");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createStatus, setCreateStatus] = useState<StatusType>("idle");
|
||||
const [createMessage, setCreateMessage] = useState("");
|
||||
const [deleteStatus, setDeleteStatus] = useState<StatusType>("idle");
|
||||
const [deleteMessage, setDeleteMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.get("/auth/users");
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newUsername.trim() || newPassword.length < 6) {
|
||||
setCreateStatus("error");
|
||||
setCreateMessage("Username required, password 6+ chars");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateStatus("loading");
|
||||
try {
|
||||
await api.post("/auth/create-user", {
|
||||
username: newUsername,
|
||||
password: newPassword,
|
||||
role: newRole,
|
||||
});
|
||||
setCreateStatus("success");
|
||||
setCreateMessage("Created");
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
setNewRole("user");
|
||||
loadUsers();
|
||||
} catch (error: any) {
|
||||
setCreateStatus("error");
|
||||
setCreateMessage(error.message || "Failed");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
setDeleteStatus("loading");
|
||||
try {
|
||||
await api.delete(`/auth/users/${userId}`);
|
||||
setDeleteStatus("success");
|
||||
setDeleteMessage("Deleted");
|
||||
setConfirmDelete(null);
|
||||
loadUsers();
|
||||
} catch (error: any) {
|
||||
setDeleteStatus("error");
|
||||
setDeleteMessage(error.message || "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
id="users"
|
||||
title="User Management"
|
||||
description="Manage users who can access this instance"
|
||||
showSeparator={false}
|
||||
>
|
||||
{/* Create User Form */}
|
||||
<div className="py-4 px-4 bg-[#1a1a1a] rounded-lg mb-4">
|
||||
<h3 className="text-sm font-medium text-white mb-3">Create New User</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<SettingsInput
|
||||
value={newUsername}
|
||||
onChange={setNewUsername}
|
||||
placeholder="Username"
|
||||
className="flex-1"
|
||||
/>
|
||||
<SettingsInput
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
placeholder="Password (6+ chars)"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-flex gap-3 items-center">
|
||||
<SettingsSelect
|
||||
value={newRole}
|
||||
onChange={(v) => setNewRole(v as "user" | "admin")}
|
||||
options={[
|
||||
{ value: "user", label: "User" },
|
||||
{ value: "admin", label: "Admin" },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newUsername.trim() || newPassword.length < 6}
|
||||
className="px-4 py-1.5 text-sm bg-white text-black font-medium rounded-full
|
||||
hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed transition-transform"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
<InlineStatus
|
||||
status={createStatus}
|
||||
message={createMessage}
|
||||
onClear={() => setCreateStatus("idle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
<div className="space-y-1">
|
||||
{loading ? (
|
||||
<div className="py-4 text-sm text-gray-500">Loading users...</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="py-4 text-sm text-gray-500">No users found</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between py-3 px-3 rounded-md hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-[#333] flex items-center justify-center text-sm text-white">
|
||||
{user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-white">
|
||||
{user.username}
|
||||
{currentUser?.id === user.id && (
|
||||
<span className="text-xs text-gray-500 ml-2">(you)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{user.role === "admin" ? "Admin" : "User"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentUser?.id !== user.id && (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(user.id)}
|
||||
className="p-2 text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={!!confirmDelete}
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
title="Delete User"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-300">
|
||||
Are you sure you want to delete this user? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end items-center">
|
||||
<InlineStatus
|
||||
status={deleteStatus}
|
||||
message={deleteMessage}
|
||||
onClear={() => setDeleteStatus("idle")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirmDelete && handleDelete(confirmDelete)}
|
||||
className="px-4 py-2 text-sm bg-red-500 text-white rounded-full hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user