Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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&apos;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>
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}