feat: Apple Watch app + Paywall + Privacy Policy + rebranding

## Major Features
- Apple Watch companion app (6 phases complete)
  - WatchConnectivity iPhone ↔ Watch
  - HealthKit integration (HR, calories)
  - SwiftUI premium UI
  - 9 complication types
  - Always-On Display support

- Paywall screen with RevenueCat integration
- Privacy Policy screen
- App rebranding: tabatago → TabataFit
- Bundle ID: com.millianlmx.tabatafit

## Changes
- New: ios/TabataFit Watch App/ (complete Watch app)
- New: app/paywall.tsx (subscription UI)
- New: app/privacy.tsx (privacy policy)
- New: src/features/watch/ (Watch sync hooks)
- New: admin-web/ (admin dashboard)
- Updated: app.json, package.json (branding)
- Updated: profile.tsx (paywall + privacy links)
- Updated: i18n translations (EN/FR/DE/ES)
- New: app icon 1024x1024

## Watch App Files
- TabataFitWatchApp.swift (entry point)
- ContentView.swift (premium UI)
- HealthKitManager.swift (HR + calories)
- WatchSessionManager.swift (communication)
- Complications/ (WidgetKit)
- UserDefaults+Shared.swift (data sharing)
This commit is contained in:
Millian Lamiaux
2026-03-11 09:43:53 +01:00
parent f80798069b
commit 2ad7ae3a34
86 changed files with 19648 additions and 365 deletions

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, Edit, Loader2, FolderOpen } from "lucide-react";
import type { Database } from "@/lib/supabase";
type Collection = Database["public"]["Tables"]["collections"]["Row"];
export default function CollectionsPage() {
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCollections();
}, []);
const fetchCollections = async () => {
try {
const { data, error } = await supabase.from("collections").select("*");
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error("Failed to fetch collections:", error);
} finally {
setLoading(false);
}
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Collections</h1>
<p className="text-neutral-400">Organize workouts into collections</p>
</div>
<Button className="bg-orange-500 hover:bg-orange-600">
<Plus className="w-4 h-4 mr-2" />
Add Collection
</Button>
</div>
{loading ? (
<div className="flex justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{collections.map((collection) => (
<Card key={collection.id} className="bg-neutral-900 border-neutral-800">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-neutral-800 rounded-xl flex items-center justify-center text-2xl">
{collection.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{collection.title}</h3>
<p className="text-neutral-400 text-sm mt-1">{collection.description}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-red-500">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{collection.gradient && (
<div
className="mt-3 h-2 rounded-full"
style={{
background: `linear-gradient(to right, ${collection.gradient[0]}, ${collection.gradient[1]})`,
}}
/>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

BIN
admin-web/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

126
admin-web/app/globals.css Normal file
View File

@@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

33
admin-web/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/sidebar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TabataFit Admin",
description: "Admin dashboard for TabataFit",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className={`${geistSans.variable} antialiased bg-neutral-950 text-white`}>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Flame, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const { error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) throw authError;
const { data: adminUser } = await supabase
.from("admin_users")
.select("*")
.single();
if (!adminUser) {
await supabase.auth.signOut();
throw new Error("Not authorized as admin");
}
router.push("/");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 p-4">
<Card className="w-full max-w-md border-neutral-800 bg-neutral-900">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-orange-500 rounded-xl flex items-center justify-center">
<Flame className="w-7 h-7 text-white" />
</div>
</div>
<CardTitle className="text-2xl text-white">TabataFit Admin</CardTitle>
<CardDescription className="text-neutral-400">
Sign in to manage your content
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="bg-neutral-950 border-neutral-800 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-neutral-300">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-neutral-950 border-neutral-800 text-white"
/>
</div>
<Button
type="submit"
className="w-full bg-orange-500 hover:bg-orange-600"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useState, useRef } from "react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Upload, Film, Image as ImageIcon, User, Loader2 } from "lucide-react";
const BUCKETS = [
{ id: "videos", label: "Videos", icon: Film, types: "video/*" },
{ id: "thumbnails", label: "Thumbnails", icon: ImageIcon, types: "image/*" },
{ id: "avatars", label: "Avatars", icon: User, types: "image/*" },
];
export default function MediaPage() {
const [uploading, setUploading] = useState(false);
const [activeTab, setActiveTab] = useState("videos");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fileExt = file.name.split(".").pop();
const fileName = `${Date.now()}.${fileExt}`;
const filePath = `${activeTab}/${fileName}`;
const { error: uploadError } = await supabase.storage
.from(activeTab)
.upload(filePath, file);
if (uploadError) throw uploadError;
alert("File uploaded successfully!");
} catch (error) {
console.error("Upload failed:", error);
alert("Upload failed: " + (error instanceof Error ? error.message : "Unknown error"));
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const activeBucket = BUCKETS.find((b) => b.id === activeTab);
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Media Library</h1>
<p className="text-neutral-400">Upload and manage media files</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-neutral-900 border-neutral-800 mb-6">
{BUCKETS.map((bucket) => (
<TabsTrigger
key={bucket.id}
value={bucket.id}
className="data-[state=active]:bg-orange-500 data-[state=active]:text-white"
>
<bucket.icon className="w-4 h-4 mr-2" />
{bucket.label}
</TabsTrigger>
))}
</TabsList>
{BUCKETS.map((bucket) => (
<TabsContent key={bucket.id} value={bucket.id}>
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<bucket.icon className="w-5 h-5 text-orange-500" />
Upload {bucket.label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="border-2 border-dashed border-neutral-700 rounded-lg p-12 text-center hover:border-orange-500/50 transition-colors">
<input
type="file"
ref={fileInputRef}
onChange={handleUpload}
accept={bucket.types}
className="hidden"
id={`file-upload-${bucket.id}`}
/>
<label
htmlFor={`file-upload-${bucket.id}`}
className="cursor-pointer flex flex-col items-center"
>
<div className="w-16 h-16 bg-neutral-800 rounded-full flex items-center justify-center mb-4">
{uploading ? (
<Loader2 className="w-8 h-8 text-orange-500 animate-spin" />
) : (
<Upload className="w-8 h-8 text-neutral-400" />
)}
</div>
<p className="text-white font-medium mb-2">
{uploading ? "Uploading..." : `Click to upload ${bucket.label.toLowerCase()}`}
</p>
<p className="text-neutral-500 text-sm">
{bucket.id === "videos"
? "MP4, MOV up to 100MB"
: "JPG, PNG up to 5MB"}
</p>
</label>
</div>
<div className="mt-6 p-4 bg-neutral-950 rounded-lg">
<h4 className="text-sm font-medium text-white mb-2">Storage Path</h4>
<code className="text-sm text-neutral-400">
{bucket.id}/filename.ext
</code>
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
</div>
);
}

132
admin-web/app/page.tsx Normal file
View File

@@ -0,0 +1,132 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dumbbell, Users, FolderOpen, Flame } from "lucide-react";
interface Stats {
workouts: number;
trainers: number;
collections: number;
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats>({ workouts: 0, trainers: 0, collections: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchStats();
}, []);
const fetchStats = async () => {
try {
const [{ count: workouts }, { count: trainers }, { count: collections }] = await Promise.all([
supabase.from("workouts").select("*", { count: "exact", head: true }),
supabase.from("trainers").select("*", { count: "exact", head: true }),
supabase.from("collections").select("*", { count: "exact", head: true }),
]);
setStats({
workouts: workouts || 0,
trainers: trainers || 0,
collections: collections || 0,
});
} catch (error) {
console.error("Failed to fetch stats:", error);
} finally {
setLoading(false);
}
};
const statCards = [
{ title: "Workouts", value: stats.workouts, icon: Dumbbell, href: "/workouts", color: "text-orange-500" },
{ title: "Trainers", value: stats.trainers, icon: Users, href: "/trainers", color: "text-blue-500" },
{ title: "Collections", value: stats.collections, icon: FolderOpen, href: "/collections", color: "text-green-500" },
];
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Dashboard</h1>
<p className="text-neutral-400">Overview of your TabataFit content</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{statCards.map((stat) => {
const Icon = stat.icon;
return (
<Link key={stat.title} href={stat.href}>
<Card className="bg-neutral-900 border-neutral-800 hover:border-neutral-700 transition-colors cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-neutral-400">
{stat.title}
</CardTitle>
<Icon className={`w-5 h-5 ${stat.color}`} />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{loading ? "-" : stat.value}
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Flame className="w-5 h-5 text-orange-500" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Link
href="/workouts"
className="block p-4 bg-neutral-950 rounded-lg hover:bg-neutral-800 transition-colors"
>
<div className="font-medium text-white">Manage Workouts</div>
<div className="text-sm text-neutral-400">Add, edit, or remove workouts</div>
</Link>
<Link
href="/trainers"
className="block p-4 bg-neutral-950 rounded-lg hover:bg-neutral-800 transition-colors"
>
<div className="font-medium text-white">Manage Trainers</div>
<div className="text-sm text-neutral-400">Update trainer profiles and photos</div>
</Link>
<Link
href="/media"
className="block p-4 bg-neutral-950 rounded-lg hover:bg-neutral-800 transition-colors"
>
<div className="font-medium text-white">Upload Media</div>
<div className="text-sm text-neutral-400">Add videos and thumbnails</div>
</Link>
</CardContent>
</Card>
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<CardTitle className="text-white">Getting Started</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-neutral-400">
<p>
Welcome to the TabataFit Admin Dashboard. Here you can manage all your
fitness content.
</p>
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Create and edit Tabata workouts</li>
<li>Manage trainer profiles</li>
<li>Organize workouts into collections</li>
<li>Upload workout videos and thumbnails</li>
</ul>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Plus, Trash2, Edit, Loader2 } from "lucide-react";
import type { Database } from "@/lib/supabase";
type Trainer = Database["public"]["Tables"]["trainers"]["Row"];
export default function TrainersPage() {
const [trainers, setTrainers] = useState<Trainer[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchTrainers();
}, []);
const fetchTrainers = async () => {
try {
const { data, error } = await supabase.from("trainers").select("*");
if (error) throw error;
setTrainers(data || []);
} catch (error) {
console.error("Failed to fetch trainers:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this trainer?")) return;
setDeletingId(id);
try {
const { error } = await supabase.from("trainers").delete().eq("id", id);
if (error) throw error;
setTrainers(trainers.filter((t) => t.id !== id));
} catch (error) {
console.error("Failed to delete trainer:", error);
alert("Failed to delete trainer");
} finally {
setDeletingId(null);
}
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Trainers</h1>
<p className="text-neutral-400">Manage your fitness trainers</p>
</div>
<Button className="bg-orange-500 hover:bg-orange-600">
<Plus className="w-4 h-4 mr-2" />
Add Trainer
</Button>
</div>
{loading ? (
<div className="flex justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{trainers.map((trainer) => (
<Card key={trainer.id} className="bg-neutral-900 border-neutral-800">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: trainer.color }}
>
{trainer.name[0]}
</div>
<div>
<h3 className="text-lg font-semibold text-white">{trainer.name}</h3>
<p className="text-neutral-400">{trainer.specialty}</p>
<p className="text-sm text-neutral-500">
{trainer.workout_count} workouts
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-red-500"
onClick={() => handleDelete(trainer.id)}
disabled={deletingId === trainer.id}
>
{deletingId === trainer.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Plus, Trash2, Edit, Loader2 } from "lucide-react";
import type { Database } from "@/lib/supabase";
type Workout = Database["public"]["Tables"]["workouts"]["Row"];
export default function WorkoutsPage() {
const [workouts, setWorkouts] = useState<Workout[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchWorkouts();
}, []);
const fetchWorkouts = async () => {
try {
const { data, error } = await supabase
.from("workouts")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
setWorkouts(data || []);
} catch (error) {
console.error("Failed to fetch workouts:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this workout?")) return;
setDeletingId(id);
try {
const { error } = await supabase.from("workouts").delete().eq("id", id);
if (error) throw error;
setWorkouts(workouts.filter((w) => w.id !== id));
} catch (error) {
console.error("Failed to delete workout:", error);
alert("Failed to delete workout");
} finally {
setDeletingId(null);
}
};
const getCategoryColor = (category: string) => {
const colors: Record<string, string> = {
"full-body": "bg-orange-500/20 text-orange-500",
core: "bg-blue-500/20 text-blue-500",
"upper-body": "bg-green-500/20 text-green-500",
"lower-body": "bg-purple-500/20 text-purple-500",
cardio: "bg-red-500/20 text-red-500",
};
return colors[category] || "bg-neutral-500/20 text-neutral-500";
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Workouts</h1>
<p className="text-neutral-400">Manage your workout library</p>
</div>
<Button className="bg-orange-500 hover:bg-orange-600">
<Plus className="w-4 h-4 mr-2" />
Add Workout
</Button>
</div>
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="p-0">
{loading ? (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-neutral-800">
<TableHead className="text-neutral-400">Title</TableHead>
<TableHead className="text-neutral-400">Category</TableHead>
<TableHead className="text-neutral-400">Level</TableHead>
<TableHead className="text-neutral-400">Duration</TableHead>
<TableHead className="text-neutral-400">Rounds</TableHead>
<TableHead className="text-neutral-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workouts.map((workout) => (
<TableRow key={workout.id} className="border-neutral-800">
<TableCell className="text-white font-medium">
{workout.title}
{workout.is_featured && (
<Badge className="ml-2 bg-orange-500/20 text-orange-500">
Featured
</Badge>
)}
</TableCell>
<TableCell>
<Badge className={getCategoryColor(workout.category)}>
{workout.category}
</Badge>
</TableCell>
<TableCell className="text-neutral-300">{workout.level}</TableCell>
<TableCell className="text-neutral-300">{workout.duration} min</TableCell>
<TableCell className="text-neutral-300">{workout.rounds}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-red-500"
onClick={() => handleDelete(workout.id)}
disabled={deletingId === workout.id}
>
{deletingId === workout.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}