brackt/app/routes/admin.data-sync.tsx
Chris Parsons 1584d34b89
Redesign to dark-mode-only with navy palette and accent colors (#13)
Removes light mode entirely in favour of a permanent dark theme with a
navy-tinted background and three signature accents (electric blue,
amber/gold, coral) exposed as CSS custom properties and Tailwind
utilities (bg-electric, text-amber-accent, text-coral-accent).

- Set class="dark" on <html> and apply Clerk dark base theme
- Rewrite app.css: single :root palette (oklch navy values), custom
  --electric / --amber-accent / --coral-accent variables, remove
  duplicate .dark block and light-mode bg-white/bg-gray-950 rule
- Install @clerk/themes for Clerk dark modal support
- Replace hardcoded Tailwind colors across 30+ files:
  - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10
  - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent
  - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants
  - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400
  - Info cards: blue-50 dark:bg-blue-950 → electric/10
  - Warning cards: yellow-500 → amber-accent variants
  - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent
  - Movement indicators: green-600/red-600 → emerald-400/coral-accent
  - Connection dots: green-500/red-500 → emerald-500/coral-accent
- Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark)
- Update DraftGrid test assertions to match new class names

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:26:11 -08:00

376 lines
13 KiB
TypeScript

import { useState } from "react";
import type { Route } from "./+types/admin.data-sync";
import { findAllSports } from "~/models/sport";
import { findAllSportsSeasons } from "~/models/sports-season";
import { findAllSeasonTemplates } from "~/models/season-template";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Download, Upload, AlertTriangle, Database } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
export async function loader() {
const [sports, sportsSeasons, templates] = await Promise.all([
findAllSports(),
findAllSportsSeasons(),
findAllSeasonTemplates(),
]);
return {
stats: {
sportsCount: sports.length,
sportsSeasonsCount: sportsSeasons.length,
templatesCount: templates.length,
},
};
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "export") {
// Export will be handled client-side via download
return { success: true };
}
if (intent === "import") {
const mode = formData.get("mode") as "merge" | "replace";
const fileData = formData.get("fileData") as string;
if (!fileData) {
return { error: "No file data provided" };
}
try {
// Import the data (we'll create a server-side function for this)
const { importSportsDataFromJSON } = await import("~/utils/sports-data-sync.server");
const result = await importSportsDataFromJSON(fileData, mode);
return {
success: true,
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}`
};
} catch (error) {
console.error("Import error:", error);
return { error: error instanceof Error ? error.message : "Import failed" };
}
}
return { error: "Invalid intent" };
}
export default function DataSync({ loaderData, actionData }: Route.ComponentProps) {
const { stats } = loaderData;
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isExporting, setIsExporting] = useState(false);
const handleExport = async () => {
setIsExporting(true);
try {
const response = await fetch("/api/admin/export-sports-data");
const data = await response.json();
// Create download
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `sports-data-export-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Export failed:", error);
alert("Export failed. Please try again.");
} finally {
setIsExporting(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleImport = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
alert("Please select a file first");
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
const fileData = event.target?.result as string;
const formData = new FormData();
formData.append("intent", "import");
formData.append("mode", importMode);
formData.append("fileData", fileData);
// Submit via form
const form = document.createElement("form");
form.method = "POST";
form.style.display = "none";
const intentInput = document.createElement("input");
intentInput.name = "intent";
intentInput.value = "import";
form.appendChild(intentInput);
const modeInput = document.createElement("input");
modeInput.name = "mode";
modeInput.value = importMode;
form.appendChild(modeInput);
const dataInput = document.createElement("input");
dataInput.name = "fileData";
dataInput.value = fileData;
form.appendChild(dataInput);
document.body.appendChild(form);
form.submit();
};
reader.readAsText(selectedFile);
};
return (
<div className="p-8">
<div className="max-w-4xl">
<div className="mb-6">
<h1 className="text-3xl font-bold">Data Sync</h1>
<p className="text-muted-foreground mt-1">
Import and export sports data between environments
</p>
</div>
{actionData?.success && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-6">
{actionData.message || "Operation completed successfully!"}
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
{actionData.error}
</div>
)}
<div className="grid gap-6 md:grid-cols-3 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sports</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.sportsCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sports Seasons</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.sportsSeasonsCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Templates</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.templatesCount}</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Export Data</CardTitle>
<CardDescription>
Download all sports data as a JSON file
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
This will export all sports, sports seasons, participants, and season templates.
</p>
<Button
onClick={handleExport}
disabled={isExporting}
className="w-full"
>
<Download className="mr-2 h-4 w-4" />
{isExporting ? "Exporting..." : "Export Data"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Import Data</CardTitle>
<CardDescription>
Upload a JSON file to import sports data
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="file">Select File</Label>
<input
id="file"
type="file"
accept=".json"
onChange={handleFileChange}
className="block w-full text-sm text-muted-foreground
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-primary file:text-primary-foreground
hover:file:bg-primary/90"
/>
</div>
<div className="space-y-2">
<Label>Import Mode</Label>
<div className="flex gap-2">
<Button
type="button"
variant={importMode === "merge" ? "default" : "outline"}
size="sm"
onClick={() => setImportMode("merge")}
>
Merge
</Button>
<Button
type="button"
variant={importMode === "replace" ? "default" : "outline"}
size="sm"
onClick={() => setImportMode("replace")}
>
Replace
</Button>
</div>
<p className="text-xs text-muted-foreground">
{importMode === "merge"
? "Updates existing records and adds new ones (safe)"
: "Deletes all existing data and recreates from file (destructive)"}
</p>
</div>
{importMode === "replace" ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={!selectedFile}
variant="destructive"
className="w-full"
>
<Upload className="mr-2 h-4 w-4" />
Import Data (Replace)
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This will <strong>delete all existing sports data</strong> and replace it
with the data from the file. This action cannot be undone.
<br /><br />
This includes:
<ul className="list-disc list-inside mt-2">
<li>All sports</li>
<li>All sports seasons</li>
<li>All participants</li>
<li>All season templates</li>
</ul>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleImport}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Yes, Replace All Data
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
onClick={handleImport}
disabled={!selectedFile}
className="w-full"
>
<Upload className="mr-2 h-4 w-4" />
Import Data (Merge)
</Button>
)}
</CardContent>
</Card>
</div>
<Card className="mt-6 border-electric/30 bg-electric/10">
<CardHeader>
<CardTitle className="text-electric">How It Works</CardTitle>
</CardHeader>
<CardContent className="text-sm text-foreground/80 space-y-3">
<div>
<strong>Export:</strong> Downloads all sports data as a JSON file that you can
save, version control, or transfer to another environment.
</div>
<div>
<strong>Import (Merge):</strong> Updates existing records and adds new ones.
Safe to use - won't delete anything.
</div>
<div>
<strong>Import (Replace):</strong> Deletes all existing sports data and recreates
it from the file. Use this for a clean slate.
</div>
<div className="pt-2 border-t border-electric/30">
<strong>Typical workflow:</strong>
<ol className="list-decimal list-inside mt-1 space-y-1">
<li>Export data from development</li>
<li>Download the JSON file</li>
<li>Upload it to production</li>
<li>Import using merge mode</li>
</ol>
</div>
</CardContent>
</Card>
</div>
</div>
);
}