Browse Source

Merge pull request #18 from Snouzy/feat/save-on-finish

feat/save on finish
Mat B. 1 month ago
parent
commit
62325aa527
39 changed files with 730 additions and 224 deletions
  1. 18 0
      app/[locale]/about/page.tsx
  2. 0 9
      app/[locale]/auth/(auth-layout)/layout.tsx
  3. 4 6
      app/[locale]/layout.tsx
  4. 17 2
      app/[locale]/profile/page.tsx
  5. 44 0
      content/about/en.mdx
  6. 44 0
      content/about/fr.mdx
  7. 0 1
      locales/en.ts
  8. 4 5
      locales/fr.ts
  9. 12 0
      prisma/migrations/20250614153656_remove_value_int_value_sec_unit_from_workoutset/migration.sql
  10. 0 3
      prisma/schema.prisma
  11. 10 1
      scripts/import-exercises-with-attributes.ts
  12. 3 3
      src/components/ui/link.tsx
  13. 29 0
      src/components/ui/local-alert.tsx
  14. 10 10
      src/features/auth/model/useLogout.ts
  15. 5 4
      src/features/layout/Footer.tsx
  16. 31 16
      src/features/layout/Header.tsx
  17. 2 2
      src/features/layout/authenticated-header.tsx
  18. 3 4
      src/features/release-notes/ui/release-notes-dialog.tsx
  19. 19 6
      src/features/theme/ThemeToggle.tsx
  20. 3 1
      src/features/workout-builder/ui/stepper-header.tsx
  21. 48 0
      src/features/workout-session/actions/get-workout-sessions.action.ts
  22. 95 0
      src/features/workout-session/actions/sync-workout-sessions.action.ts
  23. 108 0
      src/features/workout-session/model/use-sync-workout-sessions.ts
  24. 19 0
      src/features/workout-session/model/use-workout-sessions.ts
  25. 2 1
      src/features/workout-session/model/workout-session.store.ts
  26. 0 1
      src/features/workout-session/schema/workout-session-set.schema.ts
  27. 3 18
      src/features/workout-session/types/workout-set.ts
  28. 5 5
      src/features/workout-session/ui/workout-session-header.tsx
  29. 14 15
      src/features/workout-session/ui/workout-session-list.tsx
  30. 16 14
      src/features/workout-session/ui/workout-session-set.tsx
  31. 4 1
      src/features/workout-session/ui/workout-session-sets.tsx
  32. 13 0
      src/features/workout-session/ui/workout-sessions-synchronizer.tsx
  33. 3 0
      src/shared/lib/format.ts
  34. 4 3
      src/shared/lib/workout-session/types/workout-session.ts
  35. 107 0
      src/shared/lib/workout-session/use-workout-session.service.ts
  36. 0 28
      src/shared/lib/workout-session/workout-session.service.ts
  37. 1 0
      src/shared/lib/workout-session/workout-session.sync.ts
  38. 29 64
      src/shared/styles/globals.css
  39. 1 1
      tailwind.config.ts

+ 18 - 0
app/[locale]/about/page.tsx

@@ -0,0 +1,18 @@
+import { getLocalizedMdx } from "@/shared/lib/mdx/load-mdx";
+
+type PageProps = {
+  params: Promise<{ locale: string }>;
+};
+
+export default async function AboutPage({ params }: PageProps) {
+  const { locale } = await params;
+  const content = await getLocalizedMdx("about", locale);
+
+  return (
+    <div className="bg-muted/50 py-12 min-h-screen">
+      <div className="container mx-auto max-w-3xl px-4">
+        <div className="prose prose-neutral max-w-none dark:prose-invert">{content}</div>
+      </div>
+    </div>
+  );
+}

+ 0 - 9
app/[locale]/auth/(auth-layout)/layout.tsx

@@ -1,10 +1,7 @@
 import { redirect } from "next/navigation";
-import Link from "next/link";
-import Image from "next/image";
 import { headers } from "next/headers";
 
 import { getI18n } from "locales/server";
-import Logo from "@public/logo.png";
 import { paths } from "@/shared/constants/paths";
 import { auth } from "@/features/auth/lib/better-auth";
 import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -27,12 +24,6 @@ export default async function AuthLayout(props: LayoutParams<{}>) {
   return (
     <>
       <div>
-        <div className="flex justify-center gap-2">
-          <Link className="flex items-center gap-2 font-medium" href={`/${paths.root}`}>
-            <Image alt="workout cool logo" className="w-16" height={64} src={Logo} width={64} />
-          </Link>
-        </div>
-
         {searchParams.error && (
           <Alert className="mb-4" variant="error">
             <AlertTitle>{translatedError}</AlertTitle>

+ 4 - 6
app/[locale]/layout.tsx

@@ -101,23 +101,21 @@ export default async function RootLayout({ params, children }: RootLayoutProps)
 
         <body
           className={cn(
-            "flex flex-col justify-between items-center p-8 min-h-screen max-sm:p-0 max-sm:min-h-full text-sm/[22px] font-normal text-black antialiased dark:text-gray-500",
+            "flex flex-col justify-between items-center p-8 min-h-screen max-sm:p-0 max-sm:min-h-full text-sm/[22px] font-normal text-base-content bg-base-200 dark:bg-[#18181b] dark:text-gray-200 antialiased",
+            "bg-hero-light dark:bg-hero-dark",
             GeistMono.variable,
             GeistSans.variable,
             inter.variable,
             permanentMarker.variable,
           )}
-          style={{
-            backgroundImage:
-              "radial-gradient(circle at 82% 60%, rgba(59, 59, 59,0.06) 0%, rgba(59, 59, 59,0.06) 69%,transparent 69%, transparent 100%),radial-gradient(circle at 36% 0%, rgba(185, 185, 185,0.06) 0%, rgba(185, 185, 185,0.06) 59%,transparent 59%, transparent 100%),radial-gradient(circle at 58% 82%, rgba(183, 183, 183,0.06) 0%, rgba(183, 183, 183,0.06) 17%,transparent 17%, transparent 100%),radial-gradient(circle at 71% 32%, rgba(19, 19, 19,0.06) 0%, rgba(19, 19, 19,0.06) 40%,transparent 40%, transparent 100%),radial-gradient(circle at 77% 5%, rgba(31, 31, 31,0.06) 0%, rgba(31, 31, 31,0.06) 52%,transparent 52%, transparent 100%),radial-gradient(circle at 96% 80%, rgba(11, 11, 11,0.06) 0%, rgba(11, 11, 11,0.06) 73%,transparent 73%, transparent 100%),radial-gradient(circle at 91% 59%, rgba(252, 252, 252,0.06) 0%, rgba(252, 252, 252,0.06) 44%,transparent 44%, transparent 100%),radial-gradient(circle at 52% 82%, rgba(223, 223, 223,0.06) 0%, rgba(223, 223, 223,0.06) 87%,transparent 87%, transparent 100%),radial-gradient(circle at 84% 89%, rgba(160, 160, 160,0.06) 0%, rgba(160, 160, 160,0.06) 57%,transparent 57%, transparent 100%),linear-gradient(90deg, rgb(254,242,164),rgb(166, 255, 237))",
-          }}
           suppressHydrationWarning
         >
           <Providers locale={locale}>
+            {/* <WorkoutSessionsSynchronizer /> */}
             <NextTopLoader color="#FF5722" delay={100} showSpinner={false} />
 
             {/* Main Card Container */}
-            <div className="card bg-base-100 shadow-xl w-full max-w-3xl max-sm:rounded-none max-sm:h-full">
+            <div className="card bg-base-100 dark:bg-[#232324] shadow-xl w-full max-w-3xl max-sm:rounded-none max-sm:h-full border border-base-200 dark:border-gray-800">
               <div className="card-body p-0">
                 <Header />
                 <div className="px-2 sm:px-6 pb-6">{children}</div>

+ 17 - 2
app/[locale]/profile/page.tsx

@@ -1,17 +1,31 @@
 "use client";
+import { useEffect, useState } from "react";
 import { useRouter } from "next/navigation";
 
 import { useI18n } from "locales/client";
-import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
+import { useWorkoutSessionService } from "@/shared/lib/workout-session/use-workout-session.service";
 import { WorkoutSessionList } from "@/features/workout-session/ui/workout-session-list";
 import { WorkoutSessionHeatmap } from "@/features/workout-session/ui/workout-session-heatmap";
+import { useSession } from "@/features/auth/lib/auth-client";
+import { LocalAlert } from "@/components/ui/local-alert";
 import { Button } from "@/components/ui/button";
 
+import type { WorkoutSession } from "@/shared/lib/workout-session/types/workout-session";
+
 export default function ProfilePage() {
   const router = useRouter();
   const t = useI18n();
+  const [sessions, setSessions] = useState<WorkoutSession[]>([]);
+  const { getAll } = useWorkoutSessionService();
+  const { data: session } = useSession();
+  useEffect(() => {
+    const loadSessions = async () => {
+      const loadedSessions = await getAll();
+      setSessions(loadedSessions);
+    };
+    loadSessions();
+  }, []);
 
-  const sessions = typeof window !== "undefined" ? workoutSessionLocal.getAll() : [];
   const values: Record<string, number> = {};
   sessions.forEach((session) => {
     const date = session.startedAt.slice(0, 10);
@@ -24,6 +38,7 @@ export default function ProfilePage() {
 
   return (
     <div>
+      {!session && <LocalAlert />}
       <WorkoutSessionHeatmap until={until} values={values} />
       <WorkoutSessionList />
       <div className="mt-8 flex justify-center">

+ 44 - 0
content/about/en.mdx

@@ -0,0 +1,44 @@
+import Link from "next/link";
+
+# About Workout.cool
+
+## Why Workout.cool?
+
+Workout.cool was born out of the desire to offer a reliable, modern, and actively maintained workout platform, after the original project **workout.lol** was abandoned.
+
+## The Story
+
+Workout.cool is the result of a community-driven adventure.
+
+I was the **first open source contributor** to the `workout.lol` project.
+
+This means I saw the project *come to life*, *grow*, then get **sold** and ultimately **abandoned** by its new owner.
+
+Like many users, I felt a **deep frustration** and a *sense of abandonment* watching a tool I had contributed so much to disappear, with feature requests left unanswered and growing old.
+
+---
+
+*For months*, I tried to contact the new owner—**never receiving a single reply** despite many attempts (*about 15*).
+
+Faced with this **silence** and the **distress of the community**, I decided to **take matters into my own hands**:
+
+> Rather than let all this work vanish, **I relaunched an even more ambitious, modern, and open project for everyone.**
+
+This project is not driven by profit, but by **passion** and a desire to serve the open source fitness community.
+
+**Someone had to save the community—_I decided to be that someone!_**
+
+## Open Source & Community
+
+Workout.cool is open source, ensuring transparency, modularity, and scalability.  
+Everyone is welcome to contribute—code, documentation, or ideas!
+
+- [See the project on GitHub](https://github.com/Snouzy/workout-cool)
+- [Buy a coffee to support](https://ko-fi.com/workoutcool)
+
+## Join the Mission!
+
+Want to contribute, suggest a feature, or simply support the project?  
+Contact us or open an issue on GitHub!
+
+**[hello@workout.cool](mailto:hello@workout.cool)**

+ 44 - 0
content/about/fr.mdx

@@ -0,0 +1,44 @@
+import Link from "next/link";
+
+# À propos de Workout.cool
+
+## Pourquoi Workout.cool ?
+
+Workout.cool est né de la volonté de proposer une plateforme d'entraînement fiable, moderne et maintenue, après l'abandon du projet **workout.lol**.
+
+## L'histoire
+
+Workout.cool est le fruit d'une aventure communautaire.
+
+J'ai été le **premier contributeur open source** du projet `workout.lol`.
+
+De ce fait, j'ai vu ce projet *naître*, *grandir*, puis être **vendu** et finalement **abandonné** par son nouveau propriétaire.
+
+Comme beaucoup d'utilisateurs, j'ai ressenti une **grande frustration** et un *sentiment d'abandon* en voyant disparaître un outil auquel j'avais tant contribué, et en voyant les demandes d'évolution se perdre et prendre de l'âge.
+
+---
+
+*Pendant des mois*, j'ai tenté de contacter le nouveau propriétaire, **sans jamais obtenir de réponse** malgré de nombreux essais (*environ 15*).
+
+Face à ce **silence** et à la **détresse de la communauté**, j'ai décidé de **prendre les choses en main** :
+
+> Plutôt que de laisser ce travail disparaître, **j'ai relancé un projet encore plus ambitieux, moderne et ouvert à tous.**
+
+Ce projet n'est pas motivé par le profit, mais par la **passion** et l'envie de servir la communauté fitness open source.
+
+**Quelqu'un devait sauver la communauté, _j'ai décidé d'être ce quelqu'un_ !**
+
+## Open source & communauté
+
+Workout.cool est open source, garantir transparence, modularité et évolutivité.  
+Toute contribution est la bienvenue, que ce soit pour le code, la documentation ou les idées !
+
+- [Voir le projet sur GitHub](https://github.com/Snouzy/workout-cool)
+- [Payer un café en guise de soutien](https://ko-fi.com/workoutcool)
+
+## Rejoignez la mission !
+
+Vous souhaitez contribuer, proposer une fonctionnalité ou simplement soutenir le projet ?  
+Contactez-nous ou ouvrez une issue sur GitHub !
+
+**[hello@workout.cool](mailto:hello@workout.cool)**

+ 0 - 1
locales/en.ts

@@ -168,7 +168,6 @@ export default {
       previous: "Previous",
       continue: "Continue",
       complete: "Complete",
-      complete_workout: "Complete Workout",
     },
     stats: {
       "muscle_selected#zero": "0 muscle selected",

+ 4 - 5
locales/fr.ts

@@ -168,7 +168,6 @@ export default {
       previous: "Précédent",
       continue: "Continuer",
       complete: "Terminer",
-      complete_workout: "Terminer la séance",
     },
     stats: {
       "muscle_selected#zero": "0 muscle sélectionné",
@@ -211,7 +210,7 @@ export default {
       set_number_plural_singular: "Séries {number}",
       workout_in_progress: "Entraînement en cours",
       started_at: "Débuté à",
-      quit_workout: "Quitter l'Entraînement",
+      quit_workout: "Quitter l'entraînement",
       elapsed_time: "Temps écoulé",
       chronometer: "Chronomètre",
       total_workout_time: "Temps total d'entraînement",
@@ -220,12 +219,12 @@ export default {
       complete: "Terminé",
       active: "Actif",
       no_exercise_selected: "Aucun exercice sélectionné",
-      quit_workout_title: "Quitter l'Entraînement ?",
+      quit_workout_title: "Quitter l'entraînement ?",
       progress: "Progression",
       quit_warning: "Êtes-vous sûr de vouloir quitter ? Vous pouvez sauvegarder votre progression ou la perdre complètement.",
       save_and_quit: "Sauvegarder & Quitter",
       quit_without_save: "Quitter Sans Sauvegarder",
-      continue_workout: "Continuer l'Entraînement",
+      continue_workout: "Continuer l'entraînement",
       history: "Historique des séances [{count}]",
       no_workout_yet: "Aucune séance enregistrée.",
       start: "début",
@@ -324,7 +323,7 @@ export default {
     consent_banner: "Nous utilisons des cookies pour améliorer votre expérience. En cliquant sur Accepter, vous acceptez nos cookies.",
     about: "À propos",
     profile: "Profil",
-    donate: "Faire un don",
+    donate: "Faire un don ❤️",
     my_account: "Mon compte",
     dashboard: "Tableau de bord",
     home: "Accueil",

+ 12 - 0
prisma/migrations/20250614153656_remove_value_int_value_sec_unit_from_workoutset/migration.sql

@@ -0,0 +1,12 @@
+/*
+  Warnings:
+
+  - You are about to drop the column `unit` on the `workout_sets` table. All the data in the column will be lost.
+  - You are about to drop the column `valueInt` on the `workout_sets` table. All the data in the column will be lost.
+  - You are about to drop the column `valueSec` on the `workout_sets` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "workout_sets" DROP COLUMN "unit",
+DROP COLUMN "valueInt",
+DROP COLUMN "valueSec";

+ 0 - 3
prisma/schema.prisma

@@ -296,11 +296,8 @@ model WorkoutSet {
   setIndex                 Int
   type                     WorkoutSetType
   types                    WorkoutSetType[]       @default([])
-  valueInt                 Int? // reps, poids, minutes, etc.
   valuesInt                Int[]                  @default([])
-  valueSec                 Int? // secondes si TIME
   valuesSec                Int[]                  @default([])
-  unit                     WorkoutSetUnit?
   units                    WorkoutSetUnit[]       @default([])
   completed                Boolean                @default(false)
   workoutSessionExercise   WorkoutSessionExercise @relation(fields: [workoutSessionExerciseId], references: [id])

+ 10 - 1
scripts/import-exercises-with-attributes.ts

@@ -76,6 +76,15 @@ async function ensureAttributeNameExists(name: ExerciseAttributeNameEnum) {
   return attributeName;
 }
 
+function normalizeAttributeValue(value: string): ExerciseAttributeValueEnum {
+  const cleaned = value.trim().toUpperCase();
+  if (["N/A", "NA", "NONE", "NULL", ""].includes(cleaned)) return "NA";
+  if ((Object.values(ExerciseAttributeValueEnum) as string[]).includes(cleaned)) {
+    return cleaned as ExerciseAttributeValueEnum;
+  }
+  throw new Error(`Unknown attribute value: ${value}`);
+}
+
 async function ensureAttributeValueExists(attributeNameId: string, value: ExerciseAttributeValueEnum) {
   let attributeValue = await prisma.exerciseAttributeValue.findFirst({
     where: {
@@ -156,7 +165,7 @@ async function importExercisesFromCSV(filePath: string) {
               for (const attr of exercise.attributes) {
                 try {
                   const attributeName = await ensureAttributeNameExists(attr.attributeName);
-                  const attributeValue = await ensureAttributeValueExists(attributeName.id, attr.attributeValue);
+                  const attributeValue = await ensureAttributeValueExists(attributeName.id, normalizeAttributeValue(attr.attributeValue));
 
                   await prisma.exerciseAttribute.create({
                     data: {

+ 3 - 3
src/components/ui/link.tsx

@@ -13,9 +13,9 @@ interface LinkProps extends ComponentProps<typeof NextLink> {
 export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
   ({ className, variant = "default", size = "base", children, ...props }, ref) => {
     const variants = {
-      default: "link link-hover text-base-content hover:text-primary transition-colors",
-      nav: "link link-hover text-base-content/80 hover:text-base-content transition-colors",
-      footer: "link link-hover text-base-content/70 hover:text-base-content transition-colors",
+      default: "link link-hover text-base-content hover:text-primary transition-colors dark:text-gray-200 dark:hover:text-primary",
+      nav: "link link-hover text-base-content/80 hover:text-base-content transition-colors dark:text-gray-200 dark:hover:text-primary",
+      footer: "link link-hover text-base-content/70 hover:text-base-content transition-colors dark:text-gray-200 dark:hover:text-primary",
       button: "btn btn-link no-underline hover:underline",
     };
 

+ 29 - 0
src/components/ui/local-alert.tsx

@@ -0,0 +1,29 @@
+import React from "react";
+import Link from "next/link";
+
+import { cn } from "@/shared/lib/utils";
+import { paths } from "@/shared/constants/paths";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+interface LocalAlertProps {
+  className?: string;
+}
+
+export const LocalAlert = ({ className }: LocalAlertProps) => {
+  return (
+    <Alert className={cn("bg-blue-100 border-0 text-black", className)} variant="info">
+      <AlertDescription className="flex flex-wrap items-center gap-1 italic text-base">
+        Your progress is stored in your browser.
+        <br className="sm:hidden" />
+        <Link className="ml-1 mr-1 font-medium text-blue-700 underline" href={paths.signUp}>
+          Create an account
+        </Link>
+        or
+        <Link className="ml-1 font-medium text-purple-700 underline" href={paths.signIn}>
+          Log-in
+        </Link>
+        to ensure it is not getting lost
+      </AlertDescription>
+    </Alert>
+  );
+};

+ 10 - 10
src/features/auth/model/useLogout.ts

@@ -1,19 +1,19 @@
 "use client";
 
-import { redirect } from "next/navigation";
-import { useMutation } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
 
 import { authClient } from "@/features/auth/lib/auth-client";
 
 export const useLogout = (redirectUrl: string = "/") => {
+  const router = useRouter();
+  const queryClient = useQueryClient();
+
   return useMutation({
-    mutationFn: async () =>
-      await authClient.signOut({
-        fetchOptions: {
-          onSuccess: () => {
-            redirect(redirectUrl);
-          },
-        },
-      }),
+    mutationFn: async () => {
+      await authClient.signOut();
+      router.push(redirectUrl);
+      queryClient.invalidateQueries({ queryKey: ["session"] });
+    },
   });
 };

+ 5 - 4
src/features/layout/Footer.tsx

@@ -16,7 +16,7 @@ const SOCIAL_LINKS = [
     label: "Twitter/X",
   },
   {
-    href: "mailto:info@workout.cool",
+    href: "mailto:coolworkout6@gmail.com",
     icon: Mail,
     label: "Email",
   },
@@ -32,14 +32,14 @@ export const Footer = async () => {
   const t = await getI18n();
 
   return (
-    <footer className="border-t border-base-300 bg-base-100 px-6 py-4">
+    <footer className="border-t border-base-300 dark:border-gray-800 bg-base-100 dark:bg-black px-6 py-4 rounded-b-lg">
       <div className="flex flex-col sm:flex-row justify-between items-center gap-4">
         {/* Social Icons */}
         <div className="flex gap-2">
           {SOCIAL_LINKS.map(({ href, icon: Icon, label }) => (
             <a
               aria-label={label}
-              className="btn btn-ghost btn-sm btn-circle text-gray-700"
+              className="btn btn-ghost btn-sm btn-circle text-gray-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-800"
               href={href}
               key={label}
               rel="noopener noreferrer"
@@ -51,9 +51,10 @@ export const Footer = async () => {
         </div>
 
         {/* Navigation Links */}
-        <div className="flex flex-col sm:flex-row gap-2 sm:gap-4 text-center text-gray-700">
+        <div className="flex flex-col sm:flex-row gap-2 sm:gap-4 text-center text-gray-700 dark:text-gray-300">
           {NAVIGATION(t).map(({ name, href }) => (
             <Link
+              className="hover:underline hover:text-blue-500 dark:hover:text-blue-400"
               href={href}
               key={name}
               size="sm"

+ 31 - 16
src/features/layout/Header.tsx

@@ -5,10 +5,10 @@ import { Home, LogIn, UserPlus, LogOut, User } from "lucide-react";
 
 import { useI18n } from "locales/client";
 import Logo from "@public/logo.png";
+import { ThemeToggle } from "@/features/theme/ThemeToggle";
 import { ReleaseNotesDialog } from "@/features/release-notes";
 import { useLogout } from "@/features/auth/model/useLogout";
 import { useSession } from "@/features/auth/lib/auth-client";
-import { InlineTooltip } from "@/components/ui/tooltip";
 import { Link } from "@/components/ui/link";
 
 export const Header = () => {
@@ -24,21 +24,24 @@ export const Header = () => {
   };
 
   return (
-    <div className="navbar bg-base-100 px-4">
+    <div className="navbar bg-base-100 dark:bg-black dark:text-gray-200 px-4 rounded-lg">
       {/* Logo and Title */}
       <div className="navbar-start flex items-center gap-2">
-        <Link className="group flex items-center space-x-3 rounded-xl bg-gradient-to-r px-4 py-2 transition-all duration-200 " href="/">
-          <div className="relative">
+        <Link
+          className="group flex items-center space-x-3 rounded-xl bg-gradient-to-r px-4 py-2 transition-all duration-200 dark:text-gray-200 dark:bg-gray-800"
+          href="/"
+        >
+          <div className="relative flex-none">
             <Image
               alt="workout cool logo"
-              className="h-8 w-8 transition-transform duration-200 group-hover:rotate-[20deg] group-hover:scale-110"
+              className="h-6 w-6 sm:h-8 sm:w-8 transition-transform duration-200 group-hover:rotate-[20deg] group-hover:scale-110"
               height={32}
               src={Logo}
               width={32}
             />
             <div className="absolute -top-1 -right-1 h-3 w-3 rounded-full bg-emerald-400 opacity-0 transition-opacity duration-200 group-hover:opacity-100"></div>
           </div>
-          <div className="flex flex-col">
+          <div className="flex-col hidden sm:flex">
             <span className="font-bold transition-colors duration-200 group-hover:text-blue-400">Workout.cool</span>
           </div>
         </Link>
@@ -46,48 +49,60 @@ export const Header = () => {
 
       {/* User Menu */}
       <div className="navbar-end">
-        <Link aria-label={t("commons.home")} className="hover:bg-slate-100 rounded-full p-2 transition" href="/">
-          <InlineTooltip title={t("commons.home")}>
-            <Home className="w-6 h-6 text-blue-500" />
-          </InlineTooltip>
+        <Link
+          aria-label={t("commons.home")}
+          className="hover:bg-slate-100 dark:hover:bg-gray-800 rounded-full p-2 transition flex"
+          href="/"
+        >
+          <div className="tooltip" data-tip={t("commons.home")}>
+            <Home className="w-6 h-6 text-blue-500 dark:text-blue-400" />
+          </div>
         </Link>
 
         <ReleaseNotesDialog />
 
-        <div className="dropdown dropdown-end">
+        <ThemeToggle />
+
+        <div className="dropdown dropdown-end ml-1">
           <div className="btn btn-ghost btn-circle avatar" role="button" tabIndex={0}>
             <div className="w-8 rounded-full bg-primary text-primary-content !flex items-center justify-center text-sm font-medium">
               {userAvatar || <User className="w-4 h-4" />}
             </div>
           </div>
 
-          <ul className="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 rounded-box w-52" tabIndex={0}>
+          <ul
+            className="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 dark:bg-black dark:text-gray-200 rounded-box w-52 border border-slate-100 dark:border-gray-800"
+            tabIndex={0}
+          >
             <li>
               <Link className="!no-underline" href="/profile" size="base" variant="nav">
                 {t("commons.profile")}
               </Link>
             </li>
 
-            <hr className="my-1" />
+            <hr className="my-1 border-slate-100 dark:border-gray-800" />
 
             {!session.data ? (
               <>
                 <li>
                   <Link className="!no-underline" href="/auth/signin" size="base" variant="nav">
-                    <LogIn className="w-4 h-4" />
+                    <LogIn className="w-4 h-4 text-gray-700 dark:text-gray-300" />
                     {t("commons.login")}
                   </Link>
                 </li>
                 <li>
                   <Link className="!no-underline" href="/auth/signup" size="base" variant="nav">
-                    <UserPlus className="w-4 h-4" />
+                    <UserPlus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
                     {t("commons.register")}
                   </Link>
                 </li>
               </>
             ) : (
               <li>
-                <button className="flex items-center gap-2 text-base" onClick={handleSignOut}>
+                <button
+                  className="flex items-center gap-2 text-base text-gray-700 dark:text-gray-300 hover:bg-slate-100 dark:hover:bg-gray-800 rounded-lg px-3 py-2 transition-colors"
+                  onClick={handleSignOut}
+                >
                   <LogOut className="w-4 h-4" />
                   {t("commons.logout")}
                 </button>

+ 2 - 2
src/features/layout/authenticated-header.tsx

@@ -101,7 +101,7 @@ export const AuthenticatedHeader = () => {
   const user = useCurrentUser();
   const { data: sessionData, isPending: isSessionLoading } = useSession();
   const { toggleSidebar } = useSidebarToggle();
-  const logout = useLogout();
+  const logout = useLogout(paths.root);
   const [mounted, setMounted] = useState(false);
 
   useEffect(() => {
@@ -212,7 +212,7 @@ export const AuthenticatedHeader = () => {
                 <DropdownMenuItem className="p-0">
                   <Button
                     className={`flex items-center gap-1.5 rounded-lg px-3 py-2 ${pathName === "/login" && "!bg-gray-400 !text-black dark:!bg-white/5 dark:!text-white"}`}
-                    onClick={() => logout.mutateAsync()}
+                    onClick={() => logout.mutate()}
                     variant={null}
                   >
                     <LogOut className="size-[18px] shrink-0" />

+ 3 - 4
src/features/release-notes/ui/release-notes-dialog.tsx

@@ -5,7 +5,6 @@ import { Bell } from "lucide-react";
 
 import { useCurrentLocale, useI18n } from "locales/client";
 import { formatDate } from "@/shared/lib/date";
-import { InlineTooltip } from "@/components/ui/tooltip";
 import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
 import { Button } from "@/components/ui/button";
 
@@ -19,9 +18,9 @@ export function ReleaseNotesDialog() {
     <Dialog>
       <DialogTrigger asChild>
         <Button aria-label={t("release_notes.release_notes")} className="rounded-full hover:bg-slate-100" size="small" variant="ghost">
-          <InlineTooltip title={t("commons.changelog")}>
-            <Bell className="text-blue-500 h-6 w-6" />
-          </InlineTooltip>
+          <div className="tooltip" data-tip={t("commons.changelog")}>
+            <Bell className="text-blue-500 dark:text-blue-400 h-6 w-6" />
+          </div>
         </Button>
       </DialogTrigger>
       <DialogContent className="max-w-md">

+ 19 - 6
src/features/theme/ThemeToggle.tsx

@@ -1,18 +1,31 @@
 "use client";
 
+import { useEffect } from "react";
 import { useTheme } from "next-themes";
-import { Moon, Sun } from "lucide-react";
+import { MoonIcon, SunIcon } from "lucide-react";
 
 import { Button } from "@/components/ui/button";
 
 export function ThemeToggle() {
-  const { setTheme, theme } = useTheme();
+  const { setTheme, resolvedTheme } = useTheme();
+
+  useEffect(() => {
+    console.log("resolvedTheme:", resolvedTheme);
+  }, [resolvedTheme]);
 
   return (
-    <Button onClick={() => setTheme(theme === "light" ? "dark" : "light")} variant="ghost">
-      <Sun className="h-[1.5rem] w-[1.3rem] dark:hidden" />
-      <Moon className="hidden size-5 dark:block" />
-      <span className="sr-only">Toggle theme</span>
+    <Button
+      className="hover:bg-slate-100 rounded-full p-2 pr-2"
+      onClick={() => setTheme(resolvedTheme === "light" ? "dark" : "light")}
+      variant="ghost"
+    >
+      <div className="tooltip" data-tip={resolvedTheme === "light" ? "Dark mode" : "Light mode"}>
+        {resolvedTheme === "light" ? (
+          <MoonIcon className="text-blue-500 dark:text-blue-400" />
+        ) : (
+          <SunIcon className="text-blue-500 dark:text-blue-400" />
+        )}
+      </div>
     </Button>
   );
 }

+ 3 - 1
src/features/workout-builder/ui/stepper-header.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import React from "react";
+import { useTheme } from "next-themes";
 import { Check } from "lucide-react";
 
 import { cn } from "@/shared/lib/utils";
@@ -88,8 +89,9 @@ function StepperStep({ description, isActive, isCompleted, stepNumber, title }:
 }
 
 export function StepperHeader({ steps }: StepperHeaderProps) {
+  const { resolvedTheme } = useTheme();
   return (
-    <div className="w-full mb-8">
+    <div className={cn("w-full", resolvedTheme === "dark" ? "my-8" : "mb-8")}>
       {/* Layout mobile - vertical */}
       <div className="flex flex-col space-y-6 md:hidden">
         {steps.map((step, index) => (

+ 48 - 0
src/features/workout-session/actions/get-workout-sessions.action.ts

@@ -0,0 +1,48 @@
+"use server";
+
+import { z } from "zod";
+
+import { prisma } from "@/shared/lib/prisma";
+import { actionClient } from "@/shared/api/safe-actions";
+
+const getWorkoutSessionsSchema = z.object({
+  userId: z.string().optional(),
+});
+
+export const getWorkoutSessionsAction = actionClient.schema(getWorkoutSessionsSchema).action(async ({ parsedInput }) => {
+  try {
+    const { userId } = parsedInput;
+
+    if (!userId) {
+      return { serverError: "User ID is required" };
+    }
+
+    const sessions = await prisma.workoutSession.findMany({
+      where: { userId },
+      include: {
+        exercises: {
+          include: {
+            exercise: {
+              include: {
+                attributes: {
+                  include: {
+                    attributeName: true,
+                    attributeValue: true,
+                  },
+                },
+              },
+            },
+            sets: true,
+          },
+        },
+      },
+      orderBy: {
+        startedAt: "desc",
+      },
+    });
+    return { sessions };
+  } catch (error) {
+    console.error("Error fetching workout sessions:", error);
+    return { serverError: "Failed to fetch workout sessions" };
+  }
+});

+ 95 - 0
src/features/workout-session/actions/sync-workout-sessions.action.ts

@@ -0,0 +1,95 @@
+"use server";
+
+import { z } from "zod";
+
+import { workoutSessionStatuses } from "@/shared/lib/workout-session/types/workout-session";
+import { prisma } from "@/shared/lib/prisma";
+import { actionClient } from "@/shared/api/safe-actions";
+
+// Schéma WorkoutSet
+const workoutSetSchema = z.object({
+  id: z.string(),
+  setIndex: z.number(),
+  types: z.array(z.enum(["TIME", "WEIGHT", "REPS", "BODYWEIGHT", "NA"])),
+  valuesInt: z.array(z.number()).optional(),
+  valuesSec: z.array(z.number()).optional(),
+  units: z.array(z.enum(["kg", "lbs"])).optional(),
+  completed: z.boolean(),
+});
+
+const workoutSessionExerciseSchema = z.object({
+  id: z.string(),
+  order: z.number(),
+  sets: z.array(workoutSetSchema),
+});
+
+const syncWorkoutSessionSchema = z.object({
+  session: z.object({
+    id: z.string(),
+    userId: z.string(),
+    startedAt: z.string(),
+    endedAt: z.string().optional(),
+    exercises: z.array(workoutSessionExerciseSchema),
+    status: z.enum(workoutSessionStatuses),
+  }),
+});
+
+export const syncWorkoutSessionAction = actionClient.schema(syncWorkoutSessionSchema).action(async ({ parsedInput }) => {
+  try {
+    const { session } = parsedInput;
+
+    const { status: _s, ...sessionData } = session;
+
+    const result = await prisma.workoutSession.upsert({
+      where: { id: session.id },
+      create: {
+        ...sessionData,
+        exercises: {
+          create: session.exercises.map((exercise) => ({
+            order: exercise.order,
+            exercise: { connect: { id: exercise.id } },
+            sets: {
+              create: exercise.sets.map((set) => ({
+                setIndex: set.setIndex,
+                types: set.types,
+                valuesInt: set.valuesInt,
+                valuesSec: set.valuesSec,
+                units: set.units,
+                completed: set.completed,
+                type: set.types && set.types.length > 0 ? set.types[0] : "NA",
+              })),
+            },
+          })),
+        },
+      },
+      update: {
+        startedAt: sessionData.startedAt,
+        endedAt: sessionData.endedAt,
+        userId: sessionData.userId,
+        exercises: {
+          deleteMany: {},
+          create: session.exercises.map((exercise) => ({
+            order: exercise.order,
+            exercise: { connect: { id: exercise.id } },
+            sets: {
+              create: exercise.sets.map((set) => ({
+                setIndex: set.setIndex,
+                types: set.types,
+                valuesInt: set.valuesInt,
+                valuesSec: set.valuesSec,
+                units: set.units,
+                completed: set.completed,
+                type: set.types && set.types.length > 0 ? set.types[0] : "NA",
+              })),
+            },
+          })),
+        },
+      },
+    });
+
+    return { data: result };
+  } catch (error) {
+    console.error("Error syncing workout session:", error);
+    return { serverError: "Failed to sync workout session" };
+  }
+});

+ 108 - 0
src/features/workout-session/model/use-sync-workout-sessions.ts

@@ -0,0 +1,108 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
+import { useSession } from "@/features/auth/lib/auth-client";
+import { brandedToast } from "@/components/ui/toast";
+
+import { syncWorkoutSessionAction } from "../actions/sync-workout-sessions.action";
+
+interface SyncState {
+  isSyncing: boolean;
+  error: Error | null;
+  lastSyncAt: Date | null;
+}
+
+const SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes
+
+export function useSyncWorkoutSessions() {
+  const { data: session, isPending: isSessionLoading } = useSession();
+
+  const [syncState, setSyncState] = useState<SyncState>({
+    isSyncing: false,
+    error: null,
+    lastSyncAt: null,
+  });
+
+  const syncSessions = async () => {
+    if (!session?.user) return;
+
+    setSyncState((prev) => ({ ...prev, isSyncing: true, error: null }));
+
+    try {
+      const localSessions = workoutSessionLocal.getAll().filter((s) => s.status === "completed");
+
+      for (const localSession of localSessions) {
+        try {
+          const result = await syncWorkoutSessionAction({
+            session: {
+              ...localSession,
+              userId: session.user.id,
+              status: "synced",
+            },
+          });
+
+          if (result && result.serverError) {
+            throw new Error(result.serverError);
+          }
+
+          if (result && result.data) {
+            const { data } = result.data;
+
+            if (data) {
+              workoutSessionLocal.markSynced(localSession.id, data.id);
+            }
+          }
+        } catch (error) {
+          console.error(`Failed to sync session ${localSession.id}:`, error);
+        }
+      }
+
+      workoutSessionLocal.purgeSynced();
+
+      setSyncState((prev) => ({
+        ...prev,
+        isSyncing: false,
+        lastSyncAt: new Date(),
+      }));
+
+      brandedToast({
+        title: "Synchronisation réussie",
+        subtitle: `${localSessions.length} sessions synchronisées`,
+      });
+    } catch (error) {
+      setSyncState((prev) => ({
+        ...prev,
+        isSyncing: false,
+        error: error as Error,
+      }));
+
+      brandedToast({
+        title: "Erreur de synchronisation",
+        subtitle: "Certaines sessions n'ont pas pu être synchronisées",
+        variant: "error",
+      });
+    }
+  };
+
+  // Sync on login
+  useEffect(() => {
+    if (!isSessionLoading && session?.user) {
+      syncSessions();
+    }
+  }, [session, isSessionLoading]);
+
+  // Periodic sync
+  useEffect(() => {
+    if (!session?.user) return;
+
+    const interval = setInterval(syncSessions, SYNC_INTERVAL);
+    return () => clearInterval(interval);
+  }, [session]);
+
+  return {
+    syncSessions,
+    ...syncState,
+  };
+}

+ 19 - 0
src/features/workout-session/model/use-workout-sessions.ts

@@ -0,0 +1,19 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { useWorkoutSessionService } from "@/shared/lib/workout-session/use-workout-session.service";
+import { useSession } from "@/features/auth/lib/auth-client";
+
+export function useWorkoutSessions() {
+  const { data: session } = useSession();
+
+  const { getAll } = useWorkoutSessionService();
+
+  return useQuery({
+    queryKey: ["workout-sessions", session?.user?.id],
+    queryFn: async () => {
+      return getAll();
+    },
+  });
+}

+ 2 - 1
src/features/workout-session/model/workout-session.store.ts

@@ -1,7 +1,8 @@
 import { create } from "zustand";
 
 import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
-import { WorkoutSession, WorkoutSessionExercise, WorkoutSet } from "@/features/workout-session/types/workout-set";
+import { WorkoutSession } from "@/shared/lib/workout-session/types/workout-session";
+import { WorkoutSessionExercise, WorkoutSet } from "@/features/workout-session/types/workout-set";
 import { useWorkoutBuilderStore } from "@/features/workout-builder/model/workout-builder.store";
 
 import { ExerciseWithAttributes } from "../../workout-builder/types";

+ 0 - 1
src/features/workout-session/schema/workout-session-set.schema.ts

@@ -5,7 +5,6 @@ export const workoutSessionSetSchema = z.object({
   setIndex: z.number().int().min(0),
   type: z.enum(["TIME", "WEIGHT", "REPS", "BODYWEIGHT", "NA"]),
   types: z.array(z.enum(["TIME", "WEIGHT", "REPS", "BODYWEIGHT", "NA"])).optional(),
-  valueInt: z.number().int().optional(),
   valuesInt: z.array(z.number().int()).optional(),
   valueSec: z.number().int().min(0).max(59).optional(),
   valuesSec: z.array(z.number().int().min(0).max(59)).optional(),

+ 3 - 18
src/features/workout-session/types/workout-set.ts

@@ -6,12 +6,9 @@ export type WorkoutSetUnit = "kg" | "lbs";
 export interface WorkoutSet {
   id: string;
   setIndex: number;
-  types: WorkoutSetType[]; // Pour supporter plusieurs colonnes
-  valueInt?: number; // reps, weight, minutes, etc.
-  valuesInt?: number[]; // Pour supporter plusieurs colonnes
-  valueSec?: number; // seconds (if TIME)
-  valuesSec?: number[]; // Pour supporter plusieurs colonnes
-  unit?: WorkoutSetUnit;
+  types: WorkoutSetType[]; // To support multiple columns
+  valuesInt?: number[]; // To support multiple columns
+  valuesSec?: number[]; // To support multiple columns
   units?: WorkoutSetUnit[]; // Pour supporter plusieurs colonnes
   completed: boolean;
 }
@@ -21,15 +18,3 @@ export interface WorkoutSessionExercise extends ExerciseWithAttributes {
   order: number;
   sets: WorkoutSet[];
 }
-
-export interface WorkoutSession {
-  id: string;
-  userId: string;
-  startedAt: string;
-  endedAt?: string;
-  duration?: number;
-  exercises: WorkoutSessionExercise[];
-  status?: "active" | "completed" | "synced";
-  currentExerciseIndex?: number;
-  isActive?: boolean;
-}

+ 5 - 5
src/features/workout-session/ui/workout-session-header.tsx

@@ -53,7 +53,7 @@ export function WorkoutSessionHeader({
   return (
     <>
       <div className="w-full mb-8">
-        <div className="rounded-xl p-3 bg-slate-50">
+        <div className="rounded-xl p-3 bg-slate-50 dark:bg-slate-900/80">
           <div className="flex items-center justify-between mb-4">
             <div className="flex items-center gap-2">
               <div className="w-2 h-2 rounded-full bg-emerald-400 animate-ping"></div>
@@ -64,7 +64,7 @@ export function WorkoutSessionHeader({
             </div>
 
             <Button
-              className="border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500 px-2 py-1 text-xs"
+              className="border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500 px-2 py-1 text-xs dark:border-red-700/40 dark:text-red-300 dark:hover:bg-red-700/10"
               onClick={handleQuitClick}
               variant="outline"
             >
@@ -128,18 +128,18 @@ export function WorkoutSessionHeader({
               <div className="space-y-2">
                 <div className="flex items-center justify-between">
                   <span className="text-lg font-bold text-slate-900 dark:text-white">{exercisesCompleted}</span>
-                  <span className="text-slate-400">/ {totalExercises}</span>
+                  <span className="text-slate-400 dark:text-slate-400">/ {totalExercises}</span>
                 </div>
 
                 <div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2 overflow-hidden">
                   <div
                     className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-500 ease-out"
-                    style={{ width: `${((currentExerciseIndex + 1) / totalExercises) * 100}%` }}
+                    style={{ width: `${(exercisesCompleted / totalExercises) * 100}%` }}
                   />
                 </div>
 
                 <div className="text-center">
-                  <span className="text-xs text-slate-400">
+                  <span className="text-xs text-slate-400 dark:text-slate-400">
                     {Math.round((exercisesCompleted / totalExercises) * 100)}% {t("workout_builder.session.complete")}
                   </span>
                 </div>

+ 14 - 15
src/features/workout-session/ui/workout-session-list.tsx

@@ -1,15 +1,11 @@
-import { useState } from "react";
 import { useRouter } from "next/navigation";
 import { Repeat2, Trash2 } from "lucide-react";
 
 import { useCurrentLocale, useI18n } from "locales/client";
-import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
+import { useWorkoutSessions } from "@/features/workout-session/model/use-workout-sessions";
 import { useWorkoutBuilderStore } from "@/features/workout-builder/model/workout-builder.store";
-import { InlineTooltip } from "@/components/ui/tooltip";
 import { Button } from "@/components/ui/button";
 
-import type { WorkoutSession } from "@/shared/lib/workout-session/types/workout-session";
-
 const BADGE_COLORS = [
   "bg-blue-100 text-blue-700 border-blue-300",
   "bg-green-100 text-green-700 border-green-300",
@@ -25,13 +21,16 @@ export function WorkoutSessionList() {
   const router = useRouter();
   const loadFromSession = useWorkoutBuilderStore((s) => s.loadFromSession);
 
-  const [sessions, setSessions] = useState<WorkoutSession[]>(() =>
-    workoutSessionLocal.getAll().sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()),
-  );
+  // const [sessions, setSessions] = useState<WorkoutSession[]>(() =>
+  //   workoutSessionLocal.getAll().sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()),
+  // );
+
+  const { data: sessions = [] } = useWorkoutSessions();
+  console.log("sessions:", sessions);
 
-  const handleDelete = (id: string) => {
-    workoutSessionLocal.remove(id);
-    setSessions(workoutSessionLocal.getAll().sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()));
+  const handleDelete = (_id: string) => {
+    // TODO: delete by service
+    // workoutSessionLocal.remove(id);
   };
 
   const handleRepeat = (id: string) => {
@@ -117,7 +116,7 @@ export function WorkoutSessionList() {
                 })}
               </div>
               <div className="flex gap-2 items-center mt-2 sm:mt-0">
-                <InlineTooltip title={t("workout_builder.session.repeat")}>
+                <div className="tooltip" data-tip={t("workout_builder.session.repeat")}>
                   <Button
                     aria-label={t("workout_builder.session.repeat")}
                     className="w-12 h-12"
@@ -127,8 +126,8 @@ export function WorkoutSessionList() {
                   >
                     <Repeat2 className="w-7 h-7 text-blue-500" />
                   </Button>
-                </InlineTooltip>
-                <InlineTooltip title={t("workout_builder.session.delete")}>
+                </div>
+                <div className="tooltip" data-tip={t("workout_builder.session.delete")}>
                   <Button
                     aria-label={t("workout_builder.session.delete")}
                     onClick={() => handleDelete(session.id)}
@@ -137,7 +136,7 @@ export function WorkoutSessionList() {
                   >
                     <Trash2 className="w-7 h-7 text-red-500" />
                   </Button>
-                </InlineTooltip>
+                </div>
               </div>
             </li>
           );

+ 16 - 14
src/features/workout-session/ui/workout-session-set.tsx

@@ -67,16 +67,16 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
   };
 
   const renderInputForType = (type: WorkoutSetType, columnIndex: number) => {
-    const valuesInt = Array.isArray(set.valuesInt) ? set.valuesInt : [set.valueInt];
-    const valuesSec = Array.isArray(set.valuesSec) ? set.valuesSec : [set.valueSec];
-    const units = Array.isArray(set.units) ? set.units : [set.unit];
+    const valuesInt = set.valuesInt || [];
+    const valuesSec = set.valuesSec || [];
+    const units = set.units || [];
 
     switch (type) {
       case "TIME":
         return (
           <div className="flex gap-1 w-full">
             <input
-              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold"
+              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold dark:bg-slate-800 dark:placeholder:text-slate-500"
               disabled={set.completed}
               min={0}
               onChange={handleValueIntChange(columnIndex)}
@@ -85,7 +85,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
               value={valuesInt[columnIndex] ?? ""}
             />
             <input
-              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold"
+              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold dark:bg-slate-800 dark:placeholder:text-slate-500"
               disabled={set.completed}
               max={59}
               min={0}
@@ -100,7 +100,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
         return (
           <div className="flex gap-1 w-full items-center">
             <input
-              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold"
+              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold dark:bg-slate-800"
               disabled={set.completed}
               min={0}
               onChange={handleValueIntChange(columnIndex)}
@@ -109,7 +109,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
               value={valuesInt[columnIndex] ?? ""}
             />
             <select
-              className="border border-black rounded px-1 py-1 w-1/2 text-sm font-bold bg-white"
+              className="border border-black rounded px-1 py-1 w-1/2 text-sm font-bold bg-white dark:bg-slate-800 dark:text-gray-200"
               disabled={set.completed}
               onChange={handleUnitChange(columnIndex)}
               value={units[columnIndex] ?? "kg"}
@@ -122,7 +122,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
       case "REPS":
         return (
           <input
-            className="border border-black rounded px-1 py-1 w-full text-sm text-center font-bold"
+            className="border border-black rounded px-1 py-1 w-full text-sm text-center font-bold dark:bg-slate-800"
             disabled={set.completed}
             min={0}
             onChange={handleValueIntChange(columnIndex)}
@@ -134,7 +134,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
       case "BODYWEIGHT":
         return (
           <input
-            className="border border-black rounded px-1 py-1 w-full text-sm text-center font-bold"
+            className="border border-black rounded px-1 py-1 w-full text-sm text-center font-bold dark:bg-slate-800"
             disabled={set.completed}
             placeholder=""
             readOnly
@@ -147,12 +147,14 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
   };
 
   return (
-    <div className="w-full py-4 flex flex-col gap-2 bg-slate-50 border border-slate-200 rounded-xl shadow-sm mb-3 relative px-2 sm:px-4">
+    <div className="w-full py-4 flex flex-col gap-2 bg-slate-50 dark:bg-slate-900/80 border border-slate-200 dark:border-slate-700/50 rounded-xl shadow-sm mb-3 relative px-2 sm:px-4">
       <div className="flex items-center justify-between mb-2">
-        <div className="bg-blue-500 text-white text-xs font-bold px-3 py-1 rounded-full shadow">SET {setIndex + 1}</div>
+        <div className="bg-blue-500 text-white text-xs font-bold px-3 py-1 rounded-full shadow dark:bg-blue-900 dark:text-blue-300">
+          SET {setIndex + 1}
+        </div>
         <Button
           aria-label="Supprimer la série"
-          className="bg-red-100 hover:bg-red-200 text-red-600 rounded-full p-1 h-8 w-8 flex items-center justify-center shadow transition"
+          className="bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/60 text-red-600 dark:text-red-300 rounded-full p-1 h-8 w-8 flex items-center justify-center shadow transition"
           disabled={set.completed}
           onClick={onRemove}
           type="button"
@@ -167,7 +169,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
           <div className="flex flex-col w-full md:w-auto" key={columnIndex}>
             <div className="flex items-center w-full gap-1 mb-1">
               <select
-                className="border border-black rounded font-bold px-1 py-1 text-sm w-full bg-white min-w-0"
+                className="border border-black dark:border-slate-700 rounded font-bold px-1 py-1 text-sm w-full bg-white dark:bg-slate-800 dark:text-gray-200 min-w-0"
                 disabled={set.completed}
                 onChange={handleTypeChange(columnIndex)}
                 value={type}
@@ -179,7 +181,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
               </select>
               {types.length > 1 && (
                 <Button
-                  className="p-1 h-auto bg-red-500 hover:bg-red-600 flex-shrink-0"
+                  className="p-1 h-auto bg-red-500 hover:bg-red-600 dark:bg-red-900 dark:hover:bg-red-800 flex-shrink-0"
                   onClick={() => removeColumn(columnIndex)}
                   size="small"
                   variant="destructive"

+ 4 - 1
src/features/workout-session/ui/workout-session-sets.tsx

@@ -10,6 +10,7 @@ import { useCurrentLocale, useI18n } from "locales/client";
 import TrophyImg from "@public/images/trophy.png";
 import { cn } from "@/shared/lib/utils";
 import { useWorkoutSession } from "@/features/workout-session/model/use-workout-session";
+import { useSyncWorkoutSessions } from "@/features/workout-session/model/use-sync-workout-sessions";
 import { ExerciseVideoModal } from "@/features/workout-builder/ui/exercise-video-modal";
 import { Button } from "@/components/ui/button";
 
@@ -31,6 +32,7 @@ export function WorkoutSessionSets({
     useWorkoutSession();
   const exerciseDetailsMap = Object.fromEntries(session?.exercises.map((ex) => [ex.id, ex]) || []);
   const [videoModal, setVideoModal] = useState<{ open: boolean; exerciseId?: string }>({ open: false });
+  const { syncSessions } = useSyncWorkoutSessions();
 
   if (showCongrats) {
     return (
@@ -76,6 +78,7 @@ export function WorkoutSessionSets({
 
   const handleFinishSession = () => {
     completeWorkout();
+    syncSessions();
     onCongrats();
     confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
   };
@@ -160,7 +163,7 @@ export function WorkoutSessionSets({
               )}
               {/* Si exercice courant, afficher le détail */}
               {idx === currentExerciseIndex && (
-                <div className="bg-white dark:bg-slate-900 rounded-xl my-10">
+                <div className="bg-white dark:bg-transparent rounded-xl my-10">
                   {/* Liste des sets */}
                   <div className="space-y-10 mb-8">
                     {ex.sets.map((set, setIdx) => (

+ 13 - 0
src/features/workout-session/ui/workout-sessions-synchronizer.tsx

@@ -0,0 +1,13 @@
+"use client";
+
+import { useSyncWorkoutSessions } from "../model/use-sync-workout-sessions";
+
+export const WorkoutSessionsSynchronizer = () => {
+  const { isSyncing, syncSessions } = useSyncWorkoutSessions();
+
+  if (isSyncing) {
+    return <div>Synchronizing...</div>;
+  }
+
+  return <button onClick={() => syncSessions()}>Sync</button>;
+};

+ 3 - 0
src/shared/lib/format.ts

@@ -0,0 +1,3 @@
+export function nullToUndefined<T>(value: T | null): T | undefined {
+  return value === null ? undefined : value;
+}

+ 4 - 3
src/shared/lib/workout-session/types/workout-session.ts

@@ -1,9 +1,12 @@
 import { WorkoutSessionExercise } from "@/features/workout-session/types/workout-set";
 
+export const workoutSessionStatuses = ["active", "completed", "synced"] as const;
+export type WorkoutSessionStatus = (typeof workoutSessionStatuses)[number];
+
 export interface WorkoutSession {
   id: string; // local: "local-xxx", server: uuid
   userId: string;
-  status?: "active" | "completed" | "synced";
+  status?: WorkoutSessionStatus;
   startedAt: string;
   endedAt?: string;
   duration?: number;
@@ -12,5 +15,3 @@ export interface WorkoutSession {
   isActive?: boolean;
   serverId?: string; // If synced
 }
-
-export type WorkoutSessionStatus = WorkoutSession["status"];

+ 107 - 0
src/shared/lib/workout-session/use-workout-session.service.ts

@@ -0,0 +1,107 @@
+import { nullToUndefined } from "@/shared/lib/format";
+import { syncWorkoutSessionAction } from "@/features/workout-session/actions/sync-workout-sessions.action";
+import { getWorkoutSessionsAction } from "@/features/workout-session/actions/get-workout-sessions.action";
+import { useSession } from "@/features/auth/lib/auth-client";
+
+import { workoutSessionLocal } from "./workout-session.local";
+
+import type { WorkoutSession } from "./types/workout-session";
+
+// This is an abstraction layer to handle the local storage and/or the API calls.
+// He's the orchestrator.
+
+export const useWorkoutSessionService = () => {
+  const { data: session } = useSession();
+  const userId = session?.user?.id;
+
+  const getAll = async (): Promise<WorkoutSession[]> => {
+    if (userId) {
+      const result = await getWorkoutSessionsAction({ userId });
+      if (result?.serverError) throw new Error(result.serverError);
+
+      const serverSessions = (result?.data?.sessions || []).map((session) => ({
+        ...session,
+        startedAt: session.startedAt instanceof Date ? session.startedAt.toISOString() : session.startedAt,
+        endedAt:
+          session.endedAt instanceof Date
+            ? session.endedAt.toISOString()
+            : typeof session.endedAt === "string"
+              ? session.endedAt
+              : undefined,
+        duration: nullToUndefined(session.duration),
+        exercises: session.exercises.map(({ exercise, order, sets }) => ({
+          ...exercise,
+          order,
+          sets: sets.map((set) => {
+            return {
+              ...set,
+              units: nullToUndefined(set.units),
+            };
+          }),
+        })),
+      }));
+      const localSessions = workoutSessionLocal.getAll().filter((s) => s.status !== "synced");
+
+      return [...localSessions, ...serverSessions].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
+    }
+
+    return workoutSessionLocal.getAll().sort((a, b) => {
+      const dateA = typeof a.startedAt === "string" ? new Date(a.startedAt) : a.startedAt;
+      const dateB = typeof b.startedAt === "string" ? new Date(b.startedAt) : b.startedAt;
+      return dateB.getTime() - dateA.getTime();
+    });
+  };
+
+  const add = async (session: WorkoutSession) => {
+    if (userId) {
+      // Utiliser l'action de synchronisation
+      const result = await syncWorkoutSessionAction({
+        session: {
+          ...session,
+          userId: "current-user-id", // TODO: passer le vrai userId
+          status: "synced",
+        },
+      });
+
+      if (result?.serverError) throw new Error(result.serverError);
+
+      if (result?.data?.data) {
+        workoutSessionLocal.markSynced(session.id, result.data.data.id);
+      }
+    }
+
+    return workoutSessionLocal.add(session);
+  };
+
+  const update = async (id: string, data: Partial<WorkoutSession>) => {
+    // if (userId) {
+    //   // TODO: Créer une action updateWorkoutSessionAction
+    //   const result = await updateWorkoutSessionAction({ id, data });
+    //   if (result.serverError) throw new Error(result.serverError);
+    // }
+    // return workoutSessionLocal.update(id, data);
+  };
+
+  const complete = async (id: string) => {
+    // const data = {
+    //   status: "completed" as const,
+    //   endedAt: new Date().toISOString(),
+    // };
+    // if (isUserLoggedIn()) {
+    //   const result = await completeWorkoutSessionAction({ id });
+    //   if (result.serverError) throw new Error(result.serverError);
+    // }
+    // return workoutSessionLocal.update(id, data);
+  };
+
+  const remove = async (id: string) => {
+    // if (isUserLoggedIn()) {
+    //   // TODO: Créer une action deleteWorkoutSessionAction
+    //   const result = await deleteWorkoutSessionAction({ id });
+    //   if (result.serverError) throw new Error(result.serverError);
+    // }
+    // workoutSessionLocal.remove(id);
+  };
+
+  return { getAll, add, update, complete, remove };
+};

+ 0 - 28
src/shared/lib/workout-session/workout-session.service.ts

@@ -1,28 +0,0 @@
-import { workoutSessionLocal } from "./workout-session.local";
-import { workoutSessionApi } from "./workout-session.api";
-
-import type { WorkoutSession } from "./types/workout-session";
-
-// TODO: replace with auth context
-function isUserLoggedIn(): boolean {
-  return !!localStorage.getItem("userToken");
-}
-
-export const workoutSessionService = {
-  getAll: async (): Promise<WorkoutSession[]> => {
-    if (isUserLoggedIn()) return workoutSessionApi.getAll();
-    return workoutSessionLocal.getAll();
-  },
-  add: async (session: WorkoutSession) => {
-    if (isUserLoggedIn()) return workoutSessionApi.create(session);
-    return workoutSessionLocal.add(session);
-  },
-  update: async (id: string, data: Partial<WorkoutSession>) => {
-    if (isUserLoggedIn()) return workoutSessionApi.update(id, data);
-    return workoutSessionLocal.update(id, data);
-  },
-  complete: async (id: string) => {
-    if (isUserLoggedIn()) return workoutSessionApi.complete(id);
-    return workoutSessionLocal.update(id, { status: "completed", endedAt: new Date().toISOString() });
-  },
-};

+ 1 - 0
src/shared/lib/workout-session/workout-session.sync.ts

@@ -5,6 +5,7 @@ import { workoutSessionApi } from "./workout-session.api";
 
 export async function syncLocalWorkoutSessions() {
   const localSessions = workoutSessionLocal.getAll().filter((s) => s.status !== "synced");
+
   for (const session of localSessions) {
     try {
       const { id: serverId } = await workoutSessionApi.create(session);

+ 29 - 64
src/shared/styles/globals.css

@@ -82,6 +82,10 @@
   @apply animate-pulse bg-gray-200 dark:bg-gray-700;
 }
 
+.dark code {
+  @apply text-black;
+}
+
 @layer components {
   .sidebar .nav-link {
     @apply mx-[2px] mt-1 flex items-center gap-2.5 border border-transparent px-5 py-2.5 text-sm font-medium leading-tight text-gray transition hover:text-black dark:text-gray-500 dark:hover:text-white;
@@ -221,71 +225,32 @@
   @apply ltr:lg:ml-[60px] rtl:lg:mr-[60px];
 }
 
-/* Text Editor */
-.quill {
-  @apply rounded-lg;
-}
-.ql-editor {
-  @apply max-h-[300px] overflow-y-auto !break-all text-sm !font-medium text-black dark:text-white;
-}
-.ql-editor.ql-editor::before {
-  @apply !text-sm/6 !font-medium !not-italic;
-}
-.quill .ql-container.ql-snow,
-.ql-toolbar.ql-snow {
-  @apply !border-0;
-}
-.quill .ql-toolbar.ql-snow {
-  @apply rounded-t-lg !border-b border-gray-300 bg-gray-200 p-0 dark:border-white/20 dark:bg-black/5;
-}
-.product-editor.quill .ql-editor {
-  @apply min-h-28 resize-y;
-}
-.quill .ql-toolbar.ql-snow .ql-formats {
-  @apply mr-0 space-x-2.5 border-r border-gray-300 p-2.5 dark:border-white/20;
-}
-.quill .ql-toolbar.ql-snow .ql-formats:last-child {
-  @apply border-0;
-}
-.ql-picker-label {
-  @apply p-0;
-}
-.ql-picker-label svg {
-  @apply rtl:!right-auto rtl:left-0;
+.bg-hero-light {
+  background-image:
+    radial-gradient(circle at 82% 60%, rgba(59, 59, 59, 0.06) 0%, rgba(59, 59, 59, 0.06) 69%, transparent 69%, transparent 100%),
+    radial-gradient(circle at 36% 0%, rgba(185, 185, 185, 0.06) 0%, rgba(185, 185, 185, 0.06) 59%, transparent 59%, transparent 100%),
+    radial-gradient(circle at 58% 82%, rgba(183, 183, 183, 0.06) 0%, rgba(183, 183, 183, 0.06) 17%, transparent 17%, transparent 100%),
+    radial-gradient(circle at 71% 32%, rgba(19, 19, 19, 0.06) 0%, rgba(19, 19, 19, 0.06) 40%, transparent 40%, transparent 100%),
+    radial-gradient(circle at 77% 5%, rgba(31, 31, 31, 0.06) 0%, rgba(31, 31, 31, 0.06) 52%, transparent 52%, transparent 100%),
+    radial-gradient(circle at 96% 80%, rgba(11, 11, 11, 0.06) 0%, rgba(11, 11, 11, 0.06) 73%, transparent 73%, transparent 100%),
+    radial-gradient(circle at 91% 59%, rgba(252, 252, 252, 0.06) 0%, rgba(252, 252, 252, 0.06) 44%, transparent 44%, transparent 100%),
+    radial-gradient(circle at 52% 82%, rgba(223, 223, 223, 0.06) 0%, rgba(223, 223, 223, 0.06) 87%, transparent 87%, transparent 100%),
+    radial-gradient(circle at 84% 89%, rgba(160, 160, 160, 0.06) 0%, rgba(160, 160, 160, 0.06) 57%, transparent 57%, transparent 100%),
+    linear-gradient(90deg, rgb(254, 242, 164), rgb(166, 255, 237));
 }
-.quill .ql-toolbar.ql-snow .ql-formats > button {
-  @apply grid size-5 place-content-center p-0;
-}
-.quill .ql-toolbar.ql-snow .ql-formats > button > svg {
-  @apply size-4;
-}
-.blog-editor.quill .ql-editor {
-  @apply min-h-64 resize-y;
-}
-.toggle-editor.quill .ql-editor {
-  @apply min-h-60 resize-none bg-white py-7 sm:min-h-32;
-}
-.ql-formats {
-  @apply h-10 border-b border-gray-300 sm:border-0;
-}
-.toggle-editor.quill .ql-editor {
-  @apply dark:bg-black-dark;
-}
-.ql-editor.ql-blank::before {
-  @apply !text-gray dark:!text-gray-600;
-}
-.ql-formats .ql-stroke,
-.ql-formats button {
-  @apply dark:!stroke-gray-600 dark:text-gray-600;
-}
-.ql-picker-item:hover {
-  @apply dark:!text-primary;
-}
-.ql-picker-options {
-  @apply dark:!border-gray-700/50 dark:!bg-black-dark;
-}
-.ql-formats button.ql-active {
-  @apply !text-primary;
+
+.bg-hero-dark {
+  background-image:
+    radial-gradient(circle at 82% 60%, rgba(80, 80, 120, 0.1) 0%, rgba(80, 80, 120, 0.1) 69%, transparent 69%, transparent 100%),
+    radial-gradient(circle at 36% 0%, rgba(40, 40, 60, 0.1) 0%, rgba(40, 40, 60, 0.1) 59%, transparent 59%, transparent 100%),
+    radial-gradient(circle at 58% 82%, rgba(60, 60, 100, 0.1) 0%, rgba(60, 60, 100, 0.1) 17%, transparent 17%, transparent 100%),
+    radial-gradient(circle at 71% 32%, rgba(19, 19, 40, 0.1) 0%, rgba(19, 19, 40, 0.1) 40%, transparent 40%, transparent 100%),
+    radial-gradient(circle at 77% 5%, rgba(31, 31, 60, 0.1) 0%, rgba(31, 31, 60, 0.1) 52%, transparent 52%, transparent 100%),
+    radial-gradient(circle at 96% 80%, rgba(11, 11, 30, 0.1) 0%, rgba(11, 11, 30, 0.1) 73%, transparent 73%, transparent 100%),
+    radial-gradient(circle at 91% 59%, rgba(80, 80, 120, 0.1) 0%, rgba(80, 80, 120, 0.1) 44%, transparent 44%, transparent 100%),
+    radial-gradient(circle at 52% 82%, rgba(60, 60, 100, 0.1) 0%, rgba(60, 60, 100, 0.1) 87%, transparent 87%, transparent 100%),
+    radial-gradient(circle at 84% 89%, rgba(80, 80, 120, 0.1) 0%, rgba(80, 80, 120, 0.1) 57%, transparent 57%, transparent 100%),
+    linear-gradient(90deg, #232324, #18181b);
 }
 
 @keyframes fade-in-up {

+ 1 - 1
tailwind.config.ts

@@ -1,7 +1,7 @@
 import type { Config } from "tailwindcss";
 
 const config: Config = {
-  darkMode: ["selector", "class"],
+  darkMode: "class",
   content: [
     "./src/**/*.{ts,tsx}",
     "./pages/**/*.{js,ts,jsx,tsx,mdx}",