favorites

This commit is contained in:
2025-10-02 23:52:07 +03:00
parent dee8c09298
commit 1ed9235451
12 changed files with 194 additions and 2 deletions
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\Exercise;
use App\Models\Workout;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
class FavoriteController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function toggleExercise(Exercise $exercise): RedirectResponse
{
$user = Auth::user();
$exists = $user->favoriteExercises()->where('exercise_id', $exercise->id)->exists();
if ($exists) {
$user->favoriteExercises()->detach($exercise->id);
$msg = 'Removed from favorites';
} else {
$user->favoriteExercises()->attach($exercise->id);
$msg = 'Added to favorites';
}
return back()->with('status', $msg);
}
public function toggleWorkout(Workout $workout): RedirectResponse
{
$user = Auth::user();
if ($workout->user_id && $workout->user_id !== $user->id) {
// Users can still favorite others' workouts if shared later; for now no restriction besides existence
}
$exists = $user->favoriteWorkouts()->where('workout_id', $workout->id)->exists();
if ($exists) {
$user->favoriteWorkouts()->detach($workout->id);
$msg = 'Removed from favorites';
} else {
$user->favoriteWorkouts()->attach($workout->id);
$msg = 'Added to favorites';
}
return back()->with('status', $msg);
}
}
+5
View File
@@ -39,4 +39,9 @@ class Exercise extends Model
{
return $this->hasMany(WorkoutExercise::class);
}
public function favoritedBy()
{
return $this->belongsToMany(User::class, 'exercise_favorites')->withTimestamps();
}
}
+21
View File
@@ -51,4 +51,25 @@ class User extends Authenticatable
{
return $this->hasMany(Workout::class);
}
public function favoriteExercises()
{
return $this->belongsToMany(Exercise::class, 'exercise_favorites')->withTimestamps();
}
public function favoriteWorkouts()
{
return $this->belongsToMany(Workout::class, 'workout_favorites')->withTimestamps();
}
// Helpers
public function hasFavoritedExercise(Exercise $exercise): bool
{
return $this->favoriteExercises()->where('exercise_id', $exercise->id)->exists();
}
public function hasFavoritedWorkout(Workout $workout): bool
{
return $this->favoriteWorkouts()->where('workout_id', $workout->id)->exists();
}
}
+5
View File
@@ -40,4 +40,9 @@ class Workout extends Model
->withTimestamps()
->orderBy('workout_exercises.position');
}
public function favoritedBy()
{
return $this->belongsToMany(User::class, 'workout_favorites')->withTimestamps();
}
}
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('exercise_favorites', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('exercise_id')->constrained('exercises')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'exercise_id']);
});
}
public function down(): void
{
Schema::dropIfExists('exercise_favorites');
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('workout_favorites', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('workout_id')->constrained('workouts')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'workout_id']);
});
}
public function down(): void
{
Schema::dropIfExists('workout_favorites');
}
};
+19 -1
View File
@@ -8,7 +8,25 @@
<a href="{{ url()->previous() ?? route('exercises.index') }}" class="inline-flex items-center text-sm font-medium text-white/90 hover:text-white">
&#8592; Back
</a>
<h1 class="mt-3 text-3xl sm:text-5xl font-extrabold tracking-tight text-white drop-shadow">{{ $exercise->name }}</h1>
<div class="mt-3 flex items-start justify-between gap-4">
<h1 class="text-3xl sm:text-5xl font-extrabold tracking-tight text-white drop-shadow">{{ $exercise->name }}</h1>
@auth
@php $isFav = auth()->user()->hasFavoritedExercise($exercise); @endphp
<form method="POST" action="{{ route('exercises.favorite', $exercise) }}">
@csrf
<button type="submit" class="inline-flex items-center gap-2 rounded-full bg-white/90 px-3 py-1.5 text-sm font-semibold text-gray-900 shadow hover:bg-white">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5 {{ $isFav ? 'text-rose-600' : 'text-gray-400' }}">
@if($isFav)
<path d="M11.645 20.91l-.007-.003-.022-.01a15.247 15.247 0 01-.383-.18 25.18 25.18 0 01-4.244-2.686C4.688 15.734 2.25 13.153 2.25 9.75 2.25 7.005 4.46 4.875 7.125 4.875c1.6 0 3.09.744 4.125 1.917a5.625 5.625 0 014.125-1.917c2.665 0 4.875 2.13 4.875 4.875 0 3.403-2.438 5.984-4.739 7.58a25.175 25.175 0 01-4.244 2.685 15.247 15.247 0 01-.383.18l-.022.01-.007.004-.003.001a.75.75 0 01-.592 0l-.003-.001z"/>
@else
<path fill-rule="evenodd" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 6.01 4.01 4 6.5 4c1.74 0 3.41.81 4.5 2.09A6.004 6.004 0 0115.5 4C17.99 4 20 6.01 20 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" clip-rule="evenodd"/>
@endif
</svg>
<span>{{ $isFav ? 'Favorited' : 'Favorite' }}</span>
</button>
</form>
@endauth
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
@foreach($exercise->bodyParts as $bp)
<span class="inline-flex items-center rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-gray-900 shadow">{{ $bp->name }}</span>
+15
View File
@@ -11,6 +11,21 @@
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<script>
// Initialize theme early to avoid FOUC
(function() {
try {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {}
})();
</script>
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
@@ -20,6 +20,10 @@
</div>
<div class="hidden sm:flex items-center gap-4">
<button type="button" aria-label="Toggle dark mode" title="Toggle theme" class="inline-flex items-center justify-center rounded-md p-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50" onclick="(function(){const c=document.documentElement.classList;const now=c.toggle('dark');try{localStorage.setItem('theme', now?'dark':'light');}catch(e){}})()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5 dark:hidden"><path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12zm0 4a1 1 0 0 1-1-1v-1.06a9.01 9.01 0 0 1-6.94-6.94H3a1 1 0 1 1 0-2h1.06A9.01 9.01 0 0 1 11 4.06V3a1 1 0 1 1 2 0v1.06A9.01 9.01 0 0 1 19.94 11H21a1 1 0 1 1 0 2h-1.06A9.01 9.01 0 0 1 13 19.94V21a1 1 0 0 1-1 1z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5 hidden dark:block"><path d="M21.64 13.02A9 9 0 1 1 10.98 2.36a1 1 0 0 1 1.3 1.3 7 7 0 1 0 9.05 9.05 1 1 0 0 1 1.3 1.3z"/></svg>
</button>
<a href="{{ route('workouts.builder') }}" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500">Build a Workout</a>
@auth
<x-dropdown align="right" width="48">
@@ -64,6 +68,12 @@
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1 border-t border-gray-100 dark:border-gray-800">
<div class="px-4 pb-2">
<button type="button" aria-label="Toggle dark mode" class="inline-flex items-center gap-2 rounded-md border border-gray-200 dark:border-gray-700 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-900" onclick="(function(){const c=document.documentElement.classList;const now=c.toggle('dark');try{localStorage.setItem('theme', now?'dark':'light');}catch(e){}})()">
<span class="dark:hidden">Enable Dark Mode</span>
<span class="hidden dark:inline">Disable Dark Mode</span>
</button>
</div>
<x-responsive-nav-link :href="route('exercises.index')">Exercises</x-responsive-nav-link>
<x-responsive-nav-link :href="route('workouts.index', [], false)">Workouts</x-responsive-nav-link>
<x-responsive-nav-link :href="route('blog.index', [], false)">Blog</x-responsive-nav-link>
+19 -1
View File
@@ -4,7 +4,25 @@
<div class="flex items-center justify-between">
<div>
<a href="{{ route('workouts.index') }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">← Back to Workouts</a>
<h1 class="mt-2 text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ $workout->title }}</h1>
<div class="mt-2 flex items-center gap-3">
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ $workout->title }}</h1>
@auth
@php $isFav = auth()->user()->hasFavoritedWorkout($workout); @endphp
<form method="POST" action="{{ route('workouts.favorite', $workout) }}">
@csrf
<button type="submit" class="inline-flex items-center gap-1.5 rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs font-semibold text-gray-900 dark:text-gray-100 ring-1 ring-gray-200 dark:ring-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-4 w-4 {{ $isFav ? 'text-rose-600' : 'text-gray-400' }}">
@if($isFav)
<path d="M11.645 20.91l-.007-.003-.022-.01a15.247 15.247 0 01-.383-.18 25.18 25.18 0 01-4.244-2.686C4.688 15.734 2.25 13.153 2.25 9.75 2.25 7.005 4.46 4.875 7.125 4.875c1.6 0 3.09.744 4.125 1.917a5.625 5.625 0 014.125-1.917c2.665 0 4.875 2.13 4.875 4.875 0 3.403-2.438 5.984-4.739 7.58a25.175 25.175 0 01-4.244 2.685 15.247 15.247 0 01-.383.18l-.022.01-.007.004-.003.001a.75.75 0 01-.592 0l-.003-.001z"/>
@else
<path fill-rule="evenodd" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 6.01 4.01 4 6.5 4c1.74 0 3.41.81 4.5 2.09A6.004 6.004 0 0115.5 4C17.99 4 20 6.01 20 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" clip-rule="evenodd"/>
@endif
</svg>
<span>{{ $isFav ? 'Favorited' : 'Favorite' }}</span>
</button>
</form>
@endauth
</div>
<p class="mt-1 text-gray-600 dark:text-gray-300">@if($workout->estimated_minutes) ~ {{ $workout->estimated_minutes }} min · @endif {{ $workout->items->count() }} exercises</p>
</div>
<form method="POST" action="{{ route('workouts.destroy', $workout) }}" onsubmit="return confirm('Delete this workout?');">
+4
View File
@@ -14,6 +14,10 @@ Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// Favorites
Route::post('/exercises/{exercise}/favorite', [\App\Http\Controllers\FavoriteController::class, 'toggleExercise'])->name('exercises.favorite');
Route::post('/workouts/{workout}/favorite', [\App\Http\Controllers\FavoriteController::class, 'toggleWorkout'])->name('workouts.favorite');
});
Route::prefix('/exercises')->group(function () {
+1
View File
@@ -3,6 +3,7 @@ import forms from '@tailwindcss/forms';
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
'./storage/framework/views/*.php',