Initial commit for Start9 packaging
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user