fitaiProto/apps/admin/src/app/settings/page.tsx
2025-12-13 06:26:23 +01:00

487 lines
16 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import axios from "axios";
import {
Database,
Download,
RefreshCw,
AlertTriangle,
Check,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
interface Backup {
name: string;
size: number;
createdAt: string;
}
interface Gym {
id: string;
name: string;
location?: string | null;
status: "active" | "inactive";
adminUserId: string;
}
export default function SettingsPage() {
const { user } = useUser();
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creatingBackup, setCreatingBackup] = useState(false);
const [restoring, setRestoring] = useState<string | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Gym picker state
const [gyms, setGyms] = useState<Gym[]>([]);
const [gymsLoading, setGymsLoading] = useState<boolean>(true);
const [gymMessage, setGymMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Create Gym modal state
const [showCreateGym, setShowCreateGym] = useState(false);
const [gymName, setGymName] = useState("");
const [gymLocation, setGymLocation] = useState("");
const [creatingGym, setCreatingGym] = useState(false);
const fetchBackups = async () => {
try {
const response = await axios.get("/api/admin/backups");
setBackups(response.data);
} catch (error) {
console.error("Failed to fetch backups:", error);
} finally {
setLoading(false);
}
};
const fetchGyms = async () => {
setGymsLoading(true);
setGymMessage(null);
try {
const res = await axios.get("/api/gyms");
setGyms(Array.isArray(res.data) ? res.data : []);
} catch (error) {
console.error("Failed to fetch gyms:", error);
setGymMessage({ type: "error", text: "Failed to load gyms" });
} finally {
setGymsLoading(false);
}
};
useEffect(() => {
fetchBackups();
fetchGyms();
}, []);
const handleCreateBackup = async () => {
setCreatingBackup(true);
setMessage(null);
try {
await axios.post("/api/admin/backups");
await fetchBackups();
setMessage({ type: "success", text: "Backup created successfully" });
} catch (error) {
console.error("Failed to create backup:", error);
setMessage({ type: "error", text: "Failed to create backup" });
} finally {
setCreatingBackup(false);
}
};
const handleRestore = async (filename: string) => {
if (
!window.confirm(
`Are you sure you want to restore from ${filename}? This will overwrite the current database.`,
)
) {
return;
}
setRestoring(filename);
setMessage(null);
try {
await axios.post("/api/admin/backups/restore", { filename });
setMessage({ type: "success", text: "Database restored successfully" });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) {
console.error("Failed to restore backup:", error);
setMessage({ type: "error", text: "Failed to restore backup" });
} finally {
setRestoring(null);
}
};
const formatSize = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const handleSelectGym = async (gymId: string | null) => {
setGymMessage(null);
try {
// Update current user's gym selection
await axios.patch("/api/users/gym", { gymId });
setGymMessage({
type: "success",
text: gymId ? "Gym selected successfully" : "Proceeding without gym",
});
} catch (error) {
console.error("Failed to set gym:", error);
setGymMessage({ type: "error", text: "Failed to set gym" });
}
};
return (
<div className="space-y-8 p-8">
<div>
<h2 className="text-3xl font-bold text-slate-900">Settings</h2>
<p className="text-slate-500 mt-2">
Manage your application settings and database.
</p>
</div>
{/* Gym Picker */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<Database className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">
Gym Selection
</h3>
<p className="text-sm text-slate-500">
Select your gym or proceed without a gym
</p>
<p className="text-xs text-blue-600 mt-1">
{user ? (
<>
Current role:{" "}
<span className="font-medium">
{String(user.publicMetadata?.role ?? "unknown")}
</span>
{" • "}
Gym ID:{" "}
<span className="font-medium">
{String(user.publicMetadata?.gymId ?? "none")}
</span>
</>
) : (
"Loading user metadata..."
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={fetchGyms}
disabled={gymsLoading}
className="flex items-center gap-2"
>
{gymsLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Refresh Gyms
</Button>
<Button
onClick={() => setShowCreateGym(true)}
className="flex items-center gap-2"
>
Create Gym
</Button>
</div>
</div>
{gymMessage && (
<div
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${gymMessage.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}
>
{gymMessage.type === "success" ? (
<Check className="w-5 h-5" />
) : (
<AlertTriangle className="w-5 h-5" />
)}
{gymMessage.text}
</div>
)}
{showCreateGym && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">Create Gym</h3>
<form
onSubmit={async (e) => {
e.preventDefault();
try {
setCreatingGym(true);
await axios.post("/api/gyms", {
name: gymName.trim(),
location: gymLocation.trim() || undefined,
});
setGymMessage({
type: "success",
text: "Gym created successfully",
});
setShowCreateGym(false);
setGymName("");
setGymLocation("");
fetchGyms();
} catch (error) {
console.error("Failed to create gym:", error);
setGymMessage({
type: "error",
text: "Failed to create gym",
});
} finally {
setCreatingGym(false);
}
}}
>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Gym Name
</label>
<input
type="text"
value={gymName}
onChange={(e) => setGymName(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Location (optional)
</label>
<input
type="text"
value={gymLocation}
onChange={(e) => setGymLocation(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
placeholder="Enter location"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowCreateGym(false);
setGymName("");
setGymLocation("");
}}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
<button
type="submit"
disabled={creatingGym}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-2"
>
{creatingGym ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : null}
Create
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-lg p-4 flex flex-col justify-between">
<div>
<h4 className="font-semibold text-slate-900">
Proceed without gym
</h4>
<p className="text-sm text-slate-600 mt-1">
You can select a gym later.
</p>
</div>
<Button
variant="outline"
className="mt-4"
onClick={() => handleSelectGym(null)}
>
Proceed without gym
</Button>
</div>
{gymsLoading ? (
<div className="col-span-full flex items-center justify-center p-8 text-slate-500">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading gyms...
</div>
) : gyms.length === 0 ? (
<div className="col-span-full p-8 text-center text-slate-500">
No active gyms found.
</div>
) : (
gyms.map((gym) => (
<div
key={gym.id}
className="border rounded-lg p-4 flex flex-col justify-between"
>
<div>
<h4 className="font-semibold text-slate-900">{gym.name}</h4>
<p className="text-sm text-slate-600 mt-1">
{gym.location || "No location provided"}
</p>
</div>
<Button
variant="default"
className="mt-4"
onClick={() => handleSelectGym(gym.id)}
>
Select this gym
</Button>
</div>
))
)}
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<Database className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">
Database Management
</h3>
<p className="text-sm text-slate-500">
Create backups and restore your database
</p>
</div>
</div>
<Button
onClick={handleCreateBackup}
disabled={creatingBackup}
className="flex items-center gap-2"
>
{creatingBackup ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
Create Backup
</Button>
</div>
{message && (
<div
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${
message.type === "success"
? "bg-green-50 text-green-700"
: "bg-red-50 text-red-700"
}`}
>
{message.type === "success" ? (
<Check className="w-5 h-5" />
) : (
<AlertTriangle className="w-5 h-5" />
)}
{message.text}
</div>
)}
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-6 py-4 font-semibold text-slate-900">
Filename
</th>
<th className="px-6 py-4 font-semibold text-slate-900">Size</th>
<th className="px-6 py-4 font-semibold text-slate-900">
Created At
</th>
<th className="px-6 py-4 font-semibold text-slate-900 text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td
colSpan={4}
className="px-6 py-8 text-center text-slate-500"
>
Loading backups...
</td>
</tr>
) : backups.length === 0 ? (
<tr>
<td
colSpan={4}
className="px-6 py-8 text-center text-slate-500"
>
No backups found
</td>
</tr>
) : (
backups.map((backup) => (
<tr
key={backup.name}
className="hover:bg-slate-50 transition-colors"
>
<td className="px-6 py-4 font-medium text-slate-900">
{backup.name}
</td>
<td className="px-6 py-4 text-slate-600">
{formatSize(backup.size)}
</td>
<td className="px-6 py-4 text-slate-600">
{formatDate(backup.createdAt)}
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRestore(backup.name)}
disabled={!!restoring}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{restoring === backup.name ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Restore
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}