From 6f01c783fdc05ceace7dc449b60ac675d62afdaf Mon Sep 17 00:00:00 2001 From: AmirmahdiNourkazemi Date: Wed, 3 Jun 2026 00:55:40 +0330 Subject: [PATCH] feat: add insight timer --- .../Controllers/BackgroundSoundController.php | 21 ++++ app/Http/Controllers/BellSoundController.php | 21 ++++ app/Http/Controllers/CatalogController.php | 118 ++++++++++++++++++ .../Controllers/TimerPresetController.php | 94 ++++++++++++++ app/Models/BackgroundSound.php | 27 ++++ app/Models/BellSound.php | 27 ++++ app/Models/TimerPreset.php | 69 ++++++++++ ..._06_02_100000_create_bell_sounds_table.php | 33 +++++ ..._100100_create_background_sounds_table.php | 33 +++++ ..._100200_create_background_images_table.php | 32 +++++ ...6_02_100300_create_timer_presets_table.php | 46 +++++++ ..._use_images_for_timer_background_image.php | 48 +++++++ routes/api.php | 30 +++++ 13 files changed, 599 insertions(+) create mode 100644 app/Http/Controllers/BackgroundSoundController.php create mode 100644 app/Http/Controllers/BellSoundController.php create mode 100644 app/Http/Controllers/CatalogController.php create mode 100644 app/Http/Controllers/TimerPresetController.php create mode 100644 app/Models/BackgroundSound.php create mode 100644 app/Models/BellSound.php create mode 100644 app/Models/TimerPreset.php create mode 100644 database/migrations/2026_06_02_100000_create_bell_sounds_table.php create mode 100644 database/migrations/2026_06_02_100100_create_background_sounds_table.php create mode 100644 database/migrations/2026_06_02_100200_create_background_images_table.php create mode 100644 database/migrations/2026_06_02_100300_create_timer_presets_table.php create mode 100644 database/migrations/2026_06_03_100000_use_images_for_timer_background_image.php diff --git a/app/Http/Controllers/BackgroundSoundController.php b/app/Http/Controllers/BackgroundSoundController.php new file mode 100644 index 0000000..9c51db5 --- /dev/null +++ b/app/Http/Controllers/BackgroundSoundController.php @@ -0,0 +1,21 @@ + ['column' => 'sound_path', 'folder' => 'background-sounds/sounds', 'rules' => 'nullable|mimes:mp3,wav,ogg,flac|max:102400'], + 'image' => ['column' => 'image_path', 'folder' => 'background-sounds/images', 'rules' => 'nullable|image|max:8192'], + ]; + } +} diff --git a/app/Http/Controllers/BellSoundController.php b/app/Http/Controllers/BellSoundController.php new file mode 100644 index 0000000..3762938 --- /dev/null +++ b/app/Http/Controllers/BellSoundController.php @@ -0,0 +1,21 @@ + ['column' => 'sound_path', 'folder' => 'bells/sounds', 'rules' => 'nullable|mimes:mp3,wav,ogg,flac|max:51200'], + 'image' => ['column' => 'image_path', 'folder' => 'bells/images', 'rules' => 'nullable|image|max:8192'], + ]; + } +} diff --git a/app/Http/Controllers/CatalogController.php b/app/Http/Controllers/CatalogController.php new file mode 100644 index 0000000..28246f1 --- /dev/null +++ b/app/Http/Controllers/CatalogController.php @@ -0,0 +1,118 @@ + */ + abstract protected function modelClass(): string; + + /** + * Map of request file field => ['column' => db column, 'folder' => storage folder, 'rules' => validation]. + */ + abstract protected function fileFields(): array; + + public function index(Request $request) + { + $query = ($this->modelClass())::query(); + + if (!$request->boolean('include_inactive')) { + $query->where('is_active', true); + } + + return response()->json($query->orderBy('order')->get()); + } + + public function show($id) + { + return response()->json(($this->modelClass())::findOrFail($id)); + } + + public function store(Request $request) + { + $data = $request->validate(array_merge([ + 'name' => 'required|string|max:255', + 'order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + ], $this->fileRules())); + + $attributes = [ + 'name' => $data['name'], + 'order' => $data['order'] ?? 0, + 'is_active' => $data['is_active'] ?? true, + ]; + + foreach ($this->fileFields() as $field => $config) { + $attributes[$config['column']] = $request->hasFile($field) + ? $request->file($field)->store($config['folder'], 'public') + : null; + } + + $item = ($this->modelClass())::create($attributes); + + return response()->json(['message' => 'Created successfully', 'item' => $item], 201); + } + + public function update(Request $request, $id) + { + $item = ($this->modelClass())::findOrFail($id); + + $data = $request->validate(array_merge([ + 'name' => 'sometimes|string|max:255', + 'order' => 'nullable|integer', + 'is_active' => 'nullable|boolean', + ], $this->fileRules())); + + foreach (['name', 'order', 'is_active'] as $field) { + if ($request->has($field)) { + $item->$field = $data[$field]; + } + } + + foreach ($this->fileFields() as $field => $config) { + if ($request->hasFile($field)) { + if ($item->{$config['column']}) { + Storage::disk('public')->delete($item->{$config['column']}); + } + $item->{$config['column']} = $request->file($field)->store($config['folder'], 'public'); + } + } + + $item->save(); + + return response()->json(['message' => 'Updated successfully', 'item' => $item]); + } + + public function destroy($id) + { + $item = ($this->modelClass())::findOrFail($id); + + foreach ($this->fileFields() as $config) { + if ($item->{$config['column']}) { + Storage::disk('public')->delete($item->{$config['column']}); + } + } + + $item->delete(); + + return response()->json(['message' => 'Deleted successfully']); + } + + private function fileRules(): array + { + $rules = []; + foreach ($this->fileFields() as $field => $config) { + $rules[$field] = $config['rules']; + } + + return $rules; + } +} diff --git a/app/Http/Controllers/TimerPresetController.php b/app/Http/Controllers/TimerPresetController.php new file mode 100644 index 0000000..6d7d24c --- /dev/null +++ b/app/Http/Controllers/TimerPresetController.php @@ -0,0 +1,94 @@ +json([ + 'bell_sounds' => BellSound::where('is_active', true)->orderBy('order')->get(), + 'background_sounds' => BackgroundSound::where('is_active', true)->orderBy('order')->get(), + 'background_images' => Image::where('type', 'public')->get(), + ]); + } + + // USER: their saved timers (ذخیره‌شده‌های من). + public function index() + { + $presets = TimerPreset::with(TimerPreset::RELATIONS) + ->where('user_id', auth()->id()) + ->latest() + ->get(); + + return response()->json($presets); + } + + public function show($id) + { + $preset = TimerPreset::with(TimerPreset::RELATIONS) + ->where('user_id', auth()->id()) + ->findOrFail($id); + + return response()->json($preset); + } + + public function store(Request $request) + { + $data = $this->validatePreset($request); + + $preset = TimerPreset::create($data + ['user_id' => auth()->id()]); + + return response()->json([ + 'message' => 'Timer saved successfully', + 'preset' => $preset->load(TimerPreset::RELATIONS), + ], 201); + } + + public function update(Request $request, $id) + { + $preset = TimerPreset::where('user_id', auth()->id())->findOrFail($id); + + $data = $this->validatePreset($request, false); + + $preset->fill($data)->save(); + + return response()->json([ + 'message' => 'Timer updated successfully', + 'preset' => $preset->load(TimerPreset::RELATIONS), + ]); + } + + public function destroy($id) + { + $preset = TimerPreset::where('user_id', auth()->id())->findOrFail($id); + $preset->delete(); + + return response()->json(['message' => 'Timer deleted successfully']); + } + + private function validatePreset(Request $request, bool $creating = true): array + { + $required = $creating ? 'required' : 'sometimes'; + + return $request->validate([ + 'name' => "$required|string|max:255", + 'duration_seconds' => "$required|integer|min:1", + 'start_bell_id' => 'nullable|exists:bell_sounds,id', + 'end_bell_id' => 'nullable|exists:bell_sounds,id', + 'interval_bell_id' => 'nullable|exists:bell_sounds,id', + 'interval_seconds' => 'nullable|integer|min:1', + 'interval_repeat' => 'nullable|integer|min:1', + 'background_sound_id' => 'nullable|exists:background_sounds,id', + 'background_image_id' => 'nullable|exists:images,id', + 'volume' => 'nullable|integer|min:0|max:100', + ]); + } +} diff --git a/app/Models/BackgroundSound.php b/app/Models/BackgroundSound.php new file mode 100644 index 0000000..99ede9a --- /dev/null +++ b/app/Models/BackgroundSound.php @@ -0,0 +1,27 @@ + 'boolean', + 'order' => 'integer', + ]; + + protected $appends = ['sound_url', 'image_url']; + + public function getSoundUrlAttribute(): ?string + { + return $this->sound_path ? asset('storage/' . $this->sound_path) : null; + } + + public function getImageUrlAttribute(): ?string + { + return $this->image_path ? asset('storage/' . $this->image_path) : null; + } +} diff --git a/app/Models/BellSound.php b/app/Models/BellSound.php new file mode 100644 index 0000000..ee5d6c6 --- /dev/null +++ b/app/Models/BellSound.php @@ -0,0 +1,27 @@ + 'boolean', + 'order' => 'integer', + ]; + + protected $appends = ['sound_url', 'image_url']; + + public function getSoundUrlAttribute(): ?string + { + return $this->sound_path ? asset('storage/' . $this->sound_path) : null; + } + + public function getImageUrlAttribute(): ?string + { + return $this->image_path ? asset('storage/' . $this->image_path) : null; + } +} diff --git a/app/Models/TimerPreset.php b/app/Models/TimerPreset.php new file mode 100644 index 0000000..e020c06 --- /dev/null +++ b/app/Models/TimerPreset.php @@ -0,0 +1,69 @@ + 'integer', + 'interval_seconds' => 'integer', + 'interval_repeat' => 'integer', + 'volume' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function startBell(): BelongsTo + { + return $this->belongsTo(BellSound::class, 'start_bell_id'); + } + + public function endBell(): BelongsTo + { + return $this->belongsTo(BellSound::class, 'end_bell_id'); + } + + public function intervalBell(): BelongsTo + { + return $this->belongsTo(BellSound::class, 'interval_bell_id'); + } + + public function backgroundSound(): BelongsTo + { + return $this->belongsTo(BackgroundSound::class, 'background_sound_id'); + } + + public function backgroundImage(): BelongsTo + { + return $this->belongsTo(Image::class, 'background_image_id'); + } + + // Eager-load set for returning a fully-resolved preset to the front. + public const RELATIONS = [ + 'startBell', + 'endBell', + 'intervalBell', + 'backgroundSound', + 'backgroundImage', + ]; +} diff --git a/database/migrations/2026_06_02_100000_create_bell_sounds_table.php b/database/migrations/2026_06_02_100000_create_bell_sounds_table.php new file mode 100644 index 0000000..79b87ab --- /dev/null +++ b/database/migrations/2026_06_02_100000_create_bell_sounds_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + $table->string('sound_path')->nullable(); + $table->string('image_path')->nullable(); + $table->integer('order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bell_sounds'); + } +}; diff --git a/database/migrations/2026_06_02_100100_create_background_sounds_table.php b/database/migrations/2026_06_02_100100_create_background_sounds_table.php new file mode 100644 index 0000000..6b91d51 --- /dev/null +++ b/database/migrations/2026_06_02_100100_create_background_sounds_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + $table->string('sound_path')->nullable(); + $table->string('image_path')->nullable(); + $table->integer('order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('background_sounds'); + } +}; diff --git a/database/migrations/2026_06_02_100200_create_background_images_table.php b/database/migrations/2026_06_02_100200_create_background_images_table.php new file mode 100644 index 0000000..a963575 --- /dev/null +++ b/database/migrations/2026_06_02_100200_create_background_images_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('image_path')->nullable(); + $table->integer('order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('background_images'); + } +}; diff --git a/database/migrations/2026_06_02_100300_create_timer_presets_table.php b/database/migrations/2026_06_02_100300_create_timer_presets_table.php new file mode 100644 index 0000000..b6a0af3 --- /dev/null +++ b/database/migrations/2026_06_02_100300_create_timer_presets_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('duration_seconds')->default(0); // مدت زمان + + // Bells + $table->foreignId('start_bell_id')->nullable()->constrained('bell_sounds')->nullOnDelete(); + $table->foreignId('end_bell_id')->nullable()->constrained('bell_sounds')->nullOnDelete(); + $table->foreignId('interval_bell_id')->nullable()->constrained('bell_sounds')->nullOnDelete(); + $table->unsignedInteger('interval_seconds')->nullable(); // هر چند ثانیه یک‌بار + $table->unsignedInteger('interval_repeat')->nullable(); // تکرار چند بار + + // Ambience + $table->foreignId('background_sound_id')->nullable()->constrained('background_sounds')->nullOnDelete(); + $table->foreignId('background_image_id')->nullable()->constrained('background_images')->nullOnDelete(); + + $table->unsignedInteger('volume')->default(100); // صدای دستگاه (0-100) + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('timer_presets'); + } +}; diff --git a/database/migrations/2026_06_03_100000_use_images_for_timer_background_image.php b/database/migrations/2026_06_03_100000_use_images_for_timer_background_image.php new file mode 100644 index 0000000..661782e --- /dev/null +++ b/database/migrations/2026_06_03_100000_use_images_for_timer_background_image.php @@ -0,0 +1,48 @@ +dropForeign(['background_image_id']); + }); + + Schema::dropIfExists('background_images'); + + Schema::table('timer_presets', function (Blueprint $table) { + $table->foreign('background_image_id')->references('id')->on('images')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('timer_presets', function (Blueprint $table) { + $table->dropForeign(['background_image_id']); + }); + + Schema::create('background_images', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('image_path')->nullable(); + $table->integer('order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::table('timer_presets', function (Blueprint $table) { + $table->foreign('background_image_id')->references('id')->on('background_images')->nullOnDelete(); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index f43e0d8..57fe068 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,9 @@ use App\Http\Controllers\SurveyQuestionController; use App\Http\Controllers\SliderController; use App\Http\Controllers\SceneController; +use App\Http\Controllers\BellSoundController; +use App\Http\Controllers\BackgroundSoundController; +use App\Http\Controllers\TimerPresetController; use App\Http\Controllers\ImageController; use App\Http\Controllers\MusicController; use App\Http\Controllers\MediaController; @@ -170,6 +173,33 @@ Route::delete('/scenes/{id}', [SceneController::class, 'destroy']); + /// insight timer (زمان‌سنج) + // Builder catalogs (bells / background sounds / background images) in one call. + Route::get('/timer/options', [TimerPresetController::class, 'options']); + + // Saved timers per user (ذخیره‌شده‌های من). + Route::get('/timer-presets', [TimerPresetController::class, 'index']); + Route::post('/timer-presets', [TimerPresetController::class, 'store']); + Route::get('/timer-presets/{id}', [TimerPresetController::class, 'show']); + Route::put('/timer-presets/{id}', [TimerPresetController::class, 'update']); + Route::delete('/timer-presets/{id}', [TimerPresetController::class, 'destroy']); + + // Timer sound/image catalogs (POST update for multipart uploads). + Route::get('/bell-sounds', [BellSoundController::class, 'index']); + Route::post('/bell-sounds', [BellSoundController::class, 'store']); + Route::get('/bell-sounds/{id}', [BellSoundController::class, 'show']); + Route::post('/bell-sounds/{id}', [BellSoundController::class, 'update']); + Route::delete('/bell-sounds/{id}', [BellSoundController::class, 'destroy']); + + Route::get('/background-sounds', [BackgroundSoundController::class, 'index']); + Route::post('/background-sounds', [BackgroundSoundController::class, 'store']); + Route::get('/background-sounds/{id}', [BackgroundSoundController::class, 'show']); + Route::post('/background-sounds/{id}', [BackgroundSoundController::class, 'update']); + Route::delete('/background-sounds/{id}', [BackgroundSoundController::class, 'destroy']); + + // Background images for timers reuse the shared images catalog (see /images routes). + + ///media Route::get('/media/filters', [MediaController::class, 'filters']);