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
+
+
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.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
@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)
+
+ @elseif(Auth::check() && $workout->is_public)
+
@endif
@@ -83,30 +101,34 @@
{{ $item->notes }}
@endif
-
-
-
-
-
- Edit
-
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());
+ }
+}