From beeda8139b3c41f2a213c4c485ad5a22270f40a0 Mon Sep 17 00:00:00 2001 From: DoarMihai Date: Mon, 6 Oct 2025 22:35:04 +0300 Subject: [PATCH] some progress --- .../Controllers/Admin/ExerciseController.php | 145 ++++++++++++++++++ .../Controllers/AdminDashboardController.php | 29 ++++ .../Auth/RegisteredUserController.php | 2 + app/Http/Middleware/Admin.php | 25 +++ app/Models/Exercise.php | 14 ++ app/Models/User.php | 3 + app/Models/WorkoutSessionItem.php | 4 + bootstrap/app.php | 3 + ..._04_210000_add_is_admin_to_users_table.php | 23 +++ ...5_000000_add_is_trainer_to_users_table.php | 21 +++ ...columns_to_workout_session_items_table.php | 24 +++ ...00_add_poster_image_to_exercises_table.php | 28 ++++ lang/en/app.php | 28 +++- lang/ro/app.php | 31 +++- resources/views/admin/dashboard.blade.php | 80 ++++++++++ .../views/admin/exercises/_form.blade.php | 53 +++++++ .../views/admin/exercises/create.blade.php | 23 +++ .../views/admin/exercises/edit.blade.php | 24 +++ .../views/admin/exercises/index.blade.php | 72 +++++++++ resources/views/auth/register.blade.php | 9 ++ resources/views/exercises/index.blade.php | 2 +- resources/views/exercises/show.blade.php | 6 +- resources/views/layouts/navigation.blade.php | 6 + resources/views/workouts/session.blade.php | 140 ++++++++++++++--- routes/web.php | 7 + 25 files changed, 770 insertions(+), 32 deletions(-) create mode 100644 app/Http/Controllers/Admin/ExerciseController.php create mode 100644 app/Http/Controllers/AdminDashboardController.php create mode 100644 app/Http/Middleware/Admin.php create mode 100644 database/migrations/2025_10_04_210000_add_is_admin_to_users_table.php create mode 100644 database/migrations/2025_10_05_000000_add_is_trainer_to_users_table.php create mode 100644 database/migrations/2025_10_05_003500_add_timer_columns_to_workout_session_items_table.php create mode 100644 database/migrations/2025_10_05_175800_add_poster_image_to_exercises_table.php create mode 100644 resources/views/admin/dashboard.blade.php create mode 100644 resources/views/admin/exercises/_form.blade.php create mode 100644 resources/views/admin/exercises/create.blade.php create mode 100644 resources/views/admin/exercises/edit.blade.php create mode 100644 resources/views/admin/exercises/index.blade.php 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.title') }}

+

{{ __('app.admin.subtitle') }}

+
+
+
+
+ +
+
+
+
{{ __('app.admin.metrics.users') }}
+
{{ $metrics['users'] }}
+
+
+
{{ __('app.admin.metrics.workouts') }}
+
{{ $metrics['workouts'] }}
+
+
+
{{ __('app.admin.metrics.sessions') }}
+
{{ $metrics['sessions'] }}
+
+
+
{{ __('app.admin.metrics.exercises') }}
+
{{ $metrics['exercises'] }}
+
+
+
{{ __('app.admin.metrics.yoga_poses') }}
+
{{ $metrics['yogaPoses'] }}
+
+
+ +
+
+
+

{{ __('app.admin.recent_users') }}

+
+ @if($recentUsers->isEmpty()) +
{{ __('app.admin.none') }}
+ @else +
    + @foreach($recentUsers as $u) +
  • +
    +
    {{ $u->name }}
    +
    {{ $u->email }}
    +
    +
    {{ $u->created_at->diffForHumans() }}
    +
  • + @endforeach +
+ @endif +
+ +
+

{{ __('app.admin.recent_workouts') }}

+ @if($recentWorkouts->isEmpty()) +
{{ __('app.admin.none') }}
+ @else +
    + @foreach($recentWorkouts as $w) +
  • +
    {{ $w->title }}
    +
    {{ $w->created_at->diffForHumans() }}
    +
  • + @endforeach +
+ @endif + +
+
+
+
diff --git a/resources/views/admin/exercises/_form.blade.php b/resources/views/admin/exercises/_form.blade.php new file mode 100644 index 0000000..d4a2698 --- /dev/null +++ b/resources/views/admin/exercises/_form.blade.php @@ -0,0 +1,53 @@ +@csrf +
+
+ + + +
+
+ + +

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.

+ +
+
+ + + +
+
+ @php + $poster = old('poster_image', $exercise->poster_image ?? ''); + $isHttp = is_string($poster) && preg_match('/^https?:\/\//i', $poster); + $src = $isHttp ? $poster : ($poster ? \Illuminate\Support\Facades\Storage::url($poster) : ''); + @endphp + @if(!empty($src)) +
+ Poster preview +
+ @endif +
+
+ + + +
+
+
+ Cancel + +
diff --git a/resources/views/admin/exercises/create.blade.php b/resources/views/admin/exercises/create.blade.php new file mode 100644 index 0000000..0929d55 --- /dev/null +++ b/resources/views/admin/exercises/create.blade.php @@ -0,0 +1,23 @@ + +
+
+

New Exercise

+

Add a new exercise to the library.

+
+
+ +
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+ @include('admin.exercises._form') +
+
+
diff --git a/resources/views/admin/exercises/edit.blade.php b/resources/views/admin/exercises/edit.blade.php new file mode 100644 index 0000000..8aafd25 --- /dev/null +++ b/resources/views/admin/exercises/edit.blade.php @@ -0,0 +1,24 @@ + +
+
+

Edit Exercise

+

Update exercise details.

+
+
+ +
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+ @method('PATCH') + @include('admin.exercises._form') +
+
+
diff --git a/resources/views/admin/exercises/index.blade.php b/resources/views/admin/exercises/index.blade.php new file mode 100644 index 0000000..b7af321 --- /dev/null +++ b/resources/views/admin/exercises/index.blade.php @@ -0,0 +1,72 @@ + +
+
+
+
+

Manage Exercises

+

Create, edit, and delete exercises in the library.

+
+ New Exercise +
+
+
+ +
+ @if(session('status')) +
{{ session('status') }}
+ @endif + +
+
+ + +
+
+ +
+
+ +
+
+ + + + + + + + + + + @forelse($exercises as $ex) + + + + + + + @empty + + @endforelse + +
NameSlugGIFActions
{{ $ex->name }}{{ $ex->slug }} + @if($ex->gif_url) + {{ \Illuminate\Support\Str::limit($ex->gif_url, 40) }} + @else + — + @endif + + Edit +
+ @csrf + @method('DELETE') + +
+
No exercises found.
+
+
+ {{ $exercises->links() }} +
+
+
+
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 8a87a92..5074f0a 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -45,6 +45,15 @@ + +
+
+ + +
+ +
+
{{ __('Already registered?') }} diff --git a/resources/views/exercises/index.blade.php b/resources/views/exercises/index.blade.php index 4121e42..b32cb0c 100644 --- a/resources/views/exercises/index.blade.php +++ b/resources/views/exercises/index.blade.php @@ -90,7 +90,7 @@ @foreach($exercises as $exercise)
- {{ $exercise->display_name }} + {{ $exercise->display_name }}
{{ $exercise->display_name }}
diff --git a/resources/views/exercises/show.blade.php b/resources/views/exercises/show.blade.php index e66685a..41dcac3 100644 --- a/resources/views/exercises/show.blade.php +++ b/resources/views/exercises/show.blade.php @@ -1,7 +1,7 @@
- {{ $exercise->display_name }} + {{ $exercise->display_name }}
@@ -61,7 +61,7 @@ diff --git a/resources/views/workouts/session.blade.php b/resources/views/workouts/session.blade.php index 8a8a999..b1f3d84 100644 --- a/resources/views/workouts/session.blade.php +++ b/resources/views/workouts/session.blade.php @@ -23,31 +23,58 @@
    @foreach($workout->items as $item) @php $si = $doneByItemId->get($item->id); $isDone = $si && $si->done; @endphp -
  • -
    -
    - @csrf - @method('PATCH') - -
    -
    -
    {{ $item->exercise->display_name }}
    -
    - @if($item->sets) {{ $item->sets }} {{ __('app.sessions.sets') }} @endif - @if($item->reps) · {{ $item->reps }} {{ __('app.sessions.reps') }} @endif - @if($item->weight) · {{ $item->weight }} kg @endif - @if($item->rest_seconds) · {{ $item->rest_seconds }}s {{ __('app.sessions.rest') }} @endif +
  • +
    +
    +
    + @csrf + @method('PATCH') + +
    +
    +
    {{ $item->exercise->display_name }}
    +
    + @if($item->sets) {{ $item->sets }} {{ __('app.sessions.sets') }} @endif + @if($item->reps) · {{ $item->reps }} {{ __('app.sessions.reps') }} @endif + @if($item->weight) · {{ $item->weight }} kg @endif + @if($item->rest_seconds) · {{ $item->rest_seconds }}s {{ __('app.sessions.rest') }} @endif +
    + @if($item->notes) +
    {{ $item->notes }}
    + @endif + + +
    + +
    +
    +
    {{ __('app.sessions.exercise_timer') }}
    +
    00:00
    +
    + +
    + + +
    +
    +
    {{ __('app.sessions.rest_timer') }}
    +
    00:00
    +
    +
    + + + +
    +
    +
    - @if($item->notes) -
    {{ $item->notes }}
    - @endif
  • @@ -119,6 +146,71 @@ alert('Failed to update. Please try again.'); }); }); + + // --- Timers --- + function pad(n){ return n < 10 ? '0'+n : ''+n; } + function fmt(ms){ ms = Math.max(0, ms|0); const s = Math.floor(ms/1000); const m = Math.floor(s/60); const rs = s%60; return pad(m)+':'+pad(rs); } + + function storageGet(key){ try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch(e){ return null; } } + function storageSet(key, val){ try { localStorage.setItem(key, JSON.stringify(val)); } catch(e){} } + + const intervals = new Map(); // key -> interval id + + function setupExerciseTimer(row){ + const sessionId = row.getAttribute('data-session-id'); + const itemId = row.getAttribute('data-item-id'); + const key = `sess:${sessionId}:item:${itemId}:ex`; + const display = row.querySelector('.exercise-timer-display'); + const toggle = row.querySelector('.exercise-timer-toggle'); + if (!display || !toggle) return; + + let state = storageGet(key) || { running:false, elapsedMs:0, startedAt: null }; + + function render(){ display.textContent = fmt(state.running ? (Date.now() - (state.startedAt||Date.now()) + (state.elapsedMs||0)) : (state.elapsedMs||0)); toggle.textContent = state.running ? (toggle.getAttribute('data-stop-label') || '{{ __('app.sessions.stop_timer') }}') : (toggle.getAttribute('data-start-label') || '{{ __('app.sessions.start_timer') }}'); } + + function start(){ if (state.running) return; state.running = true; state.startedAt = Date.now(); storageSet(key, state); render(); const iid = setInterval(render, 500); intervals.set(key, iid); } + function stop(){ if (!state.running) return; state.elapsedMs = (Date.now() - (state.startedAt||Date.now())) + (state.elapsedMs||0); state.running = false; state.startedAt = null; storageSet(key, state); render(); const iid = intervals.get(key); if (iid) { clearInterval(iid); intervals.delete(key); } } + + toggle.addEventListener('click', function(){ state.running ? stop() : start(); }); + + // resume + if (state.running && state.startedAt){ const iid = setInterval(render, 500); intervals.set(key, iid); } + render(); + } + + function setupRestTimer(row){ + const sessionId = row.getAttribute('data-session-id'); + const itemId = row.getAttribute('data-item-id'); + const defSeconds = parseInt(row.getAttribute('data-rest-seconds')||'60', 10); + const key = `sess:${sessionId}:item:${itemId}:rest`; + const display = row.querySelector('.rest-timer-display'); + const btnStart = row.querySelector('.rest-start'); + const btnPause = row.querySelector('.rest-pause'); + const btnReset = row.querySelector('.rest-reset'); + if (!display || !btnStart || !btnPause || !btnReset) return; + + let state = storageGet(key) || { running:false, remainingMs: defSeconds*1000, targetAt: null, defaultMs: defSeconds*1000 }; + + function render(){ const ms = state.running ? Math.max(0, (state.targetAt||Date.now()) - Date.now()) : (state.remainingMs||0); display.textContent = fmt(ms); if (ms <= 0 && state.running){ // auto-stop when finished + state.running = false; state.remainingMs = 0; state.targetAt = null; storageSet(key, state); const iid = intervals.get(key); if (iid) { clearInterval(iid); intervals.delete(key); } try{ display.classList.add('text-emerald-600'); setTimeout(()=>display.classList.remove('text-emerald-600'), 2000);}catch(e){} + } } + function start(){ if (state.running) return; const ms = (state.remainingMs==null ? state.defaultMs : state.remainingMs); state.running = true; state.targetAt = Date.now() + Math.max(0, ms); storageSet(key, state); render(); const iid = setInterval(render, 500); intervals.set(key, iid); } + function pause(){ if (!state.running) return; state.remainingMs = Math.max(0, (state.targetAt||Date.now()) - Date.now()); state.running = false; state.targetAt = null; storageSet(key, state); render(); const iid = intervals.get(key); if (iid) { clearInterval(iid); intervals.delete(key); } } + function reset(){ state.running = false; state.remainingMs = state.defaultMs; state.targetAt = null; storageSet(key, state); render(); const iid = intervals.get(key); if (iid) { clearInterval(iid); intervals.delete(key); } } + + btnStart.addEventListener('click', start); + btnPause.addEventListener('click', pause); + btnReset.addEventListener('click', reset); + + // resume + if (state.running && state.targetAt){ const iid = setInterval(render, 500); intervals.set(key, iid); } + render(); + } + + document.querySelectorAll('li[data-session-id][data-item-id]').forEach(function(row){ + setupExerciseTimer(row); + setupRestTimer(row); + }); })(); @endpush diff --git a/routes/web.php b/routes/web.php index 2f3cbd4..a412394 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,13 @@ Route::get('/', [HomeController::class, 'index'])->name('home'); Route::get('/dashboard', [\App\Http\Controllers\DashboardController::class, 'index']) ->middleware(['auth', 'verified'])->name('dashboard'); +// Admin routes +Route::prefix('admin')->middleware(['auth','admin'])->group(function(){ + Route::get('/', [\App\Http\Controllers\AdminDashboardController::class, 'index'])->name('admin.dashboard'); + // Admin Exercises CRUD + Route::resource('exercises', \App\Http\Controllers\Admin\ExerciseController::class)->except(['show'])->names('admin.exercises'); +}); + Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');