457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { User } from "@prisma/client";
|
|
import { Loader2, Eye, EyeOff, Upload, AlertTriangle, CheckCircle2 } from "lucide-react";
|
|
|
|
interface UserPreferences {
|
|
theme: string;
|
|
defaultWeightUnit: string;
|
|
defaultRestSeconds: number;
|
|
enableClaudeAI: boolean;
|
|
claudeApiKey?: string;
|
|
}
|
|
|
|
export default function SettingsForm({ user }: { user: User }) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
const [preferences, setPreferences] = useState<UserPreferences>({
|
|
theme: "system",
|
|
defaultWeightUnit: "lbs",
|
|
defaultRestSeconds: 90,
|
|
enableClaudeAI: false,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const fetchPreferences = async () => {
|
|
try {
|
|
const response = await fetch("/api/preferences");
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setPreferences(data);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch preferences:", err);
|
|
}
|
|
};
|
|
|
|
fetchPreferences();
|
|
}, []);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setSuccess(false);
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await fetch("/api/preferences", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(preferences),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.error || "Failed to save preferences");
|
|
}
|
|
|
|
setSuccess(true);
|
|
setTimeout(() => setSuccess(false), 3000);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "An error occurred");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Status Messages */}
|
|
{error && (
|
|
<div className="bg-red-900/30 border border-red-800 rounded-lg p-4 text-red-400 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div className="bg-green-900/30 border border-green-800 rounded-lg p-4 text-green-400 text-sm">
|
|
Settings saved successfully!
|
|
</div>
|
|
)}
|
|
|
|
{/* Profile Section */}
|
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
|
<h2 className="text-lg font-bold text-white mb-4">Profile</h2>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
|
Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={user.name || ""}
|
|
disabled
|
|
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-zinc-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
|
Email
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={user.email}
|
|
disabled
|
|
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-zinc-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preferences Section */}
|
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
|
<h2 className="text-lg font-bold text-white mb-4">Preferences</h2>
|
|
<div className="space-y-4">
|
|
{/* Weight Unit */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
|
Default Weight Unit
|
|
</label>
|
|
<div className="flex gap-2">
|
|
{["lbs", "kg"].map((unit) => (
|
|
<button
|
|
key={unit}
|
|
type="button"
|
|
onClick={() =>
|
|
setPreferences((prev) => ({
|
|
...prev,
|
|
defaultWeightUnit: unit,
|
|
}))
|
|
}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
|
|
preferences.defaultWeightUnit === unit
|
|
? "bg-white text-black"
|
|
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
|
}`}
|
|
>
|
|
{unit === "lbs" ? "Pounds (lbs)" : "Kilograms (kg)"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-zinc-600 mt-1.5">
|
|
Kettlebell exercises always default to kg
|
|
</p>
|
|
</div>
|
|
|
|
{/* Theme */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-zinc-400 mb-2">
|
|
Theme
|
|
</label>
|
|
<select
|
|
value={preferences.theme}
|
|
onChange={(e) =>
|
|
setPreferences((prev) => ({
|
|
...prev,
|
|
theme: e.target.value,
|
|
}))
|
|
}
|
|
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
|
|
>
|
|
<option value="system">System</option>
|
|
<option value="light">Light</option>
|
|
<option value="dark">Dark</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Claude AI Section */}
|
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
|
<h2 className="text-lg font-bold text-white mb-4">
|
|
Claude AI Integration
|
|
</h2>
|
|
<p className="text-sm text-zinc-500 mb-4">
|
|
Enable Claude AI to get personalized workout recommendations and
|
|
program optimization suggestions.
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
{/* Enable Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-zinc-300">
|
|
Enable Claude AI
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setPreferences((prev) => ({
|
|
...prev,
|
|
enableClaudeAI: !prev.enableClaudeAI,
|
|
}))
|
|
}
|
|
className={`relative w-11 h-6 rounded-full transition ${
|
|
preferences.enableClaudeAI ? "bg-white" : "bg-zinc-700"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full transition-transform ${
|
|
preferences.enableClaudeAI
|
|
? "translate-x-5 bg-black"
|
|
: "translate-x-0 bg-zinc-400"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* API Key Input - Only show if enabled */}
|
|
{preferences.enableClaudeAI && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
|
Claude API Key
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showApiKey ? "text" : "password"}
|
|
value={preferences.claudeApiKey || ""}
|
|
onChange={(e) =>
|
|
setPreferences((prev) => ({
|
|
...prev,
|
|
claudeApiKey: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="sk-..."
|
|
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey(!showApiKey)}
|
|
className="absolute right-3 top-2.5 text-zinc-500 hover:text-zinc-300"
|
|
>
|
|
{showApiKey ? (
|
|
<EyeOff className="w-5 h-5" />
|
|
) : (
|
|
<Eye className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-zinc-600 mt-1">
|
|
Get your API key from{" "}
|
|
<a
|
|
href="https://console.anthropic.com"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-zinc-400 hover:text-white underline"
|
|
>
|
|
console.anthropic.com
|
|
</a>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save Button */}
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full bg-white hover:bg-zinc-200 disabled:bg-zinc-700 disabled:text-zinc-500 text-black font-medium py-2.5 px-4 rounded-lg transition flex items-center justify-center gap-2"
|
|
>
|
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
{loading ? "Saving..." : "Save Settings"}
|
|
</button>
|
|
|
|
{/* Database Import Section */}
|
|
<DatabaseImport />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// ---------- Database Import Component ----------
|
|
function DatabaseImport() {
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [importing, setImporting] = useState(false);
|
|
const [importError, setImportError] = useState<string | null>(null);
|
|
const [importSuccess, setImportSuccess] = useState<{
|
|
message: string;
|
|
stats: { users: number; exercises: number; workouts: number };
|
|
} | null>(null);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setImportError(null);
|
|
setImportSuccess(null);
|
|
setSelectedFile(file);
|
|
setConfirmOpen(true);
|
|
};
|
|
|
|
const handleImport = async () => {
|
|
if (!selectedFile) return;
|
|
|
|
setImporting(true);
|
|
setImportError(null);
|
|
setImportSuccess(null);
|
|
setConfirmOpen(false);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("database", selectedFile);
|
|
|
|
const response = await fetch("/api/settings/import-db", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || "Import failed");
|
|
}
|
|
|
|
setImportSuccess({
|
|
message: data.message,
|
|
stats: data.stats,
|
|
});
|
|
|
|
// Clear the file input
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
setSelectedFile(null);
|
|
} catch (err) {
|
|
setImportError(
|
|
err instanceof Error ? err.message : "An error occurred during import"
|
|
);
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setConfirmOpen(false);
|
|
setSelectedFile(null);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
};
|
|
|
|
return (
|
|
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
|
<h2 className="text-lg font-bold text-white mb-1">Import Database</h2>
|
|
<p className="text-sm text-zinc-500 mb-4">
|
|
Upload an existing Workout Planner database file (app.db) to restore
|
|
your workout history. A backup of the current database will be created
|
|
automatically.
|
|
</p>
|
|
|
|
{/* Error message */}
|
|
{importError && (
|
|
<div className="bg-red-900/30 border border-red-800 rounded-lg p-3 mb-4 flex items-start gap-2">
|
|
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
|
<span className="text-sm text-red-400">{importError}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success message */}
|
|
{importSuccess && (
|
|
<div className="bg-green-900/30 border border-green-800 rounded-lg p-3 mb-4">
|
|
<div className="flex items-start gap-2">
|
|
<CheckCircle2 className="w-4 h-4 text-green-400 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-sm text-green-400">{importSuccess.message}</p>
|
|
<p className="text-xs text-green-500 mt-1">
|
|
Imported: {importSuccess.stats.users} user(s),{" "}
|
|
{importSuccess.stats.exercises} exercises,{" "}
|
|
{importSuccess.stats.workouts} workouts
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => window.location.reload()}
|
|
className="mt-3 w-full py-2 bg-green-800/50 text-green-300 text-sm font-medium rounded-lg hover:bg-green-800/70 transition"
|
|
>
|
|
Refresh Page to Load Imported Data
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirmation dialog */}
|
|
{confirmOpen && selectedFile && (
|
|
<div className="bg-yellow-900/20 border border-yellow-800/50 rounded-lg p-4 mb-4">
|
|
<div className="flex items-start gap-2 mb-3">
|
|
<AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-sm text-yellow-400 font-medium">
|
|
Replace current database?
|
|
</p>
|
|
<p className="text-xs text-yellow-600 mt-1">
|
|
File: {selectedFile.name} (
|
|
{(selectedFile.size / 1024).toFixed(0)} KB)
|
|
</p>
|
|
<p className="text-xs text-yellow-600 mt-0.5">
|
|
Your current database will be backed up before replacement.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleImport}
|
|
className="flex-1 py-2 bg-yellow-700/50 text-yellow-200 text-sm font-medium rounded-lg hover:bg-yellow-700/70 transition"
|
|
>
|
|
Yes, Import
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleCancel}
|
|
className="flex-1 py-2 bg-zinc-800 text-zinc-400 text-sm font-medium rounded-lg hover:bg-zinc-700 transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* File input and upload button */}
|
|
{!confirmOpen && (
|
|
<div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".db,.sqlite,.sqlite3"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
id="db-import-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={importing}
|
|
className="w-full py-3 border border-dashed border-zinc-700 rounded-lg text-zinc-400 text-sm font-medium hover:text-white hover:border-zinc-500 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
|
>
|
|
{importing ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Importing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="w-4 h-4" />
|
|
Select Database File (.db)
|
|
</>
|
|
)}
|
|
</button>
|
|
<p className="text-[10px] text-zinc-600 mt-2 text-center">
|
|
Located at prisma/data/app.db in your local project
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|