Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "functional/exercises",
|
||||
"description": "",
|
||||
"type": "layer",
|
||||
"version": "1.0.0",
|
||||
"require": {
|
||||
"xefi/laravel-osdd": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Functional\\Exercises\\": "src/",
|
||||
"Functional\\Exercises\\Database\\Seeders\\": "database/seeders/",
|
||||
"Functional\\Exercises\\Database\\Factories\\": "database/factories/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Functional\\Exercises\\Providers\\ExercisesServiceProvider"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Base URL publique des médias (RustFS/S3). Ex: https://s3.nfteam.ovh/streakfit
|
||||
'media_base_url' => env('MEDIA_BASE_URL', 'https://s3.nfteam.ovh/streakfit'),
|
||||
|
||||
// Préfixe des objets média dans le bucket.
|
||||
'media_prefix' => env('EXERCISES_MEDIA_PREFIX', 'exercises'),
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Database\Factories;
|
||||
|
||||
use Functional\Exercises\Models\Exercise;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Exercise>
|
||||
*/
|
||||
class ExerciseFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->words(3, true);
|
||||
$ext = fake()->unique()->numerify('####');
|
||||
|
||||
return [
|
||||
'external_id' => $ext,
|
||||
'name' => $name,
|
||||
'slug' => \Illuminate\Support\Str::slug($name . '-' . $ext),
|
||||
'category' => fake()->randomElement(['waist', 'chest', 'back', 'upper legs', 'shoulders']),
|
||||
'body_part' => fake()->randomElement(['waist', 'chest', 'back', 'upper legs', 'shoulders']),
|
||||
'equipment' => fake()->randomElement(['body weight', 'dumbbell', 'barbell', 'cable', 'kettlebell']),
|
||||
'target' => fake()->randomElement(['abs', 'pectorals', 'lats', 'quads', 'delts']),
|
||||
'muscle_group' => fake()->word(),
|
||||
'secondary_muscles' => fake()->randomElements(['hip flexors', 'lower back', 'triceps', 'glutes'], 2),
|
||||
'instructions' => ['en' => [fake()->sentence()], 'fr' => [fake()->sentence()]],
|
||||
'image_path' => 'exercises/' . $ext . '.jpg',
|
||||
'gif_path' => 'exercises/' . $ext . '.gif',
|
||||
'attribution' => '© Gym visual — https://gymvisual.com/',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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('exercises', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('external_id')->unique(); // "0001" du dataset
|
||||
$table->string('name');
|
||||
$table->string('slug')->index();
|
||||
$table->string('category')->nullable()->index(); // waist, chest...
|
||||
$table->string('body_part')->nullable()->index(); // upper arms, back...
|
||||
$table->string('equipment')->nullable()->index(); // dumbbell, body weight...
|
||||
$table->string('target')->nullable()->index(); // muscle cible principal
|
||||
$table->string('muscle_group')->nullable(); // synergistes primaires
|
||||
$table->json('secondary_muscles')->nullable(); // ["deltoid", ...]
|
||||
$table->json('instructions')->nullable(); // { en:[...], fr:[...] }
|
||||
$table->string('image_path')->nullable(); // clé S3 / chemin média
|
||||
$table->string('gif_path')->nullable();
|
||||
$table->string('attribution')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('exercises');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ExercisesSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use Functional\Exercises\Http\Controllers\ExercisesController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Routes here are wrapped in the 'api' middleware group with the 'api'
|
||||
// prefix by the layer service provider. To version your endpoints
|
||||
// (/api/v1/...), nest Route::prefix('v1')->group(...) inside.
|
||||
|
||||
// Catalogue public, lecture seule — aucun middleware d'authentification.
|
||||
// /facets doit être déclaré avant /{exercise} pour ne pas être capturé par le binding.
|
||||
Route::get('/exercises/facets', [ExercisesController::class, 'facets']);
|
||||
Route::get('/exercises', [ExercisesController::class, 'index']);
|
||||
Route::get('/exercises/{exercise}', [ExercisesController::class, 'show']);
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
||||
// Define broadcast channels here, e.g.:
|
||||
// Broadcast::channel('App.Models.User.{id}', fn($user, $id) => (int) $user->id === (int) $id);
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
// Define artisan commands here, e.g.:
|
||||
// Artisan::command('layer:hello', fn() => info('Hello from this layer.'));
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Routes here are wrapped in the 'web' middleware group by the layer
|
||||
// service provider. For custom groups (prefix, domain, named groups, ...)
|
||||
// use Route::middleware(...)->prefix(...)->group(function () { ... });
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Console\Commands;
|
||||
|
||||
use Functional\Exercises\Models\Exercise;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Importe le dataset d'exercices (fork hasaneyldrm/exercises-dataset).
|
||||
* JSON : upsert par external_id. --media : pousse aussi images+gifs vers RustFS/S3.
|
||||
*/
|
||||
class ImportExercisesCommand extends Command
|
||||
{
|
||||
protected $signature = 'exercises:import
|
||||
{--path= : racine du dataset (contient data/exercises.json, images/, videos/)}
|
||||
{--media : uploade aussi les images/gifs vers le disque S3 "media"}
|
||||
{--langs=en,fr : langues d instructions à conserver (pt-BR retombe sur en)}
|
||||
{--fresh : vide la table avant import}';
|
||||
|
||||
protected $description = "Importe le dataset d'exercices (JSON) et, avec --media, les médias vers RustFS/S3.";
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$root = rtrim($this->option('path') ?: storage_path('app/dataset'), '/');
|
||||
$json = is_file("$root/data/exercises.json") ? "$root/data/exercises.json" : "$root/exercises.json";
|
||||
|
||||
if (! is_file($json)) {
|
||||
$this->error("exercises.json introuvable sous {$root}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$langs = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('langs')))));
|
||||
$records = json_decode((string) file_get_contents($json), true);
|
||||
|
||||
if (! is_array($records)) {
|
||||
$this->error('JSON invalide');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('fresh')) {
|
||||
Exercise::query()->delete();
|
||||
$this->warn('Table exercises vidée.');
|
||||
}
|
||||
|
||||
$media = (bool) $this->option('media');
|
||||
$prefix = trim((string) config('exercises.media_prefix', 'exercises'), '/');
|
||||
$disk = $media ? Storage::disk('media') : null;
|
||||
|
||||
$bar = $this->output->createProgressBar(count($records));
|
||||
$bar->start();
|
||||
$imported = 0;
|
||||
$uploaded = 0;
|
||||
|
||||
foreach ($records as $r) {
|
||||
$ext = (string) ($r['id'] ?? '');
|
||||
if ($ext === '') {
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Instructions : on garde les étapes (arrays) des langues demandées.
|
||||
$steps = $r['instruction_steps'] ?? [];
|
||||
$instructions = [];
|
||||
foreach ($langs as $l) {
|
||||
if (! empty($steps[$l])) {
|
||||
$instructions[$l] = array_values($steps[$l]);
|
||||
}
|
||||
}
|
||||
|
||||
$imgKey = ! empty($r['image']) ? "{$prefix}/{$ext}.jpg" : null;
|
||||
$gifKey = ! empty($r['gif_url']) ? "{$prefix}/{$ext}.gif" : null;
|
||||
|
||||
if ($media && $disk) {
|
||||
foreach ([[$r['image'] ?? null, $imgKey], [$r['gif_url'] ?? null, $gifKey]] as [$rel, $key]) {
|
||||
if ($rel && $key && is_file("{$root}/{$rel}") && ! $disk->exists($key)) {
|
||||
$disk->put($key, (string) file_get_contents("{$root}/{$rel}"), 'public');
|
||||
$uploaded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Exercise::updateOrCreate(
|
||||
['external_id' => $ext],
|
||||
[
|
||||
'name' => $r['name'] ?? $ext,
|
||||
'slug' => Str::slug(($r['name'] ?? $ext) . '-' . $ext),
|
||||
'category' => $r['category'] ?? null,
|
||||
'body_part' => $r['body_part'] ?? null,
|
||||
'equipment' => $r['equipment'] ?? null,
|
||||
'target' => $r['target'] ?? null,
|
||||
'muscle_group' => $r['muscle_group'] ?? null,
|
||||
'secondary_muscles' => $r['secondary_muscles'] ?? [],
|
||||
'instructions' => $instructions,
|
||||
'image_path' => $imgKey,
|
||||
'gif_path' => $gifKey,
|
||||
'attribution' => $r['attribution'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
$imported++;
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Importé/à jour : {$imported} exercices" . ($media ? " (+ {$uploaded} médias S3)" : ''));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Http\Controllers;
|
||||
|
||||
use Functional\Exercises\Http\Resources\ExerciseListResource;
|
||||
use Functional\Exercises\Http\Resources\ExerciseResource;
|
||||
use Functional\Exercises\Models\Exercise;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
/**
|
||||
* Catalogue d'exercices : lecture seule et public, aucun scoping par utilisateur.
|
||||
*/
|
||||
class ExercisesController
|
||||
{
|
||||
private const DEFAULT_LOCALE = 'fr';
|
||||
|
||||
private const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
/** GET /api/exercises — liste filtrable (search/body_part/equipment/target/category) et paginée. */
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$exercises = Exercise::query()
|
||||
->search($request->query('search'))
|
||||
->when($request->query('body_part'), fn ($query, $bodyPart) => $query->where('body_part', $bodyPart))
|
||||
->when($request->query('equipment'), fn ($query, $equipment) => $query->where('equipment', $equipment))
|
||||
->when($request->query('target'), fn ($query, $target) => $query->where('target', $target))
|
||||
->when($request->query('category'), fn ($query, $category) => $query->where('category', $category))
|
||||
->orderBy('name')
|
||||
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
|
||||
|
||||
return ExerciseListResource::collection($exercises);
|
||||
}
|
||||
|
||||
/** GET /api/exercises/{exercise} — détail avec instructions dans la locale demandée. */
|
||||
public function show(Request $request, Exercise $exercise): ExerciseResource
|
||||
{
|
||||
return new ExerciseResource($exercise, $this->resolveLocale($request));
|
||||
}
|
||||
|
||||
/** GET /api/exercises/facets — valeurs distinctes pour alimenter les filtres du front. */
|
||||
public function facets(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'body_parts' => Exercise::query()->whereNotNull('body_part')->distinct()->orderBy('body_part')->pluck('body_part')->values(),
|
||||
'equipments' => Exercise::query()->whereNotNull('equipment')->distinct()->orderBy('equipment')->pluck('equipment')->values(),
|
||||
'targets' => Exercise::query()->whereNotNull('target')->distinct()->orderBy('target')->pluck('target')->values(),
|
||||
'categories' => Exercise::query()->whereNotNull('category')->distinct()->orderBy('category')->pluck('category')->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Résout la locale demandée : paramètre `locale`, sinon header Accept-Language, sinon 'fr'. */
|
||||
private function resolveLocale(Request $request): string
|
||||
{
|
||||
$locale = $request->query('locale');
|
||||
|
||||
if ($locale) {
|
||||
return (string) $locale;
|
||||
}
|
||||
|
||||
$acceptLanguage = $request->header('Accept-Language');
|
||||
|
||||
if (! $acceptLanguage) {
|
||||
return self::DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
// Le header peut lister plusieurs langues pondérées ("fr-FR,fr;q=0.9,en;q=0.8") : on ne garde que la première.
|
||||
$firstLanguage = strtok($acceptLanguage, ',');
|
||||
$languageTag = strtok($firstLanguage, ';');
|
||||
|
||||
return $languageTag !== false ? $languageTag : self::DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Forme allégée d'un exercice pour les listes (GET /api/exercises).
|
||||
*/
|
||||
class ExerciseListResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'body_part' => $this->body_part,
|
||||
'equipment' => $this->equipment,
|
||||
'target' => $this->target,
|
||||
'image_url' => $this->image_url,
|
||||
'gif_url' => $this->gif_url,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Détail complet d'un exercice (GET /api/exercises/{id}), instructions localisées.
|
||||
*/
|
||||
class ExerciseResource extends JsonResource
|
||||
{
|
||||
public function __construct($resource, private readonly string $locale)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
}
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'category' => $this->category,
|
||||
'equipment' => $this->equipment,
|
||||
'target' => $this->target,
|
||||
'muscle_group' => $this->muscle_group,
|
||||
'secondary_muscles' => $this->secondary_muscles ?? [],
|
||||
'instructions' => $this->resource->instructionsFor($this->locale),
|
||||
'image_url' => $this->image_url,
|
||||
'gif_url' => $this->gif_url,
|
||||
'attribution' => $this->attribution,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Models;
|
||||
|
||||
use Functional\Exercises\Database\Factories\ExerciseFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\UseFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Catalogue d'exercices (lecture seule, alimenté par l'import du dataset).
|
||||
*
|
||||
* @property string $external_id
|
||||
* @property string $name
|
||||
* @property array|null $secondary_muscles
|
||||
* @property array|null $instructions
|
||||
*/
|
||||
#[UseFactory(ExerciseFactory::class)]
|
||||
class Exercise extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'external_id', 'name', 'slug', 'category', 'body_part', 'equipment',
|
||||
'target', 'muscle_group', 'secondary_muscles', 'instructions',
|
||||
'image_path', 'gif_path', 'attribution',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'secondary_muscles' => 'array',
|
||||
'instructions' => 'array',
|
||||
];
|
||||
|
||||
protected $appends = ['image_url', 'gif_url'];
|
||||
|
||||
/** URL publique du thumbnail (média servi depuis RustFS/S3). */
|
||||
public function imageUrl(): ?string
|
||||
{
|
||||
return $this->mediaUrl($this->image_path);
|
||||
}
|
||||
|
||||
public function gifUrl(): ?string
|
||||
{
|
||||
return $this->mediaUrl($this->gif_path);
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->imageUrl();
|
||||
}
|
||||
|
||||
public function getGifUrlAttribute(): ?string
|
||||
{
|
||||
return $this->gifUrl();
|
||||
}
|
||||
|
||||
private function mediaUrl(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim((string) config('exercises.media_base_url'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructions dans la locale demandée, avec repli.
|
||||
* pt-BR n'existe pas dans le dataset → repli en/fr.
|
||||
*/
|
||||
public function instructionsFor(string $locale): array
|
||||
{
|
||||
$data = $this->instructions ?? [];
|
||||
$chain = [$locale, substr($locale, 0, 2), 'en', 'fr'];
|
||||
|
||||
foreach ($chain as $lang) {
|
||||
if (! empty($data[$lang])) {
|
||||
return (array) $data[$lang];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Recherche plein-texte simple (nom / muscle cible / équipement). */
|
||||
public function scopeSearch(Builder $query, ?string $term): Builder
|
||||
{
|
||||
if (! $term) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$like = '%' . str_replace('%', '\%', $term) . '%';
|
||||
|
||||
return $query->where(function (Builder $q) use ($like) {
|
||||
$q->where('name', 'like', $like)
|
||||
->orWhere('target', 'like', $like)
|
||||
->orWhere('equipment', 'like', $like)
|
||||
->orWhere('body_part', 'like', $like);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Providers;
|
||||
|
||||
use Functional\Exercises\Console\Commands\ImportExercisesCommand;
|
||||
use Functional\Exercises\Database\Seeders\ExercisesSeeder;
|
||||
use Xefi\LaravelOSDD\LayerServiceProvider;
|
||||
|
||||
class ExercisesServiceProvider extends LayerServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
||||
$this->loadSeeders([ExercisesSeeder::class]);
|
||||
$this->commands([ImportExercisesCommand::class]);
|
||||
}
|
||||
|
||||
$this->withRouting(
|
||||
web: __DIR__ . '/../../routes/web.php',
|
||||
api: __DIR__ . '/../../routes/api.php',
|
||||
commands: __DIR__ . '/../../routes/console.php',
|
||||
channels: __DIR__ . '/../../routes/channels.php',
|
||||
);
|
||||
$this->loadSeeders([\Functional\Exercises\Database\Seeders\ExercisesSeeder::class]);
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/exercises.php', 'exercises');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user