added yoga stuff, i18n, workout tracking and a dashboard to track progress
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\YogaCategory;
|
||||||
|
use App\Models\YogaPose;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class FetchYogaDataCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'yoga:fetch {--force : Recreate slugs}';
|
||||||
|
|
||||||
|
protected $description = 'Fetch yoga categories and poses from public API and upsert into local DB';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$url = 'https://yoga-api-nzy4.onrender.com/v1/categories';
|
||||||
|
$this->info('Fetching: ' . $url);
|
||||||
|
|
||||||
|
$response = Http::timeout(30)->acceptJson()->get($url);
|
||||||
|
if (!$response->ok()) {
|
||||||
|
$this->error('Failed to fetch data: ' . $response->status());
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
if (!is_array($data)) {
|
||||||
|
$this->error('Unexpected response format');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$countCategories = 0; $countPoses = 0;
|
||||||
|
|
||||||
|
foreach ($data as $cat) {
|
||||||
|
$extId = (int) ($cat['id'] ?? 0);
|
||||||
|
if ($extId <= 0) { continue; }
|
||||||
|
|
||||||
|
$category = YogaCategory::updateOrCreate(
|
||||||
|
['external_id' => $extId],
|
||||||
|
[
|
||||||
|
'name' => (string) ($cat['category_name'] ?? 'Category ' . $extId),
|
||||||
|
'description' => (string) ($cat['category_description'] ?? ''),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$countCategories++;
|
||||||
|
|
||||||
|
$poses = $cat['poses'] ?? [];
|
||||||
|
if (!is_array($poses)) { $poses = []; }
|
||||||
|
|
||||||
|
foreach ($poses as $pose) {
|
||||||
|
$pExt = (int) ($pose['id'] ?? 0);
|
||||||
|
if ($pExt <= 0) { continue; }
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'english_name' => (string) ($pose['english_name'] ?? ''),
|
||||||
|
'sanskrit_name_adapted' => (string) ($pose['sanskrit_name_adapted'] ?? ''),
|
||||||
|
'sanskrit_name' => (string) ($pose['sanskrit_name'] ?? ''),
|
||||||
|
'translation_name' => (string) ($pose['translation_name'] ?? ''),
|
||||||
|
'description' => (string) ($pose['pose_description'] ?? ''),
|
||||||
|
'benefits' => (string) ($pose['pose_benefits'] ?? ''),
|
||||||
|
'url_svg' => (string) ($pose['url_svg'] ?? ''),
|
||||||
|
'url_png' => (string) ($pose['url_png'] ?? ''),
|
||||||
|
'url_svg_alt' => (string) ($pose['url_svg_alt'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
$model = YogaPose::updateOrCreate(
|
||||||
|
['external_id' => $pExt],
|
||||||
|
$payload + [
|
||||||
|
// slug may be regenerated with --force
|
||||||
|
'slug' => $this->makeSlug($payload['english_name'], $pExt, $category->name),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->option('force')) {
|
||||||
|
$model->slug = $this->makeSlug($payload['english_name'], $pExt, $category->name, true);
|
||||||
|
$model->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$countPoses++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Imported {$countCategories} categories, {$countPoses} poses.");
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makeSlug(string $englishName, int $externalId, string $categoryName = '', bool $force = false): string
|
||||||
|
{
|
||||||
|
$base = Str::slug($englishName !== '' ? $englishName : ('pose-' . $externalId));
|
||||||
|
if ($base === '') { $base = 'pose-' . $externalId; }
|
||||||
|
$slug = $base;
|
||||||
|
$i = 1;
|
||||||
|
while (YogaPose::where('slug', $slug)->exists()) {
|
||||||
|
// If force-regenerating we allow different category-based suffix to reduce collisions
|
||||||
|
$suffix = $force && $categoryName ? '-' . Str::slug($categoryName) : '';
|
||||||
|
$slug = $base . $suffix . '-' . (++$i);
|
||||||
|
}
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\WorkoutSession;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
// Basic counts
|
||||||
|
$workoutsCount = $user->workouts()->count();
|
||||||
|
$sessionsQuery = WorkoutSession::where('user_id', $user->id);
|
||||||
|
$sessionsCount = (clone $sessionsQuery)->count();
|
||||||
|
$completedSessions = (clone $sessionsQuery)->whereNotNull('completed_at')->count();
|
||||||
|
$completionRate = $sessionsCount > 0 ? round(($completedSessions / $sessionsCount) * 100) : 0;
|
||||||
|
|
||||||
|
// Recent sessions with progress
|
||||||
|
$recentSessions = (clone $sessionsQuery)
|
||||||
|
->latest('started_at')
|
||||||
|
->latest('id')
|
||||||
|
->with(['workout:id,title', 'items:id,session_id,done'])
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(function ($s) {
|
||||||
|
$total = $s->items->count();
|
||||||
|
$done = $s->items->where('done', true)->count();
|
||||||
|
$pct = $total > 0 ? (int) round(($done / $total) * 100) : 0;
|
||||||
|
return [
|
||||||
|
'id' => $s->id,
|
||||||
|
'workout_title' => optional($s->workout)->title ?? 'Workout',
|
||||||
|
'started_at' => $s->started_at,
|
||||||
|
'completed_at' => $s->completed_at,
|
||||||
|
'done' => $done,
|
||||||
|
'total' => $total,
|
||||||
|
'pct' => $pct,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activity in the last 30 days (number of sessions per day)
|
||||||
|
$since = now()->subDays(29)->startOfDay();
|
||||||
|
$dailyActivity = (clone $sessionsQuery)
|
||||||
|
->where('created_at', '>=', $since)
|
||||||
|
->get(['id', 'created_at'])
|
||||||
|
->groupBy(function ($s) { return $s->created_at->format('Y-m-d'); })
|
||||||
|
->map->count();
|
||||||
|
|
||||||
|
return view('dashboard', compact(
|
||||||
|
'workoutsCount',
|
||||||
|
'sessionsCount',
|
||||||
|
'completedSessions',
|
||||||
|
'completionRate',
|
||||||
|
'recentSessions',
|
||||||
|
'dailyActivity'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,8 +69,40 @@ class ExerciseController extends Controller
|
|||||||
public function show(Exercise $exercise)
|
public function show(Exercise $exercise)
|
||||||
{
|
{
|
||||||
$exercise->load(['bodyParts:id,name,slug', 'targetMuscles:id,name,slug', 'secondaryMuscles:id,name,slug', 'equipments:id,name,slug']);
|
$exercise->load(['bodyParts:id,name,slug', 'targetMuscles:id,name,slug', 'secondaryMuscles:id,name,slug', 'equipments:id,name,slug']);
|
||||||
|
|
||||||
|
// Find similar exercises by shared target muscles, body parts, or equipment
|
||||||
|
$targetIds = $exercise->targetMuscles->pluck('id')->all();
|
||||||
|
$bodyPartIds = $exercise->bodyParts->pluck('id')->all();
|
||||||
|
$equipmentIds = $exercise->equipments->pluck('id')->all();
|
||||||
|
|
||||||
|
$similarQuery = Exercise::query()
|
||||||
|
->with(['bodyParts:id,name,slug', 'targetMuscles:id,name,slug', 'equipments:id,name,slug'])
|
||||||
|
->where('id', '!=', $exercise->id)
|
||||||
|
->where(function ($q) use ($targetIds, $bodyPartIds, $equipmentIds) {
|
||||||
|
if (!empty($targetIds)) {
|
||||||
|
$q->orWhereHas('targetMuscles', function ($rel) use ($targetIds) {
|
||||||
|
$rel->whereIn('id', $targetIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!empty($bodyPartIds)) {
|
||||||
|
$q->orWhereHas('bodyParts', function ($rel) use ($bodyPartIds) {
|
||||||
|
$rel->whereIn('id', $bodyPartIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!empty($equipmentIds)) {
|
||||||
|
$q->orWhereHas('equipments', function ($rel) use ($equipmentIds) {
|
||||||
|
$rel->whereIn('id', $equipmentIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(9);
|
||||||
|
|
||||||
|
$similarExercises = $similarQuery->get();
|
||||||
|
|
||||||
return view('exercises.show', [
|
return view('exercises.show', [
|
||||||
'exercise' => $exercise,
|
'exercise' => $exercise,
|
||||||
|
'similarExercises' => $similarExercises,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Workout;
|
||||||
|
use App\Models\WorkoutExercise;
|
||||||
|
use App\Models\WorkoutSession;
|
||||||
|
use App\Models\WorkoutSessionItem;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class WorkoutSessionController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, Workout $workout): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeWorkout($workout);
|
||||||
|
|
||||||
|
$session = WorkoutSession::create([
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'workout_id' => $workout->id,
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pre-create items for convenience
|
||||||
|
$workout->loadMissing('items');
|
||||||
|
foreach ($workout->items as $item) {
|
||||||
|
WorkoutSessionItem::create([
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'workout_exercise_id' => $item->id,
|
||||||
|
'done' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('sessions.show', $session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(WorkoutSession $session): View
|
||||||
|
{
|
||||||
|
$this->authorizeSession($session);
|
||||||
|
$session->load(['workout.items.exercise', 'items']);
|
||||||
|
|
||||||
|
// Index session items by workout_exercise_id for quick lookup
|
||||||
|
$doneByItemId = $session->items->keyBy('workout_exercise_id');
|
||||||
|
|
||||||
|
return view('workouts.session', [
|
||||||
|
'session' => $session,
|
||||||
|
'workout' => $session->workout,
|
||||||
|
'doneByItemId' => $doneByItemId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleItem(Request $request, WorkoutSession $session, WorkoutExercise $item): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeSession($session);
|
||||||
|
if ($item->workout_id !== $session->workout_id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$si = WorkoutSessionItem::firstOrCreate([
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'workout_exercise_id' => $item->id,
|
||||||
|
], [
|
||||||
|
'done' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$si->done = !$si->done;
|
||||||
|
$si->save();
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(WorkoutSession $session): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeSession($session);
|
||||||
|
$session->completed_at = now();
|
||||||
|
$session->save();
|
||||||
|
return redirect()->route('workouts.show', $session->workout)->with('status', __('app.sessions.completed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeWorkout(Workout $workout): void
|
||||||
|
{
|
||||||
|
if ($workout->user_id !== Auth::id()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function authorizeSession(WorkoutSession $session): void
|
||||||
|
{
|
||||||
|
if ($session->user_id !== Auth::id()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\YogaCategory;
|
||||||
|
use App\Models\YogaPose;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class YogaPoseController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
$category = (int) $request->query('category', 0);
|
||||||
|
$benefit = trim((string) $request->query('benefit', ''));
|
||||||
|
$perPage = (int) $request->query('per_page', 12);
|
||||||
|
if ($perPage < 6 || $perPage > 60) { $perPage = 12; }
|
||||||
|
|
||||||
|
$query = YogaPose::query()->with('category')
|
||||||
|
->when($q !== '', function ($qb) use ($q) {
|
||||||
|
$qb->where(function ($sub) use ($q) {
|
||||||
|
$sub->where('english_name', 'like', "%{$q}%")
|
||||||
|
->orWhere('sanskrit_name', 'like', "%{$q}%")
|
||||||
|
->orWhere('sanskrit_name_adapted', 'like', "%{$q}%");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when($category > 0, function ($qb) use ($category) {
|
||||||
|
$qb->where('category_id', $category);
|
||||||
|
})
|
||||||
|
->when($benefit !== '', function ($qb) use ($benefit) {
|
||||||
|
$qb->where('benefits', 'like', "%{$benefit}%");
|
||||||
|
})
|
||||||
|
->orderBy('english_name');
|
||||||
|
|
||||||
|
$poses = $query->paginate($perPage)->withQueryString();
|
||||||
|
$categories = YogaCategory::orderBy('name')->get(['id','name']);
|
||||||
|
|
||||||
|
return view('yoga.index', [
|
||||||
|
'poses' => $poses,
|
||||||
|
'filters' => [
|
||||||
|
'q' => $q,
|
||||||
|
'category' => $category,
|
||||||
|
'benefit' => $benefit,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
],
|
||||||
|
'categories' => $categories,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(YogaPose $pose): View
|
||||||
|
{
|
||||||
|
$pose->load('category');
|
||||||
|
|
||||||
|
// Find similar poses by same category or overlapping benefits keywords
|
||||||
|
$benefits = (string) $pose->benefits;
|
||||||
|
$tokens = collect(preg_split('/[^\p{L}]+/u', mb_strtolower($benefits)))
|
||||||
|
->filter(fn($t) => $t !== null && $t !== '' && mb_strlen($t) >= 4)
|
||||||
|
->unique()
|
||||||
|
->take(8)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$similarQuery = YogaPose::query()
|
||||||
|
->with('category')
|
||||||
|
->where('id', '!=', $pose->id)
|
||||||
|
->where(function ($q) use ($pose, $tokens) {
|
||||||
|
// Same category
|
||||||
|
if ($pose->category_id) {
|
||||||
|
$q->orWhere('category_id', $pose->category_id);
|
||||||
|
}
|
||||||
|
// Overlapping benefits terms
|
||||||
|
foreach ($tokens as $tk) {
|
||||||
|
$q->orWhere('benefits', 'like', "%{$tk}%");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->orderBy('english_name')
|
||||||
|
->limit(9);
|
||||||
|
|
||||||
|
$similarPoses = $similarQuery->get();
|
||||||
|
|
||||||
|
return view('yoga.show', compact('pose', 'similarPoses'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SetLocale
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$locale = $request->query('lang');
|
||||||
|
if ($locale && in_array($locale, ['en', 'ro'], true)) {
|
||||||
|
session(['locale' => $locale]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = session('locale', config('app.locale'));
|
||||||
|
App::setLocale($current);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class WorkoutSession extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'workout_id',
|
||||||
|
'started_at',
|
||||||
|
'completed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workout(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workout::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WorkoutSessionItem::class, 'session_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WorkoutSessionItem extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'session_id',
|
||||||
|
'workout_exercise_id',
|
||||||
|
'done',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'done' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function session(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WorkoutSession::class, 'session_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workoutExercise(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WorkoutExercise::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class YogaCategory extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'external_id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function poses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(YogaPose::class, 'category_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class YogaPose extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'external_id',
|
||||||
|
'category_id',
|
||||||
|
'slug',
|
||||||
|
'english_name',
|
||||||
|
'sanskrit_name_adapted',
|
||||||
|
'sanskrit_name',
|
||||||
|
'translation_name',
|
||||||
|
'description',
|
||||||
|
'benefits',
|
||||||
|
'url_svg',
|
||||||
|
'url_png',
|
||||||
|
'url_svg_alt',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(YogaCategory::class, 'category_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -3,6 +3,7 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use App\Http\Middleware\SetLocale;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -11,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->web([
|
||||||
|
SetLocale::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?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_sessions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('workout_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->timestamp('started_at')->nullable();
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'workout_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('workout_sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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_session_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('session_id')->constrained('workout_sessions')->cascadeOnDelete();
|
||||||
|
$table->foreignId('workout_exercise_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->boolean('done')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['session_id', 'workout_exercise_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('workout_session_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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::create('yoga_categories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('external_id')->unique();
|
||||||
|
$table->string('name');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('yoga_categories');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?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('yoga_poses', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('external_id')->unique();
|
||||||
|
$table->foreignId('category_id')->constrained('yoga_categories')->cascadeOnDelete();
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('english_name');
|
||||||
|
$table->string('sanskrit_name_adapted')->nullable();
|
||||||
|
$table->string('sanskrit_name')->nullable();
|
||||||
|
$table->string('translation_name')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->text('benefits')->nullable();
|
||||||
|
$table->string('url_svg')->nullable();
|
||||||
|
$table->string('url_png')->nullable();
|
||||||
|
$table->string('url_svg_alt')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['category_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('yoga_poses');
|
||||||
|
}
|
||||||
|
};
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nav' => [
|
||||||
|
'exercises' => 'Exercises',
|
||||||
|
'yoga' => 'Yoga',
|
||||||
|
'workouts' => 'Workouts',
|
||||||
|
'blog' => 'Blog',
|
||||||
|
'build_workout' => 'Build a Workout',
|
||||||
|
'profile' => 'Profile',
|
||||||
|
'logout' => 'Log Out',
|
||||||
|
'login' => 'Log in',
|
||||||
|
'register' => 'Sign up',
|
||||||
|
'enable_dark' => 'Enable Dark Mode',
|
||||||
|
'disable_dark' => 'Disable Dark Mode',
|
||||||
|
'language' => 'Language',
|
||||||
|
'english' => 'English',
|
||||||
|
'romanian' => 'Romanian',
|
||||||
|
],
|
||||||
|
|
||||||
|
'home' => [
|
||||||
|
'badge' => 'New • Explore 1,000+ moves',
|
||||||
|
'title' => 'Discover 1,000+ Exercises. Build Your Perfect Workout.',
|
||||||
|
'subtitle' => 'Explore FitAtlas to find exercises by muscle group and equipment, then craft routines that match your goals.',
|
||||||
|
'search_placeholder' => 'Search exercises (e.g. push up, dumbbell, back)',
|
||||||
|
'search' => 'Search',
|
||||||
|
'filters' => [
|
||||||
|
'arms' => 'Arms',
|
||||||
|
'chest' => 'Chest',
|
||||||
|
'legs' => 'Legs',
|
||||||
|
'core' => 'Core',
|
||||||
|
'back' => 'Back',
|
||||||
|
],
|
||||||
|
'featured' => 'Featured Exercises',
|
||||||
|
'browse_all' => 'Browse all',
|
||||||
|
'favorites' => 'Favorites',
|
||||||
|
'favorites_desc' => 'A balanced routine targeting all major muscle groups. Suitable for most levels and easy to adapt with available equipment.',
|
||||||
|
'try_routine' => 'Try This Routine',
|
||||||
|
'footer' => [
|
||||||
|
'about' => 'About',
|
||||||
|
'privacy' => 'Privacy',
|
||||||
|
'terms' => 'Terms',
|
||||||
|
'contact' => 'Contact',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'workouts' => [
|
||||||
|
'my_workouts' => 'My Workouts',
|
||||||
|
'manage_desc' => 'Create routines and manage your exercises.',
|
||||||
|
'new_workout' => 'New Workout',
|
||||||
|
'none_yet' => 'No workouts yet. Create your first workout.',
|
||||||
|
'open' => 'Open',
|
||||||
|
'delete' => 'Delete',
|
||||||
|
'confirm_delete' => 'Delete this workout?',
|
||||||
|
|
||||||
|
'create_title' => 'Create Workout',
|
||||||
|
'create_desc' => 'Give your workout a name and optional details.',
|
||||||
|
'title_label' => 'Title',
|
||||||
|
'estimated_minutes' => 'Estimated Minutes',
|
||||||
|
'notes' => 'Notes',
|
||||||
|
'cancel' => 'Cancel',
|
||||||
|
'create_submit' => 'Create Workout',
|
||||||
|
|
||||||
|
'back_to_workouts' => '← Back to Workouts',
|
||||||
|
'favorited' => 'Favorited',
|
||||||
|
'favorite' => 'Favorite',
|
||||||
|
'min' => 'min',
|
||||||
|
'exercises_count' => 'exercises',
|
||||||
|
'exercises' => 'Exercises',
|
||||||
|
'no_exercises_yet' => 'No exercises yet. Add your first one on the right.',
|
||||||
|
'remove_exercise_confirm' => 'Remove this exercise?',
|
||||||
|
'remove' => 'Remove',
|
||||||
|
'edit' => 'Edit',
|
||||||
|
'position_ph' => 'Position',
|
||||||
|
'sets' => 'Sets',
|
||||||
|
'reps' => 'Reps',
|
||||||
|
'weight' => 'Weight (kg)',
|
||||||
|
'rest' => 'Rest (s)',
|
||||||
|
'save' => 'Save',
|
||||||
|
'add_exercise' => 'Add Exercise',
|
||||||
|
'exercise_label' => 'Exercise',
|
||||||
|
'add_exercise_submit' => 'Add Exercise',
|
||||||
|
'rest_suffix' => 's rest',
|
||||||
|
'kg' => 'kg',
|
||||||
|
],
|
||||||
|
|
||||||
|
'exercises' => [
|
||||||
|
'browse' => 'Browse Exercises',
|
||||||
|
'browse_desc' => 'Explore the full FitAtlas library. Filter by body part, muscle, or equipment.',
|
||||||
|
'search' => 'Search',
|
||||||
|
'search_placeholder' => 'Search by name or instruction',
|
||||||
|
'body_part' => 'Body Part',
|
||||||
|
'muscle' => 'Muscle',
|
||||||
|
'equipment' => 'Equipment',
|
||||||
|
'any' => 'Any',
|
||||||
|
'reset' => 'Reset',
|
||||||
|
'apply_filters' => 'Apply Filters',
|
||||||
|
'showing' => 'Showing',
|
||||||
|
'exercises_noun' => 'exercises',
|
||||||
|
'per_page' => 'Per page',
|
||||||
|
'none_found' => 'No exercises found. Try adjusting your filters.',
|
||||||
|
'similar' => 'Similar exercises',
|
||||||
|
'similar_none' => 'No similar exercises found.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sessions' => [
|
||||||
|
'start' => 'Start Session',
|
||||||
|
'back_to_workout' => 'Back to workout',
|
||||||
|
'progress' => 'Progress',
|
||||||
|
'mark_complete' => 'Mark as complete',
|
||||||
|
'mark_complete_confirm' => 'Mark this session as complete?',
|
||||||
|
'no_items' => 'No exercises in this workout.',
|
||||||
|
'sets' => 'sets',
|
||||||
|
'reps' => 'reps',
|
||||||
|
'rest' => 'rest',
|
||||||
|
'completed' => 'Workout session completed! Great job!'
|
||||||
|
],
|
||||||
|
|
||||||
|
'dashboard' => [
|
||||||
|
'title' => 'Your Dashboard',
|
||||||
|
'subtitle' => 'See your workouts and training progress at a glance.',
|
||||||
|
'metrics' => [
|
||||||
|
'workouts' => 'Workouts',
|
||||||
|
'sessions' => 'Sessions',
|
||||||
|
'completed' => 'Completed',
|
||||||
|
'completion_rate' => 'Completion rate',
|
||||||
|
],
|
||||||
|
'recent_sessions' => 'Recent Sessions',
|
||||||
|
'no_sessions' => 'No sessions yet. Start a workout to track your progress.',
|
||||||
|
'completed' => 'Completed',
|
||||||
|
'view_session' => 'View session',
|
||||||
|
'activity_30d' => 'Last 30 Days Activity',
|
||||||
|
'activity_30d_help' => 'Number of sessions per day (higher is better).',
|
||||||
|
],
|
||||||
|
|
||||||
|
'yoga' => [
|
||||||
|
'browse' => 'Browse Yoga Poses',
|
||||||
|
'browse_desc' => 'Explore yoga poses by category or benefits.',
|
||||||
|
'search' => 'Search',
|
||||||
|
'search_placeholder' => 'Search by English or Sanskrit name',
|
||||||
|
'category' => 'Category',
|
||||||
|
'benefit' => 'Benefit contains',
|
||||||
|
'benefit_placeholder' => 'e.g. stress, digestion',
|
||||||
|
'any' => 'Any',
|
||||||
|
'reset' => 'Reset',
|
||||||
|
'apply_filters' => 'Apply Filters',
|
||||||
|
'showing' => 'Showing',
|
||||||
|
'poses_noun' => 'poses',
|
||||||
|
'none_found' => 'No poses found. Try adjusting your filters.',
|
||||||
|
'no_image' => 'No image',
|
||||||
|
'back' => 'Back',
|
||||||
|
'description' => 'Description',
|
||||||
|
'no_description' => 'No description available.',
|
||||||
|
'benefits' => 'Benefits',
|
||||||
|
'no_benefits' => 'No documented benefits.',
|
||||||
|
'pose_info' => 'Pose Info',
|
||||||
|
'translation' => 'Translation',
|
||||||
|
'sanskrit' => 'Sanskrit',
|
||||||
|
'sanskrit_adapted' => 'Sanskrit (adapted)',
|
||||||
|
'browse_all' => 'Browse all poses',
|
||||||
|
'similar' => 'Similar poses',
|
||||||
|
'similar_none' => 'No similar poses found.',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used during authentication for various
|
||||||
|
| messages that we need to display to the user. You are free to modify
|
||||||
|
| these language lines according to your application's requirements.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => 'These credentials do not match our records.',
|
||||||
|
'password' => 'The provided password is incorrect.',
|
||||||
|
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pagination Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used by the paginator library to build
|
||||||
|
| the simple pagination links. You are free to change them to anything
|
||||||
|
| you want to customize your views to better match your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'previous' => '« Previous',
|
||||||
|
'next' => 'Next »',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Reset Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are the default lines which match reasons
|
||||||
|
| that are given by the password broker for a password update attempt
|
||||||
|
| outcome such as failure due to an invalid password / reset token.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'reset' => 'Your password has been reset.',
|
||||||
|
'sent' => 'We have emailed your password reset link.',
|
||||||
|
'throttled' => 'Please wait before retrying.',
|
||||||
|
'token' => 'This password reset token is invalid.',
|
||||||
|
'user' => "We can't find a user with that email address.",
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines contain the default error messages used by
|
||||||
|
| the validator class. Some of these rules have multiple versions such
|
||||||
|
| as the size rules. Feel free to tweak each of these messages here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'accepted' => 'The :attribute field must be accepted.',
|
||||||
|
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
|
||||||
|
'active_url' => 'The :attribute field must be a valid URL.',
|
||||||
|
'after' => 'The :attribute field must be a date after :date.',
|
||||||
|
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
|
||||||
|
'alpha' => 'The :attribute field must only contain letters.',
|
||||||
|
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
|
||||||
|
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
|
||||||
|
'any_of' => 'The :attribute field is invalid.',
|
||||||
|
'array' => 'The :attribute field must be an array.',
|
||||||
|
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
|
||||||
|
'before' => 'The :attribute field must be a date before :date.',
|
||||||
|
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
|
||||||
|
'between' => [
|
||||||
|
'array' => 'The :attribute field must have between :min and :max items.',
|
||||||
|
'file' => 'The :attribute field must be between :min and :max kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be between :min and :max.',
|
||||||
|
'string' => 'The :attribute field must be between :min and :max characters.',
|
||||||
|
],
|
||||||
|
'boolean' => 'The :attribute field must be true or false.',
|
||||||
|
'can' => 'The :attribute field contains an unauthorized value.',
|
||||||
|
'confirmed' => 'The :attribute field confirmation does not match.',
|
||||||
|
'contains' => 'The :attribute field is missing a required value.',
|
||||||
|
'current_password' => 'The password is incorrect.',
|
||||||
|
'date' => 'The :attribute field must be a valid date.',
|
||||||
|
'date_equals' => 'The :attribute field must be a date equal to :date.',
|
||||||
|
'date_format' => 'The :attribute field must match the format :format.',
|
||||||
|
'decimal' => 'The :attribute field must have :decimal decimal places.',
|
||||||
|
'declined' => 'The :attribute field must be declined.',
|
||||||
|
'declined_if' => 'The :attribute field must be declined when :other is :value.',
|
||||||
|
'different' => 'The :attribute field and :other must be different.',
|
||||||
|
'digits' => 'The :attribute field must be :digits digits.',
|
||||||
|
'digits_between' => 'The :attribute field must be between :min and :max digits.',
|
||||||
|
'dimensions' => 'The :attribute field has invalid image dimensions.',
|
||||||
|
'distinct' => 'The :attribute field has a duplicate value.',
|
||||||
|
'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.',
|
||||||
|
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
|
||||||
|
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
|
||||||
|
'email' => 'The :attribute field must be a valid email address.',
|
||||||
|
'ends_with' => 'The :attribute field must end with one of the following: :values.',
|
||||||
|
'enum' => 'The selected :attribute is invalid.',
|
||||||
|
'exists' => 'The selected :attribute is invalid.',
|
||||||
|
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
|
||||||
|
'file' => 'The :attribute field must be a file.',
|
||||||
|
'filled' => 'The :attribute field must have a value.',
|
||||||
|
'gt' => [
|
||||||
|
'array' => 'The :attribute field must have more than :value items.',
|
||||||
|
'file' => 'The :attribute field must be greater than :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be greater than :value.',
|
||||||
|
'string' => 'The :attribute field must be greater than :value characters.',
|
||||||
|
],
|
||||||
|
'gte' => [
|
||||||
|
'array' => 'The :attribute field must have :value items or more.',
|
||||||
|
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be greater than or equal to :value.',
|
||||||
|
'string' => 'The :attribute field must be greater than or equal to :value characters.',
|
||||||
|
],
|
||||||
|
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
|
||||||
|
'image' => 'The :attribute field must be an image.',
|
||||||
|
'in' => 'The selected :attribute is invalid.',
|
||||||
|
'in_array' => 'The :attribute field must exist in :other.',
|
||||||
|
'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.',
|
||||||
|
'integer' => 'The :attribute field must be an integer.',
|
||||||
|
'ip' => 'The :attribute field must be a valid IP address.',
|
||||||
|
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
|
||||||
|
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
|
||||||
|
'json' => 'The :attribute field must be a valid JSON string.',
|
||||||
|
'list' => 'The :attribute field must be a list.',
|
||||||
|
'lowercase' => 'The :attribute field must be lowercase.',
|
||||||
|
'lt' => [
|
||||||
|
'array' => 'The :attribute field must have less than :value items.',
|
||||||
|
'file' => 'The :attribute field must be less than :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be less than :value.',
|
||||||
|
'string' => 'The :attribute field must be less than :value characters.',
|
||||||
|
],
|
||||||
|
'lte' => [
|
||||||
|
'array' => 'The :attribute field must not have more than :value items.',
|
||||||
|
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be less than or equal to :value.',
|
||||||
|
'string' => 'The :attribute field must be less than or equal to :value characters.',
|
||||||
|
],
|
||||||
|
'mac_address' => 'The :attribute field must be a valid MAC address.',
|
||||||
|
'max' => [
|
||||||
|
'array' => 'The :attribute field must not have more than :max items.',
|
||||||
|
'file' => 'The :attribute field must not be greater than :max kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must not be greater than :max.',
|
||||||
|
'string' => 'The :attribute field must not be greater than :max characters.',
|
||||||
|
],
|
||||||
|
'max_digits' => 'The :attribute field must not have more than :max digits.',
|
||||||
|
'mimes' => 'The :attribute field must be a file of type: :values.',
|
||||||
|
'mimetypes' => 'The :attribute field must be a file of type: :values.',
|
||||||
|
'min' => [
|
||||||
|
'array' => 'The :attribute field must have at least :min items.',
|
||||||
|
'file' => 'The :attribute field must be at least :min kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be at least :min.',
|
||||||
|
'string' => 'The :attribute field must be at least :min characters.',
|
||||||
|
],
|
||||||
|
'min_digits' => 'The :attribute field must have at least :min digits.',
|
||||||
|
'missing' => 'The :attribute field must be missing.',
|
||||||
|
'missing_if' => 'The :attribute field must be missing when :other is :value.',
|
||||||
|
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
|
||||||
|
'missing_with' => 'The :attribute field must be missing when :values is present.',
|
||||||
|
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
|
||||||
|
'multiple_of' => 'The :attribute field must be a multiple of :value.',
|
||||||
|
'not_in' => 'The selected :attribute is invalid.',
|
||||||
|
'not_regex' => 'The :attribute field format is invalid.',
|
||||||
|
'numeric' => 'The :attribute field must be a number.',
|
||||||
|
'password' => [
|
||||||
|
'letters' => 'The :attribute field must contain at least one letter.',
|
||||||
|
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
|
||||||
|
'numbers' => 'The :attribute field must contain at least one number.',
|
||||||
|
'symbols' => 'The :attribute field must contain at least one symbol.',
|
||||||
|
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
|
||||||
|
],
|
||||||
|
'present' => 'The :attribute field must be present.',
|
||||||
|
'present_if' => 'The :attribute field must be present when :other is :value.',
|
||||||
|
'present_unless' => 'The :attribute field must be present unless :other is :value.',
|
||||||
|
'present_with' => 'The :attribute field must be present when :values is present.',
|
||||||
|
'present_with_all' => 'The :attribute field must be present when :values are present.',
|
||||||
|
'prohibited' => 'The :attribute field is prohibited.',
|
||||||
|
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
|
||||||
|
'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
|
||||||
|
'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
|
||||||
|
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
|
||||||
|
'prohibits' => 'The :attribute field prohibits :other from being present.',
|
||||||
|
'regex' => 'The :attribute field format is invalid.',
|
||||||
|
'required' => 'The :attribute field is required.',
|
||||||
|
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
|
||||||
|
'required_if' => 'The :attribute field is required when :other is :value.',
|
||||||
|
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
|
||||||
|
'required_if_declined' => 'The :attribute field is required when :other is declined.',
|
||||||
|
'required_unless' => 'The :attribute field is required unless :other is in :values.',
|
||||||
|
'required_with' => 'The :attribute field is required when :values is present.',
|
||||||
|
'required_with_all' => 'The :attribute field is required when :values are present.',
|
||||||
|
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||||
|
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||||
|
'same' => 'The :attribute field must match :other.',
|
||||||
|
'size' => [
|
||||||
|
'array' => 'The :attribute field must contain :size items.',
|
||||||
|
'file' => 'The :attribute field must be :size kilobytes.',
|
||||||
|
'numeric' => 'The :attribute field must be :size.',
|
||||||
|
'string' => 'The :attribute field must be :size characters.',
|
||||||
|
],
|
||||||
|
'starts_with' => 'The :attribute field must start with one of the following: :values.',
|
||||||
|
'string' => 'The :attribute field must be a string.',
|
||||||
|
'timezone' => 'The :attribute field must be a valid timezone.',
|
||||||
|
'unique' => 'The :attribute has already been taken.',
|
||||||
|
'uploaded' => 'The :attribute failed to upload.',
|
||||||
|
'uppercase' => 'The :attribute field must be uppercase.',
|
||||||
|
'url' => 'The :attribute field must be a valid URL.',
|
||||||
|
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||||
|
'uuid' => 'The :attribute field must be a valid UUID.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Language Lines
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify custom validation messages for attributes using the
|
||||||
|
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||||
|
| specify a specific custom language line for a given attribute rule.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'custom' => [
|
||||||
|
'attribute-name' => [
|
||||||
|
'rule-name' => 'custom-message',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Custom Validation Attributes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following language lines are used to swap our attribute placeholder
|
||||||
|
| with something more reader friendly such as "E-Mail Address" instead
|
||||||
|
| of "email". This simply helps us make our message more expressive.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'attributes' => [],
|
||||||
|
|
||||||
|
];
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nav' => [
|
||||||
|
'exercises' => 'Exerciții',
|
||||||
|
'yoga' => 'Yoga',
|
||||||
|
'workouts' => 'Antrenamente',
|
||||||
|
'blog' => 'Blog',
|
||||||
|
'build_workout' => 'Creează un antrenament',
|
||||||
|
'profile' => 'Profil',
|
||||||
|
'logout' => 'Deconectare',
|
||||||
|
'login' => 'Autentificare',
|
||||||
|
'register' => 'Înregistrare',
|
||||||
|
'enable_dark' => 'Activează Modul Închis',
|
||||||
|
'disable_dark' => 'Dezactivează Modul Închis',
|
||||||
|
'language' => 'Limba',
|
||||||
|
'english' => 'Engleză',
|
||||||
|
'romanian' => 'Română',
|
||||||
|
],
|
||||||
|
|
||||||
|
'home' => [
|
||||||
|
'badge' => 'Nou • Descoperă peste 1.000 de mișcări',
|
||||||
|
'title' => 'Descoperă peste 1.000 de exerciții. Construiește antrenamentul perfect.',
|
||||||
|
'subtitle' => 'Explorează FitAtlas pentru a găsi exerciții după grupă musculară și echipament, apoi creează rutine potrivite obiectivelor tale.',
|
||||||
|
'search_placeholder' => 'Caută exerciții (ex: flotări, gantere, spate)',
|
||||||
|
'search' => 'Caută',
|
||||||
|
'filters' => [
|
||||||
|
'arms' => 'Brațe',
|
||||||
|
'chest' => 'Piept',
|
||||||
|
'legs' => 'Picioare',
|
||||||
|
'core' => 'Trunchi',
|
||||||
|
'back' => 'Spate',
|
||||||
|
],
|
||||||
|
'featured' => 'Exerciții recomandate',
|
||||||
|
'browse_all' => 'Vezi toate',
|
||||||
|
'favorites' => 'Favorite',
|
||||||
|
'favorites_desc' => 'O rutină echilibrată care vizează toate grupele musculare principale. Potrivită pentru majoritatea nivelurilor și ușor de adaptat în funcție de echipamentul disponibil.',
|
||||||
|
'try_routine' => 'Încearcă această rutină',
|
||||||
|
'footer' => [
|
||||||
|
'about' => 'Despre',
|
||||||
|
'privacy' => 'Confidențialitate',
|
||||||
|
'terms' => 'Termeni',
|
||||||
|
'contact' => 'Contact',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'workouts' => [
|
||||||
|
'my_workouts' => 'Antrenamentele mele',
|
||||||
|
'manage_desc' => 'Creează rutine și gestionează exercițiile.',
|
||||||
|
'new_workout' => 'Antrenament nou',
|
||||||
|
'none_yet' => 'Încă nu există antrenamente. Creează primul tău antrenament.',
|
||||||
|
'open' => 'Deschide',
|
||||||
|
'delete' => 'Șterge',
|
||||||
|
'confirm_delete' => 'Ștergi acest antrenament?',
|
||||||
|
|
||||||
|
'create_title' => 'Creează antrenament',
|
||||||
|
'create_desc' => 'Dă un nume antrenamentului și, opțional, adaugă detalii.',
|
||||||
|
'title_label' => 'Titlu',
|
||||||
|
'estimated_minutes' => 'Minute estimate',
|
||||||
|
'notes' => 'Notițe',
|
||||||
|
'cancel' => 'Anulează',
|
||||||
|
'create_submit' => 'Creează antrenament',
|
||||||
|
|
||||||
|
'back_to_workouts' => '← Înapoi la Antrenamente',
|
||||||
|
'favorited' => 'La favorite',
|
||||||
|
'favorite' => 'Adaugă la favorite',
|
||||||
|
'min' => 'min',
|
||||||
|
'exercises_count' => 'exerciții',
|
||||||
|
'exercises' => 'Exerciții',
|
||||||
|
'no_exercises_yet' => 'Încă nu există exerciții. Adaugă primul în dreapta.',
|
||||||
|
'remove_exercise_confirm' => 'Elimini acest exercițiu?',
|
||||||
|
'remove' => 'Elimină',
|
||||||
|
'edit' => 'Editează',
|
||||||
|
'position_ph' => 'Poziție',
|
||||||
|
'sets' => 'Seturi',
|
||||||
|
'reps' => 'Repetări',
|
||||||
|
'weight' => 'Greutate (kg)',
|
||||||
|
'rest' => 'Pauză (s)',
|
||||||
|
'save' => 'Salvează',
|
||||||
|
'add_exercise' => 'Adaugă exercițiu',
|
||||||
|
'exercise_label' => 'Exercițiu',
|
||||||
|
'add_exercise_submit' => 'Adaugă exercițiu',
|
||||||
|
'rest_suffix' => 's pauză',
|
||||||
|
'kg' => 'kg',
|
||||||
|
],
|
||||||
|
|
||||||
|
'exercises' => [
|
||||||
|
'browse' => 'Răsfoiește exercițiile',
|
||||||
|
'browse_desc' => 'Explorează întreaga bibliotecă FitAtlas. Filtrează după zonă, mușchi sau echipament.',
|
||||||
|
'search' => 'Căutare',
|
||||||
|
'search_placeholder' => 'Caută după nume sau instrucțiuni',
|
||||||
|
'body_part' => 'Zonă',
|
||||||
|
'muscle' => 'Mușchi',
|
||||||
|
'equipment' => 'Echipament',
|
||||||
|
'any' => 'Oricare',
|
||||||
|
'reset' => 'Resetează',
|
||||||
|
'apply_filters' => 'Aplică filtrele',
|
||||||
|
'showing' => 'Se afișează',
|
||||||
|
'exercises_noun' => 'exerciții',
|
||||||
|
'per_page' => 'Pe pagină',
|
||||||
|
'none_found' => 'Nu s-au găsit exerciții. Încearcă să ajustezi filtrele.',
|
||||||
|
'similar' => 'Exerciții similare',
|
||||||
|
'similar_none' => 'Nu s-au găsit exerciții similare.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sessions' => [
|
||||||
|
'start' => 'Începe sesiunea',
|
||||||
|
'back_to_workout' => 'Înapoi la antrenament',
|
||||||
|
'progress' => 'Progres',
|
||||||
|
'mark_complete' => 'Marchează ca finalizată',
|
||||||
|
'mark_complete_confirm' => 'Marchezi această sesiune ca finalizată?',
|
||||||
|
'no_items' => 'Nu există exerciții în acest antrenament.',
|
||||||
|
'sets' => 'seturi',
|
||||||
|
'reps' => 'repetări',
|
||||||
|
'rest' => 'pauză',
|
||||||
|
'completed' => 'Sesiune de antrenament finalizată! Bravo!'
|
||||||
|
],
|
||||||
|
|
||||||
|
'dashboard' => [
|
||||||
|
'title' => 'Tabloul tău de bord',
|
||||||
|
'subtitle' => 'Vezi pe scurt antrenamentele și progresul tău.',
|
||||||
|
'metrics' => [
|
||||||
|
'workouts' => 'Antrenamente',
|
||||||
|
'sessions' => 'Sesiuni',
|
||||||
|
'completed' => 'Finalizate',
|
||||||
|
'completion_rate' => 'Rată de finalizare',
|
||||||
|
],
|
||||||
|
'recent_sessions' => 'Sesiuni recente',
|
||||||
|
'no_sessions' => 'Nu există sesiuni încă. Î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).',
|
||||||
|
],
|
||||||
|
|
||||||
|
'yoga' => [
|
||||||
|
'browse' => 'Răsfoiește pozițiile de yoga',
|
||||||
|
'browse_desc' => 'Explorează pozițiile de yoga după categorie sau beneficii.',
|
||||||
|
'search' => 'Căutare',
|
||||||
|
'search_placeholder' => 'Caută după nume în engleză sau sanscrită',
|
||||||
|
'category' => 'Categorie',
|
||||||
|
'benefit' => 'Beneficiu conține',
|
||||||
|
'benefit_placeholder' => 'ex: stres, digestie',
|
||||||
|
'any' => 'Oricare',
|
||||||
|
'reset' => 'Resetează',
|
||||||
|
'apply_filters' => 'Aplică filtrele',
|
||||||
|
'showing' => 'Se afișează',
|
||||||
|
'poses_noun' => 'poziții',
|
||||||
|
'none_found' => 'Nu s-au găsit poziții. Încearcă să ajustezi filtrele.',
|
||||||
|
'no_image' => 'Fără imagine',
|
||||||
|
'back' => 'Înapoi',
|
||||||
|
'description' => 'Descriere',
|
||||||
|
'no_description' => 'Nu există descriere disponibilă.',
|
||||||
|
'benefits' => 'Beneficii',
|
||||||
|
'no_benefits' => 'Nu există beneficii documentate.',
|
||||||
|
'pose_info' => 'Informații despre poziție',
|
||||||
|
'translation' => 'Traducere',
|
||||||
|
'sanskrit' => 'Sanscrită',
|
||||||
|
'sanskrit_adapted' => 'Sanscrită (adaptată)',
|
||||||
|
'browse_all' => 'Vezi toate pozițiile',
|
||||||
|
'similar' => 'Poziții similare',
|
||||||
|
'similar_none' => 'Nu s-au găsit poziții similare.',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -1,17 +1,92 @@
|
|||||||
<x-app-layout>
|
<x-app-layout>
|
||||||
<x-slot name="header">
|
<section class="bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{{ __('Dashboard') }}
|
<div class="flex items-center justify-between">
|
||||||
</h2>
|
<div>
|
||||||
</x-slot>
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ __('app.dashboard.title') }}</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-300">{{ __('app.dashboard.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="py-12">
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
|
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||||
<div class="p-6 text-gray-900 dark:text-gray-100">
|
<div class="text-sm text-gray-500">{{ __('app.dashboard.metrics.workouts') }}</div>
|
||||||
{{ __("You're logged in!") }}
|
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $workoutsCount }}</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.dashboard.metrics.sessions') }}</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $sessionsCount }}</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.dashboard.metrics.completed') }}</div>
|
||||||
|
<div class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{{ $completedSessions }}</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="flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-500">{{ __('app.dashboard.metrics.completion_rate') }}</div>
|
||||||
|
<div class="text-sm font-semibold text-indigo-600">{{ $completionRate }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||||
|
<div class="h-2 bg-gradient-to-r from-emerald-500 to-teal-500" style="width: {{ $completionRate }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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.dashboard.recent_sessions') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
@if($recentSessions->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.dashboard.no_sessions') }}</div>
|
||||||
|
@else
|
||||||
|
<ul class="mt-4 space-y-4">
|
||||||
|
@foreach($recentSessions as $s)
|
||||||
|
<li class="rounded-xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-900 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-base font-semibold text-gray-900 dark:text-white">{{ $s['workout_title'] }}</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $s['done'] }} / {{ $s['total'] }} · {{ $s['pct'] }}%
|
||||||
|
@if($s['completed_at'])
|
||||||
|
· <span class="text-emerald-600">{{ __('app.dashboard.completed') }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-2 w-56 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||||
|
<div class="h-2 bg-indigo-500" style="width: {{ $s['pct'] }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('sessions.show', $s['id']) }}" class="text-sm font-semibold text-indigo-600 hover:text-indigo-500">{{ __('app.dashboard.view_session') }}</a>
|
||||||
|
</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.dashboard.activity_30d') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">{{ __('app.dashboard.activity_30d_help') }}</p>
|
||||||
|
@php
|
||||||
|
$today = now()->startOfDay();
|
||||||
|
$days = [];
|
||||||
|
for ($i = 29; $i >= 0; $i--) { $d = $today->copy()->subDays($i)->format('Y-m-d'); $days[] = $d; }
|
||||||
|
$max = max(1, $dailyActivity->max() ?? 0);
|
||||||
|
@endphp
|
||||||
|
<div class="mt-4 grid grid-cols-10 gap-2">
|
||||||
|
@foreach($days as $d)
|
||||||
|
@php $count = $dailyActivity[$d] ?? 0; $pct = $max ? intval(($count / $max) * 100) : 0; @endphp
|
||||||
|
<div class="h-14 rounded-lg bg-gray-100 dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-700 flex items-center justify-center">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center">
|
||||||
|
<span class="text-xs font-semibold text-indigo-600">{{ $count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -90,4 +90,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if(isset($similarExercises))
|
||||||
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pb-12">
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="flex items-end justify-between gap-4 mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ __('app.exercises.similar') }}</h2>
|
||||||
|
<a href="{{ route('exercises.index') }}" class="text-sm font-semibold text-indigo-600 hover:text-indigo-500">{{ __('app.home.browse_all') }}</a>
|
||||||
|
</div>
|
||||||
|
@if($similarExercises->isEmpty())
|
||||||
|
<div class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-6 text-center text-gray-600 dark:text-gray-300">{{ __('app.exercises.similar_none') }}</div>
|
||||||
|
@else
|
||||||
|
@php $badgePalette = ['bg-rose-100 text-rose-700','bg-amber-100 text-amber-700','bg-emerald-100 text-emerald-700','bg-sky-100 text-sky-700','bg-violet-100 text-violet-700']; @endphp
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@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->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->name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="mt-1 flex flex-wrap gap-2">
|
||||||
|
@foreach($ex->targetMuscles->take(2) as $i => $m)
|
||||||
|
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium {{ $badgePalette[$i % count($badgePalette)] }}">{{ $m->name }}</span>
|
||||||
|
@endforeach
|
||||||
|
@if($ex->bodyParts->count())
|
||||||
|
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ $ex->bodyParts->first()->name }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -11,41 +11,41 @@
|
|||||||
<div aria-hidden="true" class="pointer-events-none absolute -bottom-24 -right-24 h-72 w-72 rounded-full bg-cyan-400/30 blur-3xl"></div>
|
<div aria-hidden="true" class="pointer-events-none absolute -bottom-24 -right-24 h-72 w-72 rounded-full bg-cyan-400/30 blur-3xl"></div>
|
||||||
|
|
||||||
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-20 sm:py-28 text-center">
|
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-20 sm:py-28 text-center">
|
||||||
<span class="inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white ring-1 ring-white/20 backdrop-blur">New • Explore 1,000+ moves</span>
|
<span class="inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white ring-1 ring-white/20 backdrop-blur">{{ __('app.home.badge') }}</span>
|
||||||
<h1 class="mt-4 text-4xl sm:text-6xl font-extrabold tracking-tight text-white drop-shadow">
|
<h1 class="mt-4 text-4xl sm:text-6xl font-extrabold tracking-tight text-white drop-shadow">
|
||||||
Discover 1,000+ Exercises. Build Your Perfect Workout.
|
{{ __('app.home.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-4 text-lg text-indigo-100 max-w-2xl mx-auto">
|
<p class="mt-4 text-lg text-indigo-100 max-w-2xl mx-auto">
|
||||||
Explore FitAtlas to find exercises by muscle group and equipment, then craft routines that match your goals.
|
{{ __('app.home.subtitle') }}
|
||||||
</p>
|
</p>
|
||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
<form action="{{ route('exercises.index') }}" method="GET" class="mt-8 max-w-2xl mx-auto">
|
<form action="{{ route('exercises.index') }}" method="GET" class="mt-8 max-w-2xl mx-auto">
|
||||||
<div class="flex rounded-2xl shadow-lg ring-1 ring-white/20 focus-within:ring-2 focus-within:ring-fuchsia-400/80 overflow-hidden bg-white/90 backdrop-blur">
|
<div class="flex rounded-2xl shadow-lg ring-1 ring-white/20 focus-within:ring-2 focus-within:ring-fuchsia-400/80 overflow-hidden bg-white/90 backdrop-blur">
|
||||||
<input type="text" name="q" placeholder="Search exercises (e.g. push up, dumbbell, back)" class="w-full px-4 py-3 text-gray-900 placeholder-gray-500 bg-transparent focus:outline-none">
|
<input type="text" name="q" placeholder="{{ __('app.home.search_placeholder') }}" class="w-full px-4 py-3 text-gray-900 placeholder-gray-500 bg-transparent focus:outline-none">
|
||||||
<button type="submit" class="px-5 py-3 bg-gradient-to-r from-fuchsia-600 to-indigo-600 hover:from-fuchsia-500 hover:to-indigo-500 text-white font-semibold">Search</button>
|
<button type="submit" class="px-5 py-3 bg-gradient-to-r from-fuchsia-600 to-indigo-600 hover:from-fuchsia-500 hover:to-indigo-500 text-white font-semibold">{{ __('app.home.search') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<!-- Quick Filters -->
|
<!-- Quick Filters -->
|
||||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||||
@php
|
@php
|
||||||
$filters = ['Arms', 'Chest', 'Legs', 'Core', 'Back'];
|
$filters = ['arms', 'chest', 'legs', 'core', 'back'];
|
||||||
$filterColors = [
|
$filterColors = [
|
||||||
'Arms' => 'from-amber-400 to-orange-500',
|
'arms' => 'from-amber-400 to-orange-500',
|
||||||
'Chest' => 'from-rose-400 to-pink-500',
|
'chest' => 'from-rose-400 to-pink-500',
|
||||||
'Legs' => 'from-emerald-400 to-teal-500',
|
'legs' => 'from-emerald-400 to-teal-500',
|
||||||
'Core' => 'from-sky-400 to-cyan-500',
|
'core' => 'from-sky-400 to-cyan-500',
|
||||||
'Back' => 'from-violet-400 to-indigo-500',
|
'back' => 'from-violet-400 to-indigo-500',
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
@foreach($filters as $filter)
|
@foreach($filters as $filter)
|
||||||
<a href="{{ route('exercises.index') }}?muscle={{ strtolower($filter) }}" class="inline-flex items-center rounded-full bg-gradient-to-r {{ $filterColors[$filter] }} px-4 py-1.5 text-sm font-semibold text-white shadow hover:opacity-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/60">
|
<a href="{{ route('exercises.index') }}?muscle={{ $filter }}" class="inline-flex items-center rounded-full bg-gradient-to-r {{ $filterColors[$filter] }} px-4 py-1.5 text-sm font-semibold text-white shadow hover:opacity-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/60">
|
||||||
{{ $filter }}
|
{{ __('app.home.filters.' . $filter) }}
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<a href="{{ route('workouts.builder') }}" class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-5 py-3 text-sm sm:text-base font-semibold text-white shadow-lg hover:from-fuchsia-500 hover:to-indigo-500">
|
<a href="{{ route('workouts.builder') }}" class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-5 py-3 text-sm sm:text-base font-semibold text-white shadow-lg hover:from-fuchsia-500 hover:to-indigo-500">
|
||||||
Build a Workout
|
{{ __('app.nav.build_workout') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,8 +54,8 @@
|
|||||||
<!-- Featured Exercises -->
|
<!-- Featured Exercises -->
|
||||||
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12 sm:py-16">
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12 sm:py-16">
|
||||||
<div class="flex items-end justify-between gap-4 mb-6">
|
<div class="flex items-end justify-between gap-4 mb-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Featured Exercises</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ __('app.home.featured') }}</h2>
|
||||||
<a href="{{ route('exercises.index') }}" class="text-sm font-semibold text-indigo-600 hover:text-indigo-500">Browse all</a>
|
<a href="{{ route('exercises.index') }}" class="text-sm font-semibold text-indigo-600 hover:text-indigo-500">{{ __('app.home.browse_all') }}</a>
|
||||||
</div>
|
</div>
|
||||||
@php
|
@php
|
||||||
$exercises = $exercises ?? [
|
$exercises = $exercises ?? [
|
||||||
@@ -96,9 +96,9 @@
|
|||||||
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl sm:text-3xl font-extrabold text-gray-900 dark:text-white">Favorites</h2>
|
<h2 class="text-2xl sm:text-3xl font-extrabold text-gray-900 dark:text-white">{{ __('app.home.favorites') }}</h2>
|
||||||
<p class="mt-3 text-gray-600 dark:text-gray-300">A balanced routine targeting all major muscle groups. Suitable for most levels and easy to adapt with available equipment.</p>
|
<p class="mt-3 text-gray-600 dark:text-gray-300">{{ __('app.home.favorites_desc') }}</p>
|
||||||
<a href="{{ route('workouts.builder') }}" class="mt-6 inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-5 py-3 text-white font-semibold shadow-lg hover:from-fuchsia-500 hover:to-indigo-500">Try This Routine</a>
|
<a href="{{ route('workouts.builder') }}" class="mt-6 inline-flex items-center rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-5 py-3 text-white font-semibold shadow-lg hover:from-fuchsia-500 hover:to-indigo-500">{{ __('app.home.try_routine') }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative h-64 sm:h-80">
|
<div class="relative h-64 sm:h-80">
|
||||||
<div class="absolute inset-0 rounded-3xl overflow-hidden ring-1 ring-gray-200 dark:ring-gray-700">
|
<div class="absolute inset-0 rounded-3xl overflow-hidden ring-1 ring-gray-200 dark:ring-gray-700">
|
||||||
@@ -116,10 +116,10 @@
|
|||||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">© {{ date('Y') }} FitAtlas</div>
|
<div class="text-sm text-gray-600 dark:text-gray-300">© {{ date('Y') }} FitAtlas</div>
|
||||||
<div class="flex items-center gap-6 text-sm">
|
<div class="flex items-center gap-6 text-sm">
|
||||||
<a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">About</a>
|
<a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.home.footer.about') }}</a>
|
||||||
<a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Privacy</a>
|
<a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.home.footer.privacy') }}</a>
|
||||||
<a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Terms</a>
|
<a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.home.footer.terms') }}</a>
|
||||||
<a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Contact</a>
|
<a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.home.footer.contact') }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="#" aria-label="Twitter" class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">
|
<a href="#" aria-label="Twitter" class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white">
|
||||||
|
|||||||
@@ -47,5 +47,6 @@
|
|||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@stack('scripts')
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
|
|
||||||
<!-- Navigation Links -->
|
<!-- Navigation Links -->
|
||||||
<div class="hidden sm:flex items-center gap-6">
|
<div class="hidden sm:flex items-center gap-6">
|
||||||
<a href="{{ route('exercises.index') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Exercises</a>
|
<a href="{{ route('exercises.index') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.exercises') }}</a>
|
||||||
<a href="{{ route('workouts.index', [], false) }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Workouts</a>
|
<a href="{{ route('yoga.index') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.yoga') }}</a>
|
||||||
<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">Blog</a>
|
<a href="{{ route('workouts.index', [], false) }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.workouts') }}</a>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,7 +25,12 @@
|
|||||||
<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 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>
|
<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>
|
</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>
|
<div class="hidden sm:flex items-center gap-2">
|
||||||
|
<a href="{{ request()->fullUrlWithQuery(['lang' => 'en']) }}" class="text-xs font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900">EN</a>
|
||||||
|
<span class="text-gray-300">|</span>
|
||||||
|
<a href="{{ request()->fullUrlWithQuery(['lang' => 'ro']) }}" class="text-xs font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900">RO</a>
|
||||||
|
</div>
|
||||||
|
<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">{{ __('app.nav.build_workout') }}</a>
|
||||||
@auth
|
@auth
|
||||||
<x-dropdown align="right" width="48">
|
<x-dropdown align="right" width="48">
|
||||||
<x-slot name="trigger">
|
<x-slot name="trigger">
|
||||||
@@ -46,9 +52,9 @@
|
|||||||
</x-slot>
|
</x-slot>
|
||||||
</x-dropdown>
|
</x-dropdown>
|
||||||
@else
|
@else
|
||||||
<a href="{{ route('login') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Log in</a>
|
<a href="{{ route('login') }}" class="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.login') }}</a>
|
||||||
@if (Route::has('register'))
|
@if (Route::has('register'))
|
||||||
<a href="{{ route('register') }}" class="text-sm font-semibold text-gray-700 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white">Sign up</a>
|
<a href="{{ route('register') }}" class="text-sm font-semibold text-gray-700 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white">{{ __('app.nav.register') }}</a>
|
||||||
@endif
|
@endif
|
||||||
@endauth
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
@@ -70,14 +76,21 @@
|
|||||||
<div class="pt-2 pb-3 space-y-1 border-t border-gray-100 dark:border-gray-800">
|
<div class="pt-2 pb-3 space-y-1 border-t border-gray-100 dark:border-gray-800">
|
||||||
<div class="px-4 pb-2">
|
<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){}})()">
|
<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="dark:hidden">{{ __('app.nav.enable_dark') }}</span>
|
||||||
<span class="hidden dark:inline">Disable Dark Mode</span>
|
<span class="hidden dark:inline">{{ __('app.nav.disable_dark') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="mt-3 px-4 flex items-center gap-3">
|
||||||
|
<span class="text-xs text-gray-500">{{ __('app.nav.language') }}:</span>
|
||||||
|
<a href="{{ request()->fullUrlWithQuery(['lang' => 'en']) }}" class="text-xs font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900">EN</a>
|
||||||
|
<span class="text-gray-300">|</span>
|
||||||
|
<a href="{{ request()->fullUrlWithQuery(['lang' => 'ro']) }}" class="text-xs font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900">RO</a>
|
||||||
</div>
|
</div>
|
||||||
<x-responsive-nav-link :href="route('exercises.index')">Exercises</x-responsive-nav-link>
|
</div>
|
||||||
<x-responsive-nav-link :href="route('workouts.index', [], false)">Workouts</x-responsive-nav-link>
|
<x-responsive-nav-link :href="route('exercises.index')">{{ __('app.nav.exercises') }}</x-responsive-nav-link>
|
||||||
<x-responsive-nav-link :href="route('blog.index', [], false)">Blog</x-responsive-nav-link>
|
<x-responsive-nav-link :href="route('yoga.index')">{{ __('app.nav.yoga') }}</x-responsive-nav-link>
|
||||||
<x-responsive-nav-link :href="route('workouts.builder')">Build a Workout</x-responsive-nav-link>
|
<x-responsive-nav-link :href="route('workouts.index', [], false)">{{ __('app.nav.workouts') }}</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('blog.index', [], false)">{{ __('app.nav.blog') }}</x-responsive-nav-link>
|
||||||
|
<x-responsive-nav-link :href="route('workouts.builder')">{{ __('app.nav.build_workout') }}</x-responsive-nav-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-800">
|
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-800">
|
||||||
@auth
|
@auth
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">My Workouts</h1>
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ __('app.workouts.my_workouts') }}</h1>
|
||||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Create routines and manage your exercises.</p>
|
<p class="mt-2 text-gray-600 dark:text-gray-300">{{ __('app.workouts.manage_desc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ route('workouts.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 Workout</a>
|
<a href="{{ route('workouts.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">{{ __('app.workouts.new_workout') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="mb-4 rounded-xl bg-emerald-50 text-emerald-700 px-4 py-3">{{ session('status') }}</div>
|
<div class="mb-4 rounded-xl bg-emerald-50 text-emerald-700 px-4 py-3">{{ session('status') }}</div>
|
||||||
@endif
|
@endif
|
||||||
@if($workouts->count() === 0)
|
@if($workouts->count() === 0)
|
||||||
<div class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center text-gray-600 dark:text-gray-300">No workouts yet. Create your first workout.</div>
|
<div class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center text-gray-600 dark:text-gray-300">{{ __('app.workouts.none_yet') }}</div>
|
||||||
@else
|
@else
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@foreach($workouts as $workout)
|
@foreach($workouts as $workout)
|
||||||
@@ -31,11 +31,11 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-4 flex items-center justify-between">
|
||||||
<a href="{{ route('workouts.show', $workout) }}" class="text-sm font-semibold text-indigo-600 hover:text-indigo-500">Open</a>
|
<a href="{{ route('workouts.show', $workout) }}" class="text-sm font-semibold text-indigo-600 hover:text-indigo-500">{{ __('app.workouts.open') }}</a>
|
||||||
<form method="POST" action="{{ route('workouts.destroy', $workout) }}" onsubmit="return confirm('Delete this workout?');">
|
<form method="POST" action="{{ route('workouts.destroy', $workout) }}" onsubmit="return confirm('{{ __('app.workouts.confirm_delete') }}');">
|
||||||
@csrf
|
@csrf
|
||||||
@method('DELETE')
|
@method('DELETE')
|
||||||
<button type="submit" class="text-sm text-red-600 hover:text-red-500">Delete</button>
|
<button type="submit" class="text-sm text-red-600 hover:text-red-500">{{ __('app.workouts.delete') }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<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>
|
||||||
|
<a href="{{ route('workouts.show', $workout) }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">← {{ __('app.sessions.back_to_workout') }}</a>
|
||||||
|
<h1 class="mt-2 text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ $workout->title }}</h1>
|
||||||
|
<p class="mt-1 text-gray-600 dark:text-gray-300">{{ __('app.sessions.progress') }}: {{ $doneByItemId->where('done', true)->count() }} / {{ $workout->items->count() }}</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('sessions.complete', $session) }}" onsubmit="return confirm('{{ __('app.sessions.mark_complete_confirm') }}');">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" 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" title="{{ __('app.sessions.mark_complete') }}" aria-label="{{ __('app.sessions.mark_complete') }}">{{ __('app.sessions.mark_complete') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-5">
|
||||||
|
@if($workout->items->count() === 0)
|
||||||
|
<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.sessions.no_items') }}</div>
|
||||||
|
@else
|
||||||
|
<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->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
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
function updateBtnState(btn, done) {
|
||||||
|
btn.setAttribute('aria-pressed', done ? 'true' : 'false');
|
||||||
|
// Background/border colors
|
||||||
|
if (done) {
|
||||||
|
btn.classList.add('bg-emerald-500','border-emerald-600','text-white');
|
||||||
|
btn.classList.remove('border-gray-300','dark:border-gray-700');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('bg-emerald-500','border-emerald-600','text-white');
|
||||||
|
btn.classList.add('border-gray-300','dark:border-gray-700');
|
||||||
|
}
|
||||||
|
const icon = btn.querySelector('svg');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.toggle('text-white', !!done);
|
||||||
|
icon.classList.toggle('text-transparent', !done);
|
||||||
|
}
|
||||||
|
// Also toggle line-through for exercise name in the same item row
|
||||||
|
const row = btn.closest('li');
|
||||||
|
if (row) {
|
||||||
|
const titleEl = row.querySelector('.exercise-title');
|
||||||
|
if (titleEl) {
|
||||||
|
if (done) {
|
||||||
|
titleEl.classList.add('line-through','text-gray-500','dark:text-gray-400');
|
||||||
|
titleEl.classList.remove('text-gray-900','dark:text-white');
|
||||||
|
} else {
|
||||||
|
titleEl.classList.remove('line-through','text-gray-500','dark:text-gray-400');
|
||||||
|
titleEl.classList.add('text-gray-900','dark:text-white');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e){
|
||||||
|
const btn = e.target.closest('.session-toggle-form button[type="submit"]');
|
||||||
|
if (!btn) return;
|
||||||
|
const form = btn.closest('form.session-toggle-form');
|
||||||
|
if (!form) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Optimistically toggle UI
|
||||||
|
const wasDone = btn.getAttribute('aria-pressed') === 'true';
|
||||||
|
const next = !wasDone;
|
||||||
|
updateBtnState(btn, next);
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRF-TOKEN': form.querySelector('input[name="_token"]').value,
|
||||||
|
'Accept': 'text/html',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(new FormData(form))
|
||||||
|
}).then(function(res){
|
||||||
|
if (!res.ok) throw new Error('Network response was not ok');
|
||||||
|
}).catch(function(){
|
||||||
|
// Revert on error
|
||||||
|
updateBtnState(btn, wasDone);
|
||||||
|
alert('Failed to update. Please try again.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
</x-app-layout>
|
||||||
@@ -25,11 +25,19 @@
|
|||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
<form method="POST" action="{{ route('workouts.destroy', $workout) }}" onsubmit="return confirm('Delete this workout?');">
|
<div class="flex items-center gap-3">
|
||||||
|
@if(Auth::id() === $workout->user_id)
|
||||||
|
<form method="POST" action="{{ route('sessions.store', $workout) }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 px-3 py-2 text-sm font-semibold text-white shadow hover:from-emerald-500 hover:to-teal-500" title="{{ __('app.sessions.start') }}" aria-label="{{ __('app.sessions.start') }}">{{ __('app.sessions.start') }}</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ route('workouts.destroy', $workout) }}" onsubmit="return confirm('{{ __('app.workouts.confirm_delete') }}');">
|
||||||
@csrf
|
@csrf
|
||||||
@method('DELETE')
|
@method('DELETE')
|
||||||
<button type="submit" class="text-sm text-red-600 hover:text-red-500">Delete</button>
|
<button type="submit" class="text-sm text-red-600 hover:text-red-500">{{ __('app.workouts.delete') }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<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-start justify-between flex-col lg:flex-row gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-extrabold tracking-tight text-gray-900 dark:text-white">{{ __('app.yoga.browse') }}</h1>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-300">{{ __('app.yoga.browse_desc') }}</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 lg:grid-cols-12 gap-6">
|
||||||
|
<aside class="lg:col-span-12">
|
||||||
|
<form action="{{ route('yoga.index') }}" method="GET" class="space-y-4 lg:space-y-0 lg:flex lg:flex-wrap lg:items-end lg:gap-4 bg-white dark:bg-gray-800 rounded-2xl p-4 ring-1 ring-gray-200 dark:ring-gray-700">
|
||||||
|
<div class="w-full lg:w-72">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('app.yoga.search') }}</label>
|
||||||
|
<input type="text" name="q" value="{{ $filters['q'] ?? '' }}" placeholder="{{ __('app.yoga.search_placeholder') }}" 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="w-full lg:w-56">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('app.yoga.category') }}</label>
|
||||||
|
<select name="category" 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">
|
||||||
|
<option value="0">{{ __('app.yoga.any') }}</option>
|
||||||
|
@foreach($categories as $c)
|
||||||
|
<option value="{{ $c->id }}" @selected(($filters['category'] ?? 0) == $c->id)>{{ $c->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="w-full lg:w-72">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ __('app.yoga.benefit') }}</label>
|
||||||
|
<input type="text" name="benefit" value="{{ $filters['benefit'] ?? '' }}" placeholder="{{ __('app.yoga.benefit_placeholder') }}" 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="w-full lg:w-auto flex items-center justify-between lg:justify-end gap-2 lg:ml-auto">
|
||||||
|
<a href="{{ route('yoga.index') }}" class="text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">{{ __('app.yoga.reset') }}</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">{{ __('app.yoga.apply_filters') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="lg:col-span-12">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">{{ __('app.yoga.showing') }} <span class="font-medium">{{ $poses->total() }}</span> {{ __('app.yoga.poses_noun') }}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-600 dark:text-gray-300">{{ __('app.exercises.per_page') }}</label>
|
||||||
|
<select name="per_page" form="per-page-form" class="rounded-lg 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-2 py-1">
|
||||||
|
@foreach([12, 24, 36] as $n)
|
||||||
|
<option value="{{ $n }}" @selected(($filters['per_page'] ?? 12) == $n)>{{ $n }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<form id="per-page-form" action="{{ route('yoga.index') }}" method="GET" class="hidden">
|
||||||
|
@foreach(request()->except('per_page') as $k => $v)
|
||||||
|
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
|
||||||
|
@endforeach
|
||||||
|
<input type="hidden" name="per_page" value="{{ $filters['per_page'] ?? 12 }}">
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('change', (e) => {
|
||||||
|
if (e.target && e.target.name === 'per_page') {
|
||||||
|
const form = document.getElementById('per-page-form');
|
||||||
|
form.querySelector('input[name="per_page"]').value = e.target.value;
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($poses->count() === 0)
|
||||||
|
<div class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center text-gray-600 dark:text-gray-300">{{ __('app.yoga.none_found') }}</div>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@foreach($poses as $pose)
|
||||||
|
<a href="{{ route('yoga.show', $pose->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 bg-white">
|
||||||
|
@php $img = $pose->url_png ?: $pose->url_svg ?: $pose->url_svg_alt; @endphp
|
||||||
|
@if($img)
|
||||||
|
<img src="{{ $img }}" alt="{{ $pose->english_name }}" class="h-full w-full object-contain p-4 transition-transform duration-500 group-hover:scale-105">
|
||||||
|
@else
|
||||||
|
<div class="h-full w-full flex items-center justify-center text-gray-400">{{ __('app.yoga.no_image') }}</div>
|
||||||
|
@endif
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/10 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">{{ $pose->english_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($pose->category)->name }}</div>
|
||||||
|
@if($pose->sanskrit_name)
|
||||||
|
<div class="mt-1 text-xs text-gray-500 italic">{{ $pose->sanskrit_name }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">{{ $poses->links() }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</x-app-layout>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<section class="relative overflow-hidden">
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
@php $img = $pose->url_png ?: $pose->url_svg ?: $pose->url_svg_alt; @endphp
|
||||||
|
@if($img)
|
||||||
|
<img src="{{ $img }}" alt="{{ $pose->english_name }}" class="h-full w-full object-contain bg-white">
|
||||||
|
@else
|
||||||
|
<div class="h-full w-full bg-gray-100 dark:bg-gray-800"></div>
|
||||||
|
@endif
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-indigo-900/40 via-indigo-800/30 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12 sm:py-20">
|
||||||
|
<a href="{{ url()->previous() ?? route('yoga.index') }}" class="inline-flex items-center text-sm font-medium text-white/90 hover:text-white">← {{ __('app.yoga.back') }}</a>
|
||||||
|
<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">{{ $pose->english_name }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
@if($pose->category)
|
||||||
|
<span class="inline-flex items-center rounded-full bg-white/90 px-3 py-1 text-xs font-semibold text-gray-900 shadow">{{ $pose->category->name }}</span>
|
||||||
|
@endif
|
||||||
|
@if($pose->sanskrit_name)
|
||||||
|
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-700 px-2.5 py-1 text-xs font-semibold">Sanskrit: {{ $pose->sanskrit_name }}</span>
|
||||||
|
@endif
|
||||||
|
@if($pose->sanskrit_name_adapted)
|
||||||
|
<span class="inline-flex items-center rounded-full bg-sky-100 text-sky-700 px-2.5 py-1 text-xs font-semibold">{{ $pose->sanskrit_name_adapted }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-10">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
<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 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ __('app.yoga.description') }}</h2>
|
||||||
|
<div class="prose prose-indigo dark:prose-invert max-w-none mt-3">
|
||||||
|
@php $desc = trim((string) $pose->description); @endphp
|
||||||
|
@if($desc !== '')
|
||||||
|
@foreach(preg_split('/\r?\n+/', $desc) as $line)
|
||||||
|
@if(trim($line) !== '')<p>{{ $line }}</p>@endif
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<p>{{ __('app.yoga.no_description') }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-3xl overflow-hidden ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ __('app.yoga.benefits') }}</h2>
|
||||||
|
<div class="prose prose-indigo dark:prose-invert max-w-none mt-3">
|
||||||
|
@php $benef = trim((string) $pose->benefits); @endphp
|
||||||
|
@if($benef !== '')
|
||||||
|
@foreach(preg_split('/\r?\n+/', $benef) as $line)
|
||||||
|
@if(trim($line) !== '')<p>{{ $line }}</p>@endif
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<p>{{ __('app.yoga.no_benefits') }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg:col-span-4">
|
||||||
|
<div class="rounded-2xl p-5 ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ __('app.yoga.pose_info') }}</h3>
|
||||||
|
<dl class="mt-3 space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
@if($pose->translation_name)
|
||||||
|
<div class="flex justify-between"><dt>{{ __('app.yoga.translation') }}</dt><dd class="text-right ms-3">{{ $pose->translation_name }}</dd></div>
|
||||||
|
@endif
|
||||||
|
@if($pose->sanskrit_name)
|
||||||
|
<div class="flex justify-between"><dt>{{ __('app.yoga.sanskrit') }}</dt><dd class="text-right ms-3">{{ $pose->sanskrit_name }}</dd></div>
|
||||||
|
@endif
|
||||||
|
@if($pose->sanskrit_name_adapted)
|
||||||
|
<div class="flex justify-between"><dt>{{ __('app.yoga.sanskrit_adapted') }}</dt><dd class="text-right ms-3">{{ $pose->sanskrit_name_adapted }}</dd></div>
|
||||||
|
@endif
|
||||||
|
@if($pose->category)
|
||||||
|
<div class="flex justify-between"><dt>{{ __('app.yoga.category') }}</dt><dd class="text-right ms-3">{{ $pose->category->name }}</dd></div>
|
||||||
|
@endif
|
||||||
|
</dl>
|
||||||
|
<div class="mt-5">
|
||||||
|
<a href="{{ route('yoga.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.yoga.browse_all') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@isset($similarPoses)
|
||||||
|
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pb-12">
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="flex items-end justify-between gap-4 mb-4">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ __('app.yoga.similar') }}</h2>
|
||||||
|
<a href="{{ route('yoga.index') }}" class="text-sm font-semibold text-indigo-600 hover:text-indigo-500">{{ __('app.yoga.browse_all') }}</a>
|
||||||
|
</div>
|
||||||
|
@if($similarPoses->isEmpty())
|
||||||
|
<div class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-6 text-center text-gray-600 dark:text-gray-300">{{ __('app.yoga.similar_none') }}</div>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
@foreach($similarPoses as $p)
|
||||||
|
<a href="{{ route('yoga.show', $p->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 bg-white">
|
||||||
|
@php $img = $p->url_png ?: $p->url_svg ?: $p->url_svg_alt; @endphp
|
||||||
|
@if($img)
|
||||||
|
<img src="{{ $img }}" alt="{{ $p->english_name }}" class="h-full w-full object-contain transition-transform duration-500 group-hover:scale-105">
|
||||||
|
@else
|
||||||
|
<div class="h-full w-full flex items-center justify-center text-sm text-gray-500">{{ __('app.yoga.no_image') }}</div>
|
||||||
|
@endif
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/20 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">{{ $p->english_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="mt-1 flex flex-wrap gap-2">
|
||||||
|
@if($p->category)
|
||||||
|
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">{{ $p->category->name }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endisset
|
||||||
|
</x-app-layout>
|
||||||
+17
-3
@@ -6,9 +6,8 @@ use Illuminate\Support\Facades\Route;
|
|||||||
|
|
||||||
Route::get('/', [HomeController::class, 'index'])->name('home');
|
Route::get('/', [HomeController::class, 'index'])->name('home');
|
||||||
|
|
||||||
Route::get('/dashboard', function () {
|
Route::get('/dashboard', [\App\Http\Controllers\DashboardController::class, 'index'])
|
||||||
return view('dashboard');
|
->middleware(['auth', 'verified'])->name('dashboard');
|
||||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||||
@@ -38,10 +37,25 @@ Route::middleware('auth')->prefix('/workouts')->group(function () {
|
|||||||
Route::post('/{workout}/exercises', [\App\Http\Controllers\WorkoutController::class, 'addExercise'])->name('workouts.exercises.add');
|
Route::post('/{workout}/exercises', [\App\Http\Controllers\WorkoutController::class, 'addExercise'])->name('workouts.exercises.add');
|
||||||
Route::patch('/{workout}/exercises/{item}', [\App\Http\Controllers\WorkoutController::class, 'updateExercise'])->name('workouts.exercises.update');
|
Route::patch('/{workout}/exercises/{item}', [\App\Http\Controllers\WorkoutController::class, 'updateExercise'])->name('workouts.exercises.update');
|
||||||
Route::delete('/{workout}/exercises/{item}', [\App\Http\Controllers\WorkoutController::class, 'removeExercise'])->name('workouts.exercises.remove');
|
Route::delete('/{workout}/exercises/{item}', [\App\Http\Controllers\WorkoutController::class, 'removeExercise'])->name('workouts.exercises.remove');
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
Route::post('/{workout}/sessions', [\App\Http\Controllers\WorkoutSessionController::class, 'store'])->name('sessions.store');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
Route::get('/sessions/{session}', [\App\Http\Controllers\WorkoutSessionController::class, 'show'])->name('sessions.show');
|
||||||
|
Route::patch('/sessions/{session}/items/{item}', [\App\Http\Controllers\WorkoutSessionController::class, 'toggleItem'])->name('sessions.items.toggle');
|
||||||
|
Route::post('/sessions/{session}/complete', [\App\Http\Controllers\WorkoutSessionController::class, 'complete'])->name('sessions.complete');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('/blog')->group(function () {
|
Route::prefix('/blog')->group(function () {
|
||||||
Route::get('/', [\App\Http\Controllers\BlogController::class, 'index'])->name('blog.index');
|
Route::get('/', [\App\Http\Controllers\BlogController::class, 'index'])->name('blog.index');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Yoga poses
|
||||||
|
Route::prefix('/yoga')->group(function () {
|
||||||
|
Route::get('/', [\App\Http\Controllers\YogaPoseController::class, 'index'])->name('yoga.index');
|
||||||
|
Route::get('/{pose:slug}', [\App\Http\Controllers\YogaPoseController::class, 'show'])->name('yoga.show');
|
||||||
|
});
|
||||||
|
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user