Files
proof-of-work/workout-planner/components/settings/SettingsForm.tsx
T
2026-02-28 09:27:26 -06:00

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