diff --git a/app/Http/Controllers/Admin/ExerciseController.php b/app/Http/Controllers/Admin/ExerciseController.php
new file mode 100644
index 0000000..abaad48
--- /dev/null
+++ b/app/Http/Controllers/Admin/ExerciseController.php
@@ -0,0 +1,145 @@
+query('q', ''));
+ $exercises = Exercise::query()
+ ->when($q !== '', function ($qb) use ($q) {
+ $qb->where('name', 'like', "%{$q}%")
+ ->orWhere('slug', 'like', "%{$q}%");
+ })
+ ->orderBy('name')
+ ->paginate(20)
+ ->withQueryString();
+
+ return view('admin.exercises.index', compact('exercises', 'q'));
+ }
+
+ public function create(): View
+ {
+ return view('admin.exercises.create');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate([
+ 'name' => ['required', 'string', 'max:255'],
+ 'slug' => ['nullable', 'string', 'max:255', 'unique:exercises,slug'],
+ 'gif_url' => ['nullable', 'string', 'max:2048'],
+ 'poster_image' => ['nullable', 'string', 'max:2048'],
+ 'poster_image_file' => ['nullable', 'image', 'mimes:jpeg,jpg,png,webp,avif,gif', 'max:8192'],
+ 'instructions' => ['nullable', 'string'],
+ ]);
+
+ $name = trim($data['name']);
+ $slug = trim((string) ($data['slug'] ?? ''));
+ if ($slug === '') {
+ $slug = $this->uniqueSlug($name);
+ }
+
+ // Determine poster image value (uploaded file takes precedence)
+ $posterImageValue = $data['poster_image'] ?? null;
+ if ($request->hasFile('poster_image_file')) {
+ $path = $request->file('poster_image_file')->store('exercises/posters', 'public');
+ $posterImageValue = $path; // store relative path; use Storage::url to display
+ }
+
+ Exercise::create([
+ 'name' => $name,
+ 'slug' => $slug,
+ 'gif_url' => $data['gif_url'] ?? null,
+ 'poster_image' => $posterImageValue,
+ 'instructions' => $data['instructions'] ?? null,
+ ]);
+
+ return redirect()->route('admin.exercises.index')->with('status', 'Exercise created');
+ }
+
+ public function edit(Exercise $exercise): View
+ {
+ return view('admin.exercises.edit', compact('exercise'));
+ }
+
+ public function update(Request $request, Exercise $exercise): RedirectResponse
+ {
+ $data = $request->validate([
+ 'name' => ['required', 'string', 'max:255'],
+ 'slug' => ['nullable', 'string', 'max:255', 'unique:exercises,slug,' . $exercise->id],
+ 'gif_url' => ['nullable', 'string', 'max:2048'],
+ 'poster_image' => ['nullable', 'string', 'max:2048'],
+ 'poster_image_file' => ['nullable', 'image', 'mimes:jpeg,jpg,png,webp,avif,gif', 'max:8192'],
+ 'instructions' => ['nullable', 'string'],
+ ]);
+
+ $name = trim($data['name']);
+ $slug = trim((string) ($data['slug'] ?? ''));
+ if ($slug === '') {
+ // only (re)generate if empty
+ $slug = $this->uniqueSlug($name, $exercise->id);
+ }
+
+ // Determine poster image value for update (uploaded file > provided URL > keep existing)
+ $posterImageValue = $data['poster_image'] ?? $exercise->poster_image;
+ if ($request->hasFile('poster_image_file')) {
+ $path = $request->file('poster_image_file')->store('exercises/posters', 'public');
+ $posterImageValue = $path;
+ }
+
+ $exercise->update([
+ 'name' => $name,
+ 'slug' => $slug,
+ 'gif_url' => $data['gif_url'] ?? null,
+ 'poster_image' => $posterImageValue,
+ 'instructions' => $data['instructions'] ?? null,
+ ]);
+
+ return redirect()->route('admin.exercises.index')->with('status', 'Exercise updated');
+ }
+
+ public function destroy(Exercise $exercise): RedirectResponse
+ {
+ // Safely remove related data to avoid FK constraint errors
+ DB::transaction(function () use ($exercise) {
+ // Detach pivot relations if defined
+ try { $exercise->bodyParts()->detach(); } catch (\Throwable $e) {}
+ try { $exercise->targetMuscles()->detach(); } catch (\Throwable $e) {}
+ try { $exercise->secondaryMuscles()->detach(); } catch (\Throwable $e) {}
+ try { $exercise->equipments()->detach(); } catch (\Throwable $e) {}
+ try { $exercise->favoritedBy()->detach(); } catch (\Throwable $e) {}
+ // Delete aliases and workout_exercises if relations exist
+ try { $exercise->aliases()->delete(); } catch (\Throwable $e) {}
+ try { $exercise->workoutExercises()->delete(); } catch (\Throwable $e) {}
+
+ $exercise->delete();
+ });
+
+ return redirect()->route('admin.exercises.index')->with('status', 'Exercise deleted');
+ }
+
+ protected function uniqueSlug(string $name, ?int $ignoreId = null): string
+ {
+ $base = Str::slug($name);
+ if ($base === '') { $base = 'exercise'; }
+ $slug = $base;
+ $i = 1;
+ while (Exercise::where('slug', $slug)
+ ->when($ignoreId, fn($q)=>$q->where('id','!=',$ignoreId))
+ ->exists()) {
+ $slug = $base . '-' . (++$i);
+ }
+ return $slug;
+ }
+}
diff --git a/app/Http/Controllers/AdminDashboardController.php b/app/Http/Controllers/AdminDashboardController.php
new file mode 100644
index 0000000..c8e0e70
--- /dev/null
+++ b/app/Http/Controllers/AdminDashboardController.php
@@ -0,0 +1,29 @@
+ User::count(),
+ 'workouts' => Workout::count(),
+ 'sessions' => WorkoutSession::count(),
+ 'exercises' => Exercise::count(),
+ 'yogaPoses' => YogaPose::count(),
+ ];
+
+ $recentUsers = User::latest()->limit(5)->get(['id','name','email','created_at']);
+ $recentWorkouts = Workout::latest()->limit(5)->get(['id','title','created_at']);
+
+ return view('admin.dashboard', compact('metrics','recentUsers','recentWorkouts'));
+ }
+}
diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php
index 845c0b2..27ca077 100644
--- a/app/Http/Controllers/Auth/RegisteredUserController.php
+++ b/app/Http/Controllers/Auth/RegisteredUserController.php
@@ -33,6 +33,7 @@ class RegisteredUserController extends Controller
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
+ 'is_trainer' => ['nullable', 'boolean'],
// Fitness profile (all optional to avoid breaking tests unless specified)
'age' => ['nullable', 'integer', 'min:5', 'max:120'],
'weight_kg' => ['nullable', 'numeric', 'min:20', 'max:400'],
@@ -49,6 +50,7 @@ class RegisteredUserController extends Controller
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
+ 'is_trainer' => (bool) $request->boolean('is_trainer'),
'age' => $request->age,
'weight_kg' => $request->weight_kg,
'height_cm' => $request->height_cm,
diff --git a/app/Http/Middleware/Admin.php b/app/Http/Middleware/Admin.php
new file mode 100644
index 0000000..d66f223
--- /dev/null
+++ b/app/Http/Middleware/Admin.php
@@ -0,0 +1,25 @@
+is_admin) {
+ abort(403);
+ }
+ return $next($request);
+ }
+}
diff --git a/app/Models/Exercise.php b/app/Models/Exercise.php
index 5758fa1..08f989d 100644
--- a/app/Models/Exercise.php
+++ b/app/Models/Exercise.php
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Storage;
class Exercise extends Model
{
@@ -12,6 +13,7 @@ class Exercise extends Model
'slug',
'exercise_db_id',
'gif_url',
+ 'poster_image',
'instructions',
];
@@ -66,4 +68,16 @@ class Exercise extends Model
$alias = $this->aliases()->where('user_id', $userId)->first();
return $alias?->name ?: $name;
}
+
+ public function getPosterUrlAttribute(): ?string
+ {
+ $poster = (string)($this->attributes['poster_image'] ?? '');
+ if ($poster === '') {
+ return null;
+ }
+ if (preg_match('/^https?:\/\//i', $poster)) {
+ return $poster;
+ }
+ return Storage::url($poster);
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 721c610..254663c 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -30,6 +30,7 @@ class User extends Authenticatable
'available_equipment',
'days_per_week',
'minutes_per_session',
+ 'is_trainer',
];
/**
@@ -57,6 +58,8 @@ class User extends Authenticatable
'height_cm' => 'decimal:2',
'days_per_week' => 'integer',
'minutes_per_session' => 'integer',
+ 'is_admin' => 'boolean',
+ 'is_trainer' => 'boolean',
];
}
diff --git a/app/Models/WorkoutSessionItem.php b/app/Models/WorkoutSessionItem.php
index 6fa71f2..4382b6a 100644
--- a/app/Models/WorkoutSessionItem.php
+++ b/app/Models/WorkoutSessionItem.php
@@ -14,10 +14,14 @@ class WorkoutSessionItem extends Model
'session_id',
'workout_exercise_id',
'done',
+ 'exercise_ms',
+ 'rest_ms',
];
protected $casts = [
'done' => 'boolean',
+ 'exercise_ms' => 'integer',
+ 'rest_ms' => 'integer',
];
public function session(): BelongsTo
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 0ddfb23..8e56a9d 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -15,6 +15,9 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web([
SetLocale::class,
]);
+ $middleware->alias([
+ 'admin' => \App\Http\Middleware\Admin::class,
+ ]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
diff --git a/database/migrations/2025_10_04_210000_add_is_admin_to_users_table.php b/database/migrations/2025_10_04_210000_add_is_admin_to_users_table.php
new file mode 100644
index 0000000..39f123d
--- /dev/null
+++ b/database/migrations/2025_10_04_210000_add_is_admin_to_users_table.php
@@ -0,0 +1,23 @@
+boolean('is_admin')->default(false)->after('minutes_per_session');
+ $table->index('is_admin');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropIndex(['is_admin']);
+ $table->dropColumn('is_admin');
+ });
+ }
+};
diff --git a/database/migrations/2025_10_05_000000_add_is_trainer_to_users_table.php b/database/migrations/2025_10_05_000000_add_is_trainer_to_users_table.php
new file mode 100644
index 0000000..1042095
--- /dev/null
+++ b/database/migrations/2025_10_05_000000_add_is_trainer_to_users_table.php
@@ -0,0 +1,21 @@
+boolean('is_trainer')->default(false)->after('minutes_per_session');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('is_trainer');
+ });
+ }
+};
diff --git a/database/migrations/2025_10_05_003500_add_timer_columns_to_workout_session_items_table.php b/database/migrations/2025_10_05_003500_add_timer_columns_to_workout_session_items_table.php
new file mode 100644
index 0000000..294818e
--- /dev/null
+++ b/database/migrations/2025_10_05_003500_add_timer_columns_to_workout_session_items_table.php
@@ -0,0 +1,24 @@
+unsignedBigInteger('exercise_ms')->default(0)->after('done');
+ $table->unsignedBigInteger('rest_ms')->default(0)->after('exercise_ms');
+ $table->index(['session_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('workout_session_items', function (Blueprint $table) {
+ $table->dropIndex(['session_id']);
+ $table->dropColumn(['exercise_ms', 'rest_ms']);
+ });
+ }
+};
diff --git a/database/migrations/2025_10_05_175800_add_poster_image_to_exercises_table.php b/database/migrations/2025_10_05_175800_add_poster_image_to_exercises_table.php
new file mode 100644
index 0000000..fe51c68
--- /dev/null
+++ b/database/migrations/2025_10_05_175800_add_poster_image_to_exercises_table.php
@@ -0,0 +1,28 @@
+string('poster_image', 2048)->nullable()->after('gif_url');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('exercises', function (Blueprint $table) {
+ $table->dropColumn('poster_image');
+ });
+ }
+};
diff --git a/lang/en/app.php b/lang/en/app.php
index 5cb4c9e..fbf2284 100644
--- a/lang/en/app.php
+++ b/lang/en/app.php
@@ -8,6 +8,7 @@ return [
'blog' => 'Blog',
'build_workout' => 'Build a Workout',
'dashboard' => 'Dashboard',
+ 'admin' => 'Admin',
'profile' => 'Profile',
'logout' => 'Log Out',
'login' => 'Log in',
@@ -209,7 +210,14 @@ return [
'sets' => 'sets',
'reps' => 'reps',
'rest' => 'rest',
- 'completed' => 'Workout session completed! Great job!'
+ 'completed' => 'Workout session completed! Great job!',
+ 'exercise_timer' => 'Exercise timer',
+ 'start_timer' => 'Start',
+ 'stop_timer' => 'Stop',
+ 'rest_timer' => 'Rest timer',
+ 'start_rest' => 'Start',
+ 'pause_rest' => 'Pause',
+ 'reset_rest' => 'Reset'
],
'dashboard' => [
@@ -229,6 +237,23 @@ return [
'activity_30d_help' => 'Number of sessions per day (higher is better).',
],
+ 'admin' => [
+ 'title' => 'Admin Dashboard',
+ 'subtitle' => 'Manage content and see overall site statistics.',
+ 'metrics' => [
+ 'users' => 'Users',
+ 'workouts' => 'Workouts',
+ 'sessions' => 'Sessions',
+ 'exercises' => 'Exercises',
+ 'yoga_poses' => 'Yoga poses',
+ ],
+ 'recent_users' => 'Recent users',
+ 'recent_workouts' => 'Recent workouts',
+ 'none' => 'Nothing to show yet.',
+ 'manage_workouts' => 'Manage workouts',
+
+ ],
+
'yoga' => [
'browse' => 'Browse Yoga Poses',
'browse_desc' => 'Explore yoga poses by category or benefits.',
@@ -290,6 +315,7 @@ return [
'back' => 'Back',
'register' => 'Register',
],
+ 'is_trainer' => 'I am a trainer',
'equipment_options' => [
'dumbbells' => 'Dumbbells',
'barbell' => 'Barbell',
diff --git a/lang/ro/app.php b/lang/ro/app.php
index 106b9d5..e3932fb 100644
--- a/lang/ro/app.php
+++ b/lang/ro/app.php
@@ -8,6 +8,7 @@ return [
'blog' => 'Blog',
'build_workout' => 'Creează un antrenament',
'dashboard' => 'Tabloul de bord',
+ 'admin' => 'Admin',
'profile' => 'Profil',
'logout' => 'Deconectare',
'login' => 'Autentificare',
@@ -209,7 +210,14 @@ return [
'sets' => 'seturi',
'reps' => 'repetări',
'rest' => 'pauză',
- 'completed' => 'Sesiune de antrenament finalizată! Bravo!'
+ 'completed' => 'Sesiune de antrenament finalizată! Bravo!',
+ 'exercise_timer' => 'Cronometru exercițiu',
+ 'start_timer' => 'Start',
+ 'stop_timer' => 'Stop',
+ 'rest_timer' => 'Cronometru pauză',
+ 'start_rest' => 'Start',
+ 'pause_rest' => 'Pauză',
+ 'reset_rest' => 'Reset'
],
'dashboard' => [
@@ -222,11 +230,27 @@ return [
'completion_rate' => 'Rată de finalizare',
],
'recent_sessions' => 'Sesiuni recente',
- 'no_sessions' => 'Nu există sesiuni încă. Începe un antrenament pentru a-ți urmări progresul.',
+ 'no_sessions' => 'Încă nu există sesiuni. Începe un antrenament pentru a-ți urmări progresul.',
'completed' => 'Finalizată',
'view_session' => 'Vezi sesiunea',
'activity_30d' => 'Activitate în ultimele 30 de zile',
- 'activity_30d_help' => 'Numărul de sesiuni pe zi (mai mult este mai bine).',
+ 'activity_30d_help' => 'Număr de sesiuni pe zi (mai mult este mai bine).',
+ ],
+
+ 'admin' => [
+ 'title' => 'Panou de administrare',
+ 'subtitle' => 'Gestionează conținutul și vezi statisticile generale ale site‑ului.',
+ 'metrics' => [
+ 'users' => 'Utilizatori',
+ 'workouts' => 'Antrenamente',
+ 'sessions' => 'Sesiuni',
+ 'exercises' => 'Exerciții',
+ 'yoga_poses' => 'Poziții yoga',
+ ],
+ 'recent_users' => 'Utilizatori recenți',
+ 'recent_workouts' => 'Antrenamente recente',
+ 'none' => 'Nimic de afișat momentan.',
+ 'manage_workouts' => 'Gestionează antrenamentele',
],
'yoga' => [
@@ -290,6 +314,7 @@ return [
'back' => 'Înapoi',
'register' => 'Înregistrare',
],
+ 'is_trainer' => 'Sunt antrenor',
'equipment_options' => [
'dumbbells' => 'Gantere',
'barbell' => 'Bară',
diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php
new file mode 100644
index 0000000..c3e12ae
--- /dev/null
+++ b/resources/views/admin/dashboard.blade.php
@@ -0,0 +1,80 @@
+ {{ __('app.admin.subtitle') }}{{ __('app.admin.title') }}
+ {{ __('app.admin.recent_users') }}
+
+ @foreach($recentUsers as $u)
+
+ @endif
+ {{ __('app.admin.recent_workouts') }}
+ @if($recentWorkouts->isEmpty())
+
+ @foreach($recentWorkouts as $w)
+
+ @endif
+
+
Leave empty to auto-generate from the name.
+Upload a new image (JPG, PNG, WebP, up to 8MB). If provided, it will override the URL below.
+Add a new exercise to the library.
+Update exercise details.
+Create, edit, and delete exercises in the library.
+| Name | +Slug | +GIF | +Actions | +
|---|---|---|---|
| {{ $ex->name }} | +{{ $ex->slug }} | ++ @if($ex->gif_url) + {{ \Illuminate\Support\Str::limit($ex->gif_url, 40) }} + @else + — + @endif + | ++ Edit + + | +
| No exercises found. | |||