From e74aa2bda4ce1165610ee430cc3ad2a659704aa2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 12:39:16 +0300 Subject: [PATCH] Add a community workout library Workouts were private to their owner with no way to browse or copy another user's - FavoriteController::toggleWorkout even had a comment anticipating sharing ("users can still favorite others' workouts if shared later") that was never followed up on. Adds an is_public flag workouts can be toggled to from their own page, a /workouts/community browse+search page, and a copy action that duplicates a public workout (including all its exercises/sets/reps) into the copier's own library. Making this work required splitting workout authorization into two tiers: authorizeView() (owner, the assigning trainer, or anyone if the workout is public - read-only) and the existing authorizeWorkout() (owner or assigning trainer - can add/edit/remove exercises). Deletion stays strictly owner-only as before. The show page now hides the "Add Exercise" panel and per-exercise edit/remove controls for viewers who can see but not manage a workout. Found and fixed a real Blade compiler quirk via live testing: `@if` directly after a word character with no space (`exercises@if(...)`) doesn't compile - Blade's directive regex treats it as email-like text and leaves it literal - while the matching `@endif` still compiles, producing invalid mismatched PHP. Fixed by adding a space. Co-Authored-By: Claude Sonnet 5 --- app/Http/Controllers/WorkoutController.php | 72 +++++++- app/Models/Workout.php | 2 + ...093228_add_is_public_to_workouts_table.php | 30 ++++ resources/views/workouts/community.blade.php | 36 ++++ resources/views/workouts/index.blade.php | 5 +- resources/views/workouts/show.blade.php | 74 ++++++--- routes/web.php | 3 + tests/Feature/WorkoutCommunityTest.php | 157 ++++++++++++++++++ 8 files changed, 351 insertions(+), 28 deletions(-) create mode 100644 database/migrations/2026_08_10_093228_add_is_public_to_workouts_table.php create mode 100644 resources/views/workouts/community.blade.php create mode 100644 tests/Feature/WorkoutCommunityTest.php diff --git a/app/Http/Controllers/WorkoutController.php b/app/Http/Controllers/WorkoutController.php index cb6a8c5..5744f7d 100644 --- a/app/Http/Controllers/WorkoutController.php +++ b/app/Http/Controllers/WorkoutController.php @@ -21,6 +21,64 @@ class WorkoutController extends Controller return view('workouts.index', compact('workouts')); } + public function community(Request $request) + { + $q = trim((string) $request->query('q', '')); + + $workouts = Workout::query() + ->where('is_public', true) + ->with(['user:id,name', 'items']) + ->when($q !== '', fn ($qb) => $qb->where('title', 'like', "%{$q}%")) + ->latest() + ->paginate(12) + ->withQueryString(); + + return view('workouts.community', [ + 'workouts' => $workouts, + 'filters' => ['q' => $q], + ]); + } + + public function togglePublic(Workout $workout) + { + if ($workout->user_id !== Auth::id()) { + abort(403); + } + $workout->is_public = !$workout->is_public; + $workout->save(); + + return back()->with('status', $workout->is_public ? 'Workout is now public' : 'Workout is now private'); + } + + public function copy(Workout $workout) + { + if (!$workout->is_public && $workout->user_id !== Auth::id()) { + abort(403); + } + + $workout->loadMissing('items'); + + $copy = Auth::user()->workouts()->create([ + 'title' => $workout->title . ' (copy)', + 'notes' => $workout->notes, + 'estimated_minutes' => $workout->estimated_minutes, + ]); + + foreach ($workout->items as $item) { + $copy->items()->create([ + 'exercise_id' => $item->exercise_id, + 'position' => $item->position, + 'sets' => $item->sets, + 'reps' => $item->reps, + 'weight' => $item->weight, + 'rest_seconds' => $item->rest_seconds, + 'notes' => $item->notes, + ]); + } + + return redirect()->route('workouts.show', $copy)->with('status', 'Workout copied to your library'); + } + public function create() { // Simple create form (builder-like) @@ -40,8 +98,8 @@ class WorkoutController extends Controller public function show(Workout $workout) { - $this->authorizeWorkout($workout); - $workout->load(['items.exercise', 'assignedByTrainer']); + $this->authorizeView($workout); + $workout->load(['items.exercise', 'assignedByTrainer', 'user:id,name']); $exercises = Exercise::orderBy('name')->limit(20)->get(['id','name','slug']); return view('workouts.show', compact('workout', 'exercises')); } @@ -133,4 +191,14 @@ class WorkoutController extends Controller } abort(403); } + + // Read-only: anyone can view a public workout (to decide whether to copy it), + // in addition to everyone authorizeWorkout() already allows. + protected function authorizeView(Workout $workout): void + { + if ($workout->is_public) { + return; + } + $this->authorizeWorkout($workout); + } } diff --git a/app/Models/Workout.php b/app/Models/Workout.php index ba82263..9a33a43 100644 --- a/app/Models/Workout.php +++ b/app/Models/Workout.php @@ -21,12 +21,14 @@ class Workout extends Model 'week_number', 'day_number', 'assigned_by_trainer_id', + 'is_public', ]; protected $casts = [ 'estimated_minutes' => 'integer', 'week_number' => 'integer', 'day_number' => 'integer', + 'is_public' => 'boolean', ]; public function user(): BelongsTo diff --git a/database/migrations/2026_08_10_093228_add_is_public_to_workouts_table.php b/database/migrations/2026_08_10_093228_add_is_public_to_workouts_table.php new file mode 100644 index 0000000..6527e9c --- /dev/null +++ b/database/migrations/2026_08_10_093228_add_is_public_to_workouts_table.php @@ -0,0 +1,30 @@ +boolean('is_public')->default(false)->after('title'); + $table->index('is_public'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workouts', function (Blueprint $table) { + $table->dropIndex(['is_public']); + $table->dropColumn('is_public'); + }); + } +}; diff --git a/resources/views/workouts/community.blade.php b/resources/views/workouts/community.blade.php new file mode 100644 index 0000000..1aedc25 --- /dev/null +++ b/resources/views/workouts/community.blade.php @@ -0,0 +1,36 @@ + +
+
+ ← My Workouts +

Community Workouts

+

Workouts other members have made public. Copy one to your own library to use it.

+
+
+ +
+
+
+ + +
+ +
+ + @if($workouts->count() === 0) +
No public workouts yet. Make one of yours public from its page to be the first.
+ @else + +
{{ $workouts->links() }}
+ @endif +
+
diff --git a/resources/views/workouts/index.blade.php b/resources/views/workouts/index.blade.php index 704780f..3e60cb4 100644 --- a/resources/views/workouts/index.blade.php +++ b/resources/views/workouts/index.blade.php @@ -6,7 +6,10 @@

{{ __('app.workouts.my_workouts') }}

{{ __('app.workouts.manage_desc') }}

- {{ __('app.workouts.new_workout') }} + diff --git a/resources/views/workouts/show.blade.php b/resources/views/workouts/show.blade.php index f6b92cd..b1aa27d 100644 --- a/resources/views/workouts/show.blade.php +++ b/resources/views/workouts/show.blade.php @@ -1,9 +1,13 @@ + @php + $isOwner = Auth::id() === $workout->user_id; + $canManage = $isOwner || (Auth::check() && $workout->assigned_by_trainer_id === Auth::id()); + @endphp
- ← Back to Workouts + ← Back

{{ $workout->title }}

@auth @@ -26,19 +30,33 @@ @if($workout->assignedByTrainer) Assigned by {{ $workout->assignedByTrainer->name }} @endif + @if(!$isOwner && $workout->is_public) + Public workout by {{ $workout->user->name }} + @endif

@if($workout->estimated_minutes) ~ {{ $workout->estimated_minutes }} min · @endif {{ $workout->items->count() }} exercises

- @if(Auth::id() === $workout->user_id) + @if($isOwner)
@csrf
+
+ @csrf + +
@csrf @method('DELETE')
+ @elseif(Auth::check() && $workout->is_public) +
+ @csrf + +
@endif
@@ -83,30 +101,34 @@
{{ $item->notes }}
@endif
-
-
- @csrf - @method('DELETE') - -
-
-
-
- Edit -
- @csrf - @method('PATCH') - - - - - - -
- + @if($canManage) +
+ + @csrf + @method('DELETE') + +
- -
+ @endif + + @if($canManage) +
+ Edit +
+ @csrf + @method('PATCH') + + + + + + +
+ +
+
+
+ @endif @endforeach @@ -114,6 +136,7 @@
+ @if($canManage)

Add Exercise

+ @endif
diff --git a/routes/web.php b/routes/web.php index 01e9161..5dce045 100644 --- a/routes/web.php +++ b/routes/web.php @@ -44,11 +44,14 @@ Route::prefix('/exercises')->group(function () { Route::middleware('auth')->prefix('/workouts')->group(function () { Route::get('/', [\App\Http\Controllers\WorkoutController::class, 'index'])->name('workouts.index'); Route::get('/builder', [\App\Http\Controllers\WorkoutController::class, 'builder'])->name('workouts.builder'); + Route::get('/community', [\App\Http\Controllers\WorkoutController::class, 'community'])->name('workouts.community'); Route::get('/create', [\App\Http\Controllers\WorkoutController::class, 'create'])->name('workouts.create'); Route::post('/', [\App\Http\Controllers\WorkoutController::class, 'store'])->name('workouts.store'); Route::get('/{workout}', [\App\Http\Controllers\WorkoutController::class, 'show'])->name('workouts.show'); Route::delete('/{workout}', [\App\Http\Controllers\WorkoutController::class, 'destroy'])->name('workouts.destroy'); + Route::post('/{workout}/toggle-public', [\App\Http\Controllers\WorkoutController::class, 'togglePublic'])->name('workouts.toggle-public'); + Route::post('/{workout}/copy', [\App\Http\Controllers\WorkoutController::class, 'copy'])->name('workouts.copy'); // Manage exercises within a workout Route::get('/{workout}/exercises/search', [\App\Http\Controllers\WorkoutController::class, 'searchExercises'])->name('workouts.exercises.search'); diff --git a/tests/Feature/WorkoutCommunityTest.php b/tests/Feature/WorkoutCommunityTest.php new file mode 100644 index 0000000..25b5432 --- /dev/null +++ b/tests/Feature/WorkoutCommunityTest.php @@ -0,0 +1,157 @@ +create(); + $public = Workout::factory()->create(['user_id' => $user->id, 'is_public' => true, 'title' => 'Public One']); + Workout::factory()->create(['user_id' => $user->id, 'is_public' => false, 'title' => 'Private One']); + + $response = $this->actingAs($user)->get(route('workouts.community')); + + $response->assertOk(); + $response->assertSee('Public One'); + $response->assertDontSee('Private One'); + } + + public function test_community_index_can_search_by_title(): void + { + $user = User::factory()->create(); + Workout::factory()->create(['user_id' => $user->id, 'is_public' => true, 'title' => 'Leg Day Blast']); + Workout::factory()->create(['user_id' => $user->id, 'is_public' => true, 'title' => 'Upper Body Push']); + + $response = $this->actingAs($user)->get(route('workouts.community', ['q' => 'Leg'])); + + $response->assertOk(); + $response->assertSee('Leg Day Blast'); + $response->assertDontSee('Upper Body Push'); + } + + public function test_owner_can_toggle_a_workout_public(): void + { + $user = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $user->id, 'is_public' => false]); + + $this->actingAs($user)->post(route('workouts.toggle-public', $workout)); + + $this->assertTrue($workout->fresh()->is_public); + + $this->actingAs($user)->post(route('workouts.toggle-public', $workout)); + + $this->assertFalse($workout->fresh()->is_public); + } + + public function test_other_user_cannot_toggle_someone_elses_workout_public(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => false]); + + $response = $this->actingAs($other)->post(route('workouts.toggle-public', $workout)); + + $response->assertForbidden(); + $this->assertFalse($workout->fresh()->is_public); + } + + public function test_any_user_can_view_a_public_workout(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => true, 'title' => 'Full Body Blast']); + + $response = $this->actingAs($viewer)->get(route('workouts.show', $workout)); + + $response->assertOk(); + $response->assertSee('Full Body Blast'); + } + + public function test_viewer_of_a_public_workout_cannot_manage_its_exercises(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => true]); + $exercise = Exercise::factory()->create(); + + $response = $this->actingAs($viewer)->get(route('workouts.show', $workout)); + $response->assertOk(); + $response->assertDontSee('Add Exercise'); + + $addResponse = $this->actingAs($viewer)->post(route('workouts.exercises.add', $workout), [ + 'exercise_id' => $exercise->id, + ]); + $addResponse->assertForbidden(); + } + + public function test_non_owner_cannot_view_a_private_workout(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => false]); + + $response = $this->actingAs($viewer)->get(route('workouts.show', $workout)); + + $response->assertForbidden(); + } + + public function test_user_can_copy_a_public_workout_with_its_exercises(): void + { + $owner = User::factory()->create(); + $copier = User::factory()->create(); + $workout = Workout::factory()->create([ + 'user_id' => $owner->id, + 'is_public' => true, + 'title' => 'Original Workout', + 'estimated_minutes' => 45, + ]); + WorkoutExercise::factory()->create(['workout_id' => $workout->id, 'sets' => 4, 'reps' => 6]); + + $response = $this->actingAs($copier)->post(route('workouts.copy', $workout)); + + $response->assertRedirect(); + $this->assertDatabaseHas('workouts', [ + 'user_id' => $copier->id, + 'title' => 'Original Workout (copy)', + 'estimated_minutes' => 45, + ]); + $copiedWorkout = Workout::where('user_id', $copier->id)->firstOrFail(); + $this->assertEquals(1, $copiedWorkout->items()->count()); + $this->assertEquals(4, $copiedWorkout->items()->first()->sets); + + // The original is untouched + $this->assertEquals(1, $workout->fresh()->items()->count()); + } + + public function test_user_cannot_copy_a_private_workout_they_do_not_own(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => false]); + + $response = $this->actingAs($other)->post(route('workouts.copy', $workout)); + + $response->assertForbidden(); + } + + public function test_owner_can_copy_their_own_private_workout(): void + { + $owner = User::factory()->create(); + $workout = Workout::factory()->create(['user_id' => $owner->id, 'is_public' => false]); + + $response = $this->actingAs($owner)->post(route('workouts.copy', $workout)); + + $response->assertRedirect(); + $this->assertEquals(2, Workout::where('user_id', $owner->id)->count()); + } +}