some progress
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Exercise;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ExerciseController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$q = trim((string) $request->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Exercise;
|
||||
use App\Models\User;
|
||||
use App\Models\Workout;
|
||||
use App\Models\WorkoutSession;
|
||||
use App\Models\YogaPose;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AdminDashboardController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$metrics = [
|
||||
'users' => 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'));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Admin
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user || !$user->is_admin) {
|
||||
abort(403);
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
//
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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::table('users', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_trainer')->default(false)->after('minutes_per_session');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_trainer');
|
||||
});
|
||||
}
|
||||
};
|
||||
+24
@@ -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::table('workout_session_items', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('exercises', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+27
-1
@@ -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',
|
||||
|
||||
+28
-3
@@ -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ă',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<x-app-layout>
|
||||
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ __('app.admin.title') }}</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">{{ __('app.admin.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.users') }}</div>
|
||||
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['users'] }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.workouts') }}</div>
|
||||
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['workouts'] }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.sessions') }}</div>
|
||||
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['sessions'] }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.exercises') }}</div>
|
||||
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['exercises'] }}</div>
|
||||
</div>
|
||||
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<div class="text-sm text-gray-500">{{ __('app.admin.metrics.yoga_poses') }}</div>
|
||||
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $metrics['yogaPoses'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div class="lg:col-span-7 rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ __('app.admin.recent_users') }}</h2>
|
||||
</div>
|
||||
@if($recentUsers->isEmpty())
|
||||
<div class="mt-3 rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-6 text-sm text-gray-600 dark:text-gray-300">{{ __('app.admin.none') }}</div>
|
||||
@else
|
||||
<ul class="mt-4 space-y-3">
|
||||
@foreach($recentUsers as $u)
|
||||
<li class="rounded-xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-900 p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ $u->name }}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">{{ $u->email }}</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">{{ $u->created_at->diffForHumans() }}</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-5 rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ __('app.admin.recent_workouts') }}</h2>
|
||||
@if($recentWorkouts->isEmpty())
|
||||
<div class="mt-3 rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-6 text-sm text-gray-600 dark:text-gray-300">{{ __('app.admin.none') }}</div>
|
||||
@else
|
||||
<ul class="mt-4 space-y-3">
|
||||
@foreach($recentWorkouts as $w)
|
||||
<li class="rounded-xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-900 p-4 flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ $w->title }}</div>
|
||||
<div class="text-xs text-gray-500">{{ $w->created_at->diffForHumans() }}</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<a href="{{ route('workouts.index') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">{{ __('app.admin.manage_workouts') }}</a>
|
||||
<a href="{{ route('admin.exercises.index') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-indigo-600 to-fuchsia-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-indigo-500 hover:to-fuchsia-500">Manage exercises</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,53 @@
|
||||
@csrf
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<input type="text" name="name" value="{{ old('name', $exercise->name ?? '') }}" required class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug (optional)</label>
|
||||
<input type="text" name="slug" value="{{ old('slug', $exercise->slug ?? '') }}" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||
<p class="mt-1 text-xs text-gray-500">Leave empty to auto-generate from the name.</p>
|
||||
<x-input-error :messages="$errors->get('slug')" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">GIF URL</label>
|
||||
<input type="url" name="gif_url" value="{{ old('gif_url', $exercise->gif_url ?? '') }}" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||
<x-input-error :messages="$errors->get('gif_url')" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Poster image</label>
|
||||
<div class="mt-1 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<input type="file" name="poster_image_file" accept="image/*" class="block w-full text-sm text-gray-900 dark:text-gray-100 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100 dark:file:bg-indigo-900/30 dark:file:text-indigo-200" />
|
||||
<p class="mt-1 text-xs text-gray-500">Upload a new image (JPG, PNG, WebP, up to 8MB). If provided, it will override the URL below.</p>
|
||||
<x-input-error :messages="$errors->get('poster_image_file')" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">Or use an external URL</label>
|
||||
<input type="url" name="poster_image" value="{{ old('poster_image', $exercise->poster_image ?? '') }}" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2" placeholder="https://...">
|
||||
<x-input-error :messages="$errors->get('poster_image')" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
@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))
|
||||
<div class="mt-2">
|
||||
<img src="{{ $src }}" alt="Poster preview" class="h-32 w-auto rounded-lg border border-gray-200 dark:border-gray-700 object-cover">
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Instructions</label>
|
||||
<textarea name="instructions" rows="6" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">{{ old('instructions', $exercise->instructions ?? '') }}</textarea>
|
||||
<x-input-error :messages="$errors->get('instructions')" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<a href="{{ route('admin.exercises.index') }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Cancel</a>
|
||||
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">Save</button>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<x-app-layout>
|
||||
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">New Exercise</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Add a new exercise to the library.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 rounded-xl bg-red-50 text-red-700 px-4 py-3">
|
||||
<ul class="list-disc ms-5 text-sm">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
<form action="{{ route('admin.exercises.store') }}" method="POST" enctype="multipart/form-data" class="space-y-5 bg-white dark:bg-gray-800 rounded-2xl p-6 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||
@include('admin.exercises._form')
|
||||
</form>
|
||||
</section>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,24 @@
|
||||
<x-app-layout>
|
||||
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">Edit Exercise</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Update exercise details.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 rounded-xl bg-red-50 text-red-700 px-4 py-3">
|
||||
<ul class="list-disc ms-5 text-sm">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
<form action="{{ route('admin.exercises.update', $exercise) }}" method="POST" enctype="multipart/form-data" class="space-y-5 bg-white dark:bg-gray-800 rounded-2xl p-6 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||
@method('PATCH')
|
||||
@include('admin.exercises._form')
|
||||
</form>
|
||||
</section>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,72 @@
|
||||
<x-app-layout>
|
||||
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">Manage Exercises</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Create, edit, and delete exercises in the library.</p>
|
||||
</div>
|
||||
<a href="{{ route('admin.exercises.create') }}" class="inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:from-fuchsia-500 hover:to-indigo-500">New Exercise</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||
@if(session('status'))
|
||||
<div class="mb-4 rounded-xl bg-emerald-50 text-emerald-700 px-4 py-3">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<form action="{{ route('admin.exercises.index') }}" method="GET" class="mb-4 flex items-end gap-3 bg-white dark:bg-gray-800 rounded-2xl p-4 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||
<div class="w-full sm:w-80">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<input type="text" name="q" value="{{ $q }}" placeholder="Search by name or slug" class="mt-1 w-full rounded-xl border-gray-300 dark:border-gray-700 bg-white/80 dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 px-3 py-2">
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<button type="submit" class="inline-flex items-center rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-indigo-500">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Name</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Slug</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">GIF</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@forelse($exercises as $ex)
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-gray-100">{{ $ex->name }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{{ $ex->slug }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300 truncate max-w-[240px]">
|
||||
@if($ex->gif_url)
|
||||
<a href="{{ $ex->gif_url }}" target="_blank" class="text-indigo-600 hover:text-indigo-500">{{ \Illuminate\Support\Str::limit($ex->gif_url, 40) }}</a>
|
||||
@else
|
||||
<span class="text-gray-400">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('admin.exercises.edit', $ex) }}" class="text-indigo-600 hover:text-indigo-500">Edit</a>
|
||||
<form method="POST" action="{{ route('admin.exercises.destroy', $ex) }}" class="inline" onsubmit="return confirm('Delete this exercise?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="ms-3 text-red-600 hover:text-red-500">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-4 py-6 text-center text-gray-600 dark:text-gray-300">No exercises found.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
|
||||
{{ $exercises->links() }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</x-app-layout>
|
||||
@@ -45,6 +45,15 @@
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Is trainer -->
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center">
|
||||
<input id="is_trainer" name="is_trainer" type="checkbox" value="1" @checked(old('is_trainer')) class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<label for="is_trainer" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">{{ __('app.auth.register.is_trainer') }}</label>
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('is_trainer')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-6">
|
||||
<a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('login') }}">
|
||||
{{ __('Already registered?') }}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
@foreach($exercises as $exercise)
|
||||
<a href="{{ route('exercises.show', $exercise->slug) }}" class="group rounded-3xl overflow-hidden bg-white dark:bg-gray-800 ring-1 ring-gray-200 dark:ring-gray-700 shadow-sm hover:shadow-xl transition">
|
||||
<div class="relative aspect-[16/9] overflow-hidden">
|
||||
<img src="{{ $exercise->gif_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1200&auto=format&fit=crop' }}" alt="{{ $exercise->display_name }}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105">
|
||||
<img src="{{ $exercise->poster_url ?: ($exercise->gif_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1200&auto=format&fit=crop') }}" alt="{{ $exercise->display_name }}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
|
||||
<div class="absolute bottom-3 left-3 inline-flex items-center rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-gray-900 shadow">{{ $exercise->display_name }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<x-app-layout>
|
||||
<section class="relative overflow-hidden">
|
||||
<div class="absolute inset-0">
|
||||
<img src="{{ $exercise->gif_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1920&auto=format&fit=crop' }}" alt="{{ $exercise->display_name }}" class="h-full w-full object-cover">
|
||||
<img src="{{ $exercise->poster_url ?: ($exercise->gif_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1920&auto=format&fit=crop') }}" alt="{{ $exercise->display_name }}" class="h-full w-full object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-indigo-900/70 via-indigo-800/60 to-transparent"></div>
|
||||
</div>
|
||||
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-16 sm:py-24">
|
||||
@@ -61,7 +61,7 @@
|
||||
<div class="lg:col-span-8">
|
||||
<div class="rounded-3xl overflow-hidden ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800">
|
||||
<div class="relative aspect-[16/9] bg-black">
|
||||
<img src="{{ $exercise->gif_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1600&auto=format&fit=crop' }}" alt="{{ $exercise->name }} demo" class="h-full w-full object-cover">
|
||||
<img src="{{ $exercise->gif_url ?: ($exercise->poster_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1600&auto=format&fit=crop') }}" alt="{{ $exercise->name }} demo" class="h-full w-full object-cover">
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">How to perform</h2>
|
||||
@@ -118,7 +118,7 @@
|
||||
@foreach($similarExercises as $ex)
|
||||
<a href="{{ route('exercises.show', $ex->slug) }}" class="group rounded-3xl overflow-hidden bg-white dark:bg-gray-800 ring-1 ring-gray-200 dark:ring-gray-700 shadow-sm hover:shadow-xl transition">
|
||||
<div class="relative aspect-[16/9] overflow-hidden">
|
||||
<img src="{{ $ex->gif_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1200&auto=format&fit=crop' }}" alt="{{ $ex->display_name }}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105">
|
||||
<img src="{{ $ex->poster_url ?: ($ex->gif_url ?: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=1200&auto=format&fit=crop') }}" alt="{{ $ex->display_name }}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
|
||||
<div class="absolute bottom-3 left-3 inline-flex items-center rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-gray-900 shadow">{{ $ex->display_name }}</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<a href="{{ route('blog.index', [], false) }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.blog') }}</a>
|
||||
@auth
|
||||
<a href="{{ route('dashboard') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.dashboard') }}</a>
|
||||
@if(Auth::user()->is_admin)
|
||||
<a href="{{ route('admin.dashboard') }}" class="text-sm font-medium text-indigo-700 dark:text-indigo-300 hover:text-indigo-900 dark:hover:text-indigo-200">{{ __('app.nav.admin') }}</a>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,6 +98,9 @@
|
||||
<x-responsive-nav-link :href="route('blog.index', [], false)">{{ __('app.nav.blog') }}</x-responsive-nav-link>
|
||||
@auth
|
||||
<x-responsive-nav-link :href="route('dashboard')">{{ __('app.nav.dashboard') }}</x-responsive-nav-link>
|
||||
@if(Auth::user()->is_admin)
|
||||
<x-responsive-nav-link :href="route('admin.dashboard')">{{ __('app.nav.admin') }}</x-responsive-nav-link>
|
||||
@endif
|
||||
@endauth
|
||||
<x-responsive-nav-link :href="route('workouts.builder')">{{ __('app.nav.build_workout') }}</x-responsive-nav-link>
|
||||
</div>
|
||||
|
||||
@@ -23,31 +23,58 @@
|
||||
<ul class="mt-1 space-y-3">
|
||||
@foreach($workout->items as $item)
|
||||
@php $si = $doneByItemId->get($item->id); $isDone = $si && $si->done; @endphp
|
||||
<li class="rounded-xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-900 p-4 flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<form method="POST" action="{{ route('sessions.items.toggle', [$session, $item]) }}" class="session-toggle-form" data-item-id="{{ $item->id }}" data-initial-done="{{ $isDone ? '1' : '0' }}">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button type="submit" class="h-6 w-6 rounded-md border flex items-center justify-center transition-colors duration-150"
|
||||
aria-pressed="{{ $isDone ? 'true' : 'false' }}"
|
||||
title="{{ $isDone ? __('app.sessions.mark_complete') : __('app.sessions.progress') }}">
|
||||
<span class="sr-only">Toggle</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4 {{ $isDone ? 'text-white' : 'text-transparent' }}">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-7.5 7.5a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L8.5 12.086l6.793-6.793a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<div class="exercise-title text-base font-semibold {{ $isDone ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-white' }}">{{ $item->exercise->display_name }}</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
@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
|
||||
<li class="rounded-xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-900 p-4" data-session-id="{{ $session->id }}" data-item-id="{{ $item->id }}" data-rest-seconds="{{ $item->rest_seconds ?? 60 }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<form method="POST" action="{{ route('sessions.items.toggle', [$session, $item]) }}" class="session-toggle-form" data-item-id="{{ $item->id }}" data-initial-done="{{ $isDone ? '1' : '0' }}">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button type="submit" class="h-6 w-6 rounded-md border flex items-center justify-center transition-colors duration-150"
|
||||
aria-pressed="{{ $isDone ? 'true' : 'false' }}"
|
||||
title="{{ $isDone ? __('app.sessions.mark_complete') : __('app.sessions.progress') }}">
|
||||
<span class="sr-only">Toggle</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4 {{ $isDone ? 'text-white' : 'text-transparent' }}">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-7.5 7.5a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L8.5 12.086l6.793-6.793a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<div class="exercise-title text-base font-semibold {{ $isDone ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-white' }}">{{ $item->exercise->display_name }}</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
@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
|
||||
</div>
|
||||
@if($item->notes)
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $item->notes }}</div>
|
||||
@endif
|
||||
|
||||
<!-- Timers -->
|
||||
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<!-- Exercise timer (count-up) -->
|
||||
<div class="rounded-lg bg-gray-50 dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-gray-700 p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">{{ __('app.sessions.exercise_timer') }}</div>
|
||||
<div class="exercise-timer-display text-sm font-semibold text-gray-900 dark:text-white">00:00</div>
|
||||
</div>
|
||||
<button type="button" class="exercise-timer-toggle inline-flex items-center rounded-md px-3 py-1.5 text-xs font-semibold text-white bg-emerald-600 hover:bg-emerald-500">{{ __('app.sessions.start_timer') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Rest timer (countdown) -->
|
||||
<div class="rounded-lg bg-gray-50 dark:bg-gray-800/60 ring-1 ring-gray-200 dark:ring-gray-700 p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">{{ __('app.sessions.rest_timer') }}</div>
|
||||
<div class="rest-timer-display text-sm font-semibold text-gray-900 dark:text-white">00:00</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="rest-start inline-flex items-center rounded-md px-2.5 py-1.5 text-xs font-semibold text-white bg-indigo-600 hover:bg-indigo-500">{{ __('app.sessions.start_rest') }}</button>
|
||||
<button type="button" class="rest-pause inline-flex items-center rounded-md px-2.5 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">{{ __('app.sessions.pause_rest') }}</button>
|
||||
<button type="button" class="rest-reset inline-flex items-center rounded-md px-2.5 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700">{{ __('app.sessions.reset_rest') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if($item->notes)
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $item->notes }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user