1049 lines
56 KiB
TypeScript
1049 lines
56 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { api } from "@/lib/api";
|
|
import Image from "next/image";
|
|
import { GradientSpinner } from "@/components/ui/GradientSpinner";
|
|
|
|
export default function OnboardingPage() {
|
|
const router = useRouter();
|
|
const [step, setStep] = useState(1);
|
|
const [loading, setLoading] = useState(false);
|
|
const [initialLoading, setInitialLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const showPasswordMismatch = error === "Passwords don't match";
|
|
const showPasswordTooShort =
|
|
error === "Password must be at least 6 characters";
|
|
|
|
// Step 1: Account creation
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
|
|
// Check if user is already logged in and skip to step 2
|
|
useEffect(() => {
|
|
const checkExistingSession = async () => {
|
|
try {
|
|
const user = await api.getCurrentUser();
|
|
if (user && !user.onboardingComplete) {
|
|
// User exists but hasn't completed onboarding - skip to step 2
|
|
setStep(2);
|
|
}
|
|
} catch (error) {
|
|
// Not logged in, stay on step 1
|
|
} finally {
|
|
setInitialLoading(false);
|
|
}
|
|
};
|
|
checkExistingSession();
|
|
}, []);
|
|
|
|
// Step 2: Integrations
|
|
const [lidarr, setLidarr] = useState({
|
|
url: "",
|
|
apiKey: "",
|
|
enabled: false,
|
|
});
|
|
const [audiobookshelf, setAudiobookshelf] = useState({
|
|
url: "",
|
|
apiKey: "",
|
|
enabled: false,
|
|
});
|
|
const [soulseek, setSoulseek] = useState({
|
|
username: "",
|
|
password: "",
|
|
enabled: false,
|
|
});
|
|
|
|
// Step 3: Enrichment
|
|
const [enrichmentEnabled, setEnrichmentEnabled] = useState(true);
|
|
|
|
const handleRegister = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
|
|
if (password !== confirmPassword) {
|
|
setError("Passwords don't match");
|
|
return;
|
|
}
|
|
|
|
if (password.length < 6) {
|
|
setError("Password must be at least 6 characters");
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await api.post<{ token: string; user: any }>(
|
|
"/onboarding/register",
|
|
{ username, password }
|
|
);
|
|
// Store the JWT token for subsequent API calls
|
|
if (response.token) {
|
|
api.setToken(response.token);
|
|
}
|
|
setStep(2);
|
|
} catch (err: any) {
|
|
// Check if user already exists
|
|
if (err.message?.includes("already taken")) {
|
|
setError(
|
|
"Username already taken. If this is you, please refresh and continue where you left off."
|
|
);
|
|
} else {
|
|
setError(
|
|
err.response?.data?.error ||
|
|
err.message ||
|
|
"Failed to create account"
|
|
);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const testConnection = async (
|
|
type: "lidarr" | "audiobookshelf" | "soulseek"
|
|
) => {
|
|
setError("");
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Use dedicated test endpoints that actually verify the connection
|
|
if (type === "lidarr") {
|
|
if (!lidarr.url || !lidarr.apiKey) {
|
|
throw new Error("URL and API key are required");
|
|
}
|
|
await api.post("/system-settings/test-lidarr", {
|
|
url: lidarr.url,
|
|
apiKey: lidarr.apiKey,
|
|
});
|
|
} else if (type === "audiobookshelf") {
|
|
if (!audiobookshelf.url || !audiobookshelf.apiKey) {
|
|
throw new Error("URL and API key are required");
|
|
}
|
|
await api.post("/system-settings/test-audiobookshelf", {
|
|
url: audiobookshelf.url,
|
|
apiKey: audiobookshelf.apiKey,
|
|
});
|
|
} else if (type === "soulseek") {
|
|
if (!soulseek.username || !soulseek.password) {
|
|
throw new Error("Username and password are required");
|
|
}
|
|
await api.post("/system-settings/test-soulseek", {
|
|
username: soulseek.username,
|
|
password: soulseek.password,
|
|
});
|
|
}
|
|
setError(`${type} connected successfully!`);
|
|
} catch (err: any) {
|
|
const errorMessage =
|
|
err.response?.data?.error ||
|
|
err.response?.data?.details ||
|
|
err.message ||
|
|
`Failed to connect to ${type}`;
|
|
setError(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleNextStep = async () => {
|
|
setError("");
|
|
setLoading(true);
|
|
|
|
try {
|
|
if (step === 2) {
|
|
// Save all integration configs
|
|
await Promise.all([
|
|
api.post("/onboarding/lidarr", lidarr),
|
|
api.post("/onboarding/audiobookshelf", audiobookshelf),
|
|
api.post("/onboarding/soulseek", soulseek),
|
|
]);
|
|
setStep(3);
|
|
} else if (step === 3) {
|
|
// Save enrichment preference and complete
|
|
await api.post("/onboarding/enrichment", {
|
|
enabled: enrichmentEnabled,
|
|
});
|
|
await api.post("/onboarding/complete");
|
|
// Redirect to sync page
|
|
router.push("/sync");
|
|
}
|
|
} catch (err: any) {
|
|
setError(
|
|
err.response?.data?.error || "Failed to save configuration"
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen relative overflow-hidden">
|
|
{/* Dark background (matches login) */}
|
|
<div className="absolute inset-0 bg-[#000]">
|
|
<div className="absolute inset-0 bg-gradient-to-br from-[#fca200]/5 via-transparent to-transparent" />
|
|
</div>
|
|
|
|
{/* Show loading spinner while checking session */}
|
|
{initialLoading ? (
|
|
<div className="relative z-10 min-h-screen flex items-center justify-center">
|
|
<div className="text-center">
|
|
<GradientSpinner size="lg" />
|
|
<p className="text-white/60 mt-4">Loading...</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="relative z-10 min-h-screen flex items-center justify-center p-6">
|
|
<div className="w-full max-w-4xl">
|
|
{/* Logo/Brand */}
|
|
<div className="text-center mb-8">
|
|
<div className="inline-flex items-center gap-4 mb-4">
|
|
<div className="relative">
|
|
<div className="absolute inset-0 bg-white/10 blur-xl rounded-full" />
|
|
<Image
|
|
src="/assets/images/LIDIFY.webp"
|
|
alt="Lidify"
|
|
width={48}
|
|
height={48}
|
|
className="relative z-10 drop-shadow-2xl"
|
|
/>
|
|
</div>
|
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-white via-white to-gray-200 bg-clip-text text-transparent drop-shadow-2xl">
|
|
Lidify
|
|
</h1>
|
|
</div>
|
|
<p className="text-white/60 text-lg">
|
|
Welcome to your personal music streaming
|
|
platform
|
|
</p>
|
|
</div>
|
|
|
|
{/* Progress Steps */}
|
|
<div className="flex items-center justify-center gap-3 mb-8">
|
|
{[
|
|
{ num: 1, label: "Account" },
|
|
{ num: 2, label: "Integrations" },
|
|
{ num: 3, label: "Enrichment" },
|
|
].map((s, idx) => (
|
|
<div key={s.num} className="flex items-center">
|
|
<div className="flex flex-col items-center">
|
|
<div
|
|
className={`w-9 h-9 rounded-lg flex items-center justify-center font-bold text-sm transition-all ${
|
|
s.num === step
|
|
? "bg-[#fca200] text-black shadow-lg shadow-[#fca200]/20 scale-110"
|
|
: s.num < step
|
|
? "bg-white/5 text-white/80 border border-white/10"
|
|
: "bg-white/5 text-white/40 border border-white/10"
|
|
}`}
|
|
>
|
|
{s.num}
|
|
</div>
|
|
<span
|
|
className={`text-xs mt-2 transition-all ${
|
|
s.num === step
|
|
? "text-brand font-medium"
|
|
: "text-white/40"
|
|
}`}
|
|
>
|
|
{s.label}
|
|
</span>
|
|
</div>
|
|
{idx < 2 && (
|
|
<div
|
|
className={`w-16 h-0.5 mx-4 mb-6 transition-all ${
|
|
s.num < step
|
|
? "bg-[#fca200]/25"
|
|
: "bg-white/10"
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Main Content Card */}
|
|
<div className="bg-[#111]/90 rounded-lg border border-white/10 shadow-xl overflow-hidden">
|
|
<div className="p-6 md:p-8">
|
|
{step === 1 && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-white mb-1">
|
|
Create Your Account
|
|
</h2>
|
|
<p className="text-white/60">
|
|
Let's get you set up with your
|
|
personal music library
|
|
</p>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={handleRegister}
|
|
className="space-y-4 mt-8"
|
|
>
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/90 mb-1.5">
|
|
Username
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) =>
|
|
setUsername(
|
|
e.target.value
|
|
)
|
|
}
|
|
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all "
|
|
placeholder="Choose a username"
|
|
required
|
|
minLength={3}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/90 mb-1.5">
|
|
Password
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) =>
|
|
setPassword(
|
|
e.target.value
|
|
)
|
|
}
|
|
className={`w-full px-4 py-3 bg-white/5 border rounded-lg text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all ${
|
|
showPasswordTooShort
|
|
? "border-red-500/50"
|
|
: "border-white/10"
|
|
}`}
|
|
placeholder="At least 6 characters"
|
|
required
|
|
minLength={6}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-white/90 mb-1.5">
|
|
Confirm Password
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(e) =>
|
|
setConfirmPassword(
|
|
e.target.value
|
|
)
|
|
}
|
|
className={`w-full px-4 py-3 bg-white/5 border rounded-lg text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all ${
|
|
showPasswordMismatch
|
|
? "border-red-500/50"
|
|
: "border-white/10"
|
|
}`}
|
|
placeholder="Confirm your password"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full py-3.5 bg-[#fca200] text-black font-bold rounded-lg hover:bg-[#e69200] transition-all disabled:opacity-50 disabled:cursor-not-allowed relative group overflow-hidden mt-8"
|
|
>
|
|
<span className="relative z-10 flex items-center justify-center gap-2">
|
|
{loading ? (
|
|
<>
|
|
<GradientSpinner size="sm" />
|
|
Creating Account...
|
|
</>
|
|
) : (
|
|
"Continue"
|
|
)}
|
|
</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-white mb-1">
|
|
Connect Your Services
|
|
</h2>
|
|
<p className="text-white/60">
|
|
Optional integrations to enhance
|
|
your music library
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 mt-8">
|
|
{/* Lidarr */}
|
|
<IntegrationCard
|
|
title="Lidarr"
|
|
description="Automatic music library management"
|
|
localPort="localhost:8686"
|
|
icon={
|
|
<svg
|
|
className="w-6 h-6"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
/>
|
|
</svg>
|
|
}
|
|
enabled={lidarr.enabled}
|
|
onToggle={() =>
|
|
setLidarr({
|
|
...lidarr,
|
|
enabled:
|
|
!lidarr.enabled,
|
|
})
|
|
}
|
|
url={lidarr.url}
|
|
apiKey={lidarr.apiKey}
|
|
onUrlChange={(url) =>
|
|
setLidarr({
|
|
...lidarr,
|
|
url,
|
|
})
|
|
}
|
|
onApiKeyChange={(apiKey) =>
|
|
setLidarr({
|
|
...lidarr,
|
|
apiKey,
|
|
})
|
|
}
|
|
onTest={() =>
|
|
testConnection("lidarr")
|
|
}
|
|
loading={loading}
|
|
/>
|
|
|
|
{/* Audiobookshelf */}
|
|
<IntegrationCard
|
|
title="Audiobookshelf"
|
|
description="Audiobook library management"
|
|
localPort="localhost:13378"
|
|
icon={
|
|
<svg
|
|
className="w-6 h-6"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
|
/>
|
|
</svg>
|
|
}
|
|
enabled={audiobookshelf.enabled}
|
|
onToggle={() =>
|
|
setAudiobookshelf({
|
|
...audiobookshelf,
|
|
enabled:
|
|
!audiobookshelf.enabled,
|
|
})
|
|
}
|
|
url={audiobookshelf.url}
|
|
apiKey={audiobookshelf.apiKey}
|
|
onUrlChange={(url) =>
|
|
setAudiobookshelf({
|
|
...audiobookshelf,
|
|
url,
|
|
})
|
|
}
|
|
onApiKeyChange={(apiKey) =>
|
|
setAudiobookshelf({
|
|
...audiobookshelf,
|
|
apiKey,
|
|
})
|
|
}
|
|
onTest={() =>
|
|
testConnection(
|
|
"audiobookshelf"
|
|
)
|
|
}
|
|
loading={loading}
|
|
/>
|
|
|
|
{/* Soulseek */}
|
|
<SoulseekCard
|
|
enabled={soulseek.enabled}
|
|
onToggle={() =>
|
|
setSoulseek({
|
|
...soulseek,
|
|
enabled:
|
|
!soulseek.enabled,
|
|
})
|
|
}
|
|
username={soulseek.username}
|
|
password={soulseek.password}
|
|
onUsernameChange={(username) =>
|
|
setSoulseek({
|
|
...soulseek,
|
|
username,
|
|
})
|
|
}
|
|
onPasswordChange={(password) =>
|
|
setSoulseek({
|
|
...soulseek,
|
|
password,
|
|
})
|
|
}
|
|
onTest={() =>
|
|
testConnection("soulseek")
|
|
}
|
|
loading={loading}
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
className={`flex items-center gap-2 p-4 rounded-lg ${
|
|
error.includes(
|
|
"successfully"
|
|
)
|
|
? "bg-green-500/10 border border-green-500/20"
|
|
: "bg-red-500/10 border border-red-500/20"
|
|
}`}
|
|
>
|
|
<span
|
|
className={
|
|
error.includes(
|
|
"successfully"
|
|
)
|
|
? "text-green-500"
|
|
: "text-red-500"
|
|
}
|
|
>
|
|
{error.includes(
|
|
"successfully"
|
|
)
|
|
? ""
|
|
: ""}
|
|
</span>
|
|
<p
|
|
className={`text-sm ${
|
|
error.includes(
|
|
"successfully"
|
|
)
|
|
? "text-green-500"
|
|
: "text-red-500"
|
|
}`}
|
|
>
|
|
{error}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3 mt-8">
|
|
<button
|
|
onClick={() => setStep(3)}
|
|
className="flex-1 bg-white/5 border border-white/10 text-white/70 font-medium py-3.5 rounded-lg hover:bg-white/10 transition-all"
|
|
>
|
|
Skip for Now
|
|
</button>
|
|
<button
|
|
onClick={handleNextStep}
|
|
disabled={loading}
|
|
className="flex-1 py-3.5 bg-[#fca200] text-black font-bold rounded-lg hover:bg-[#e69200] transition-all disabled:opacity-50"
|
|
>
|
|
{loading
|
|
? "Saving..."
|
|
: "Continue"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-white mb-1">
|
|
Artist Enrichment
|
|
</h2>
|
|
<p className="text-white/60">
|
|
Enhance your library with
|
|
additional metadata
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-[#0f0f0f] border border-white/10 rounded-lg p-6 mt-8">
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-12 h-12 bg-[#fca200]/10 border border-[#fca200]/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
<svg
|
|
className="w-6 h-6 text-[#fca200]"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-bold text-white mb-2">
|
|
What is enrichment?
|
|
</h3>
|
|
<p className="text-white/60 text-sm leading-relaxed">
|
|
Enrichment fetches
|
|
additional metadata like
|
|
artist bios,
|
|
high-quality images,
|
|
genres, and
|
|
relationships from
|
|
external sources. This
|
|
powers smart features
|
|
and provides a richer
|
|
listening experience.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 mt-6">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<div className="w-5 h-5 bg-white/10 rounded-full flex items-center justify-center flex-shrink-0 border border-white/10">
|
|
<svg
|
|
className="w-3 h-3 text-white/70"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={3}
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-white/80">
|
|
Better artist matching
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<div className="w-5 h-5 bg-amber-500/20 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<svg
|
|
className="w-3 h-3 text-brand"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={3}
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-white/80">
|
|
Discover Weekly
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<div className="w-5 h-5 bg-amber-500/20 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<svg
|
|
className="w-3 h-3 text-brand"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={3}
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-white/80">
|
|
Similar artists
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<div className="w-5 h-5 bg-white/10 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<span className="text-white/40 text-xs">
|
|
!
|
|
</span>
|
|
</div>
|
|
<span className="text-white/50">
|
|
Uses internet data
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-5 bg-white/5 border border-white/10 rounded-lg ">
|
|
<div>
|
|
<h3 className="text-white font-medium">
|
|
Enable artist enrichment
|
|
</h3>
|
|
<p className="text-sm text-white/50 mt-0.5">
|
|
Recommended for the best
|
|
experience
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() =>
|
|
setEnrichmentEnabled(
|
|
!enrichmentEnabled
|
|
)
|
|
}
|
|
className={`relative w-12 h-6 rounded-lg transition-all ${
|
|
enrichmentEnabled
|
|
? "bg-[#fca200]"
|
|
: "bg-white/20"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-lg transition-all shadow-lg ${
|
|
enrichmentEnabled
|
|
? "translate-x-6"
|
|
: ""
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-center gap-2 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
|
<span className="text-red-500"></span>
|
|
<p className="text-red-500 text-sm">
|
|
{error}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3 mt-8">
|
|
<button
|
|
onClick={async () => {
|
|
await api.post(
|
|
"/onboarding/enrichment",
|
|
{ enabled: false }
|
|
);
|
|
await api.post(
|
|
"/onboarding/complete"
|
|
);
|
|
router.push("/");
|
|
}}
|
|
className="flex-1 bg-white/5 border border-white/10 text-white/70 font-medium py-3.5 rounded-lg hover:bg-white/10 transition-all"
|
|
>
|
|
Skip Enrichment
|
|
</button>
|
|
<button
|
|
onClick={handleNextStep}
|
|
disabled={loading}
|
|
className="flex-1 py-3.5 bg-amber-500 text-black font-bold rounded-lg hover:bg-amber-400 transition-all duration-200 disabled:opacity-50 disabled:hover:scale-100 relative group overflow-hidden"
|
|
>
|
|
<span className="relative z-10 flex items-center justify-center gap-2">
|
|
{loading ? (
|
|
<>
|
|
<GradientSpinner size="sm" />
|
|
Finishing Setup...
|
|
</>
|
|
) : (
|
|
"Complete Setup"
|
|
)}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<p className="text-center text-white/40 text-sm mt-6">
|
|
© 2025 Lidify. Your music, your way.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface IntegrationCardProps {
|
|
title: string;
|
|
description: string;
|
|
localPort?: string;
|
|
icon: React.ReactNode;
|
|
enabled: boolean;
|
|
onToggle: () => void;
|
|
url: string;
|
|
apiKey?: string;
|
|
username?: string;
|
|
password?: string;
|
|
onUrlChange: (url: string) => void;
|
|
onApiKeyChange?: (apiKey: string) => void;
|
|
onUsernameChange?: (username: string) => void;
|
|
onPasswordChange?: (password: string) => void;
|
|
onTest: () => void;
|
|
loading: boolean;
|
|
useSoulseekCreds?: boolean;
|
|
}
|
|
|
|
function IntegrationCard({
|
|
title,
|
|
description,
|
|
localPort,
|
|
icon,
|
|
enabled,
|
|
onToggle,
|
|
url,
|
|
apiKey,
|
|
username,
|
|
password,
|
|
onUrlChange,
|
|
onApiKeyChange,
|
|
onUsernameChange,
|
|
onPasswordChange,
|
|
onTest,
|
|
loading,
|
|
useSoulseekCreds = false,
|
|
}: IntegrationCardProps) {
|
|
return (
|
|
<div
|
|
className={`border rounded-lg transition-all ${
|
|
enabled
|
|
? "bg-[#0f0f0f] border-brand/25"
|
|
: "bg-white/5 border-white/10"
|
|
}`}
|
|
>
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div
|
|
className={`w-9 h-9 rounded-lg flex items-center justify-center ${
|
|
enabled
|
|
? "bg-[#fca200]/10 border border-[#fca200]/20 text-[#fca200]"
|
|
: "bg-white/5 border border-white/10 text-white/40"
|
|
}`}
|
|
>
|
|
{icon}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-white font-bold">{title}</h3>
|
|
<p className="text-sm text-white/50">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onToggle}
|
|
className={`relative w-11 h-6 rounded-lg transition-all ${
|
|
enabled ? "bg-[#fca200]" : "bg-white/20"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-[#fca200] rounded-lg transition-all shadow-lg ${
|
|
enabled ? "translate-x-5" : ""
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{enabled && (
|
|
<div className="space-y-3 mt-4 pt-4 border-t border-white/10">
|
|
<input
|
|
type="url"
|
|
value={url}
|
|
onChange={(e) => onUrlChange(e.target.value)}
|
|
placeholder={`Server URL (e.g., http://${
|
|
localPort || "localhost:PORT"
|
|
})`}
|
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all "
|
|
/>
|
|
{useSoulseekCreds ? (
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={username || ""}
|
|
onChange={(e) =>
|
|
onUsernameChange?.(e.target.value)
|
|
}
|
|
placeholder="Soulseek Username"
|
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all "
|
|
/>
|
|
<input
|
|
type="password"
|
|
value={password || ""}
|
|
onChange={(e) =>
|
|
onPasswordChange?.(e.target.value)
|
|
}
|
|
placeholder="Soulseek Password"
|
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all "
|
|
/>
|
|
<p className="text-xs text-white/50 mt-2">
|
|
These are your Soulseek network credentials,
|
|
not your Slskd login
|
|
</p>
|
|
</>
|
|
) : (
|
|
<input
|
|
type="password"
|
|
value={apiKey || ""}
|
|
onChange={(e) =>
|
|
onApiKeyChange?.(e.target.value)
|
|
}
|
|
placeholder="API Key"
|
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all "
|
|
/>
|
|
)}
|
|
<button
|
|
onClick={onTest}
|
|
disabled={
|
|
loading ||
|
|
!url ||
|
|
(!useSoulseekCreds
|
|
? !apiKey
|
|
: !username || !password)
|
|
}
|
|
className="w-full bg-white/10 text-white px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Test Connection
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SoulseekCardProps {
|
|
enabled: boolean;
|
|
onToggle: () => void;
|
|
username: string;
|
|
password: string;
|
|
onUsernameChange: (username: string) => void;
|
|
onPasswordChange: (password: string) => void;
|
|
onTest: () => void;
|
|
loading: boolean;
|
|
}
|
|
|
|
function SoulseekCard({
|
|
enabled,
|
|
onToggle,
|
|
username,
|
|
password,
|
|
onUsernameChange,
|
|
onPasswordChange,
|
|
onTest,
|
|
loading,
|
|
}: SoulseekCardProps) {
|
|
return (
|
|
<div
|
|
className={`border rounded-lg transition-all ${
|
|
enabled
|
|
? "bg-[#0f0f0f] border-brand/25"
|
|
: "bg-white/5 border-white/10"
|
|
}`}
|
|
>
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div
|
|
className={`w-9 h-9 rounded-lg flex items-center justify-center ${
|
|
enabled
|
|
? "bg-[#fca200]/10 border border-[#fca200]/20 text-[#fca200]"
|
|
: "bg-white/5 border border-white/10 text-white/40"
|
|
}`}
|
|
>
|
|
<svg
|
|
className="w-6 h-6"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-white font-bold">Soulseek</h3>
|
|
<p className="text-sm text-white/50">
|
|
Peer-to-peer music discovery
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onToggle}
|
|
className={`relative w-11 h-6 rounded-lg transition-all ${
|
|
enabled ? "bg-[#fca200]" : "bg-white/20"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-[#fca200] rounded-lg transition-all shadow-lg ${
|
|
enabled ? "translate-x-5" : ""
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{enabled && (
|
|
<div className="space-y-3 mt-4 pt-4 border-t border-white/10">
|
|
<input
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) => onUsernameChange(e.target.value)}
|
|
placeholder="Soulseek Username"
|
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all "
|
|
/>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => onPasswordChange(e.target.value)}
|
|
placeholder="Soulseek Password"
|
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-brand/30 focus:border-transparent transition-all "
|
|
/>
|
|
<p className="text-xs text-white/50">
|
|
Create an account at{" "}
|
|
<a
|
|
href="https://www.slsknet.org/news/node/1"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[#fca200] hover:underline"
|
|
>
|
|
slsknet.org
|
|
</a>
|
|
</p>
|
|
<button
|
|
onClick={onTest}
|
|
disabled={loading || !username || !password}
|
|
className="w-full bg-white/10 text-white px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-white/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Test Connection
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|