From e314f95656eca33b2017417753f047819c2a5a2e Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Fri, 22 May 2026 21:36:34 +0330 Subject: [PATCH 1/2] fix: change music duration to int --- app/Http/Controllers/MusicController.php | 4 +-- app/Models/Music.php | 10 +++---- ...nge_duration_to_integer_in_music_table.php | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_05_22_175608_change_duration_to_integer_in_music_table.php diff --git a/app/Http/Controllers/MusicController.php b/app/Http/Controllers/MusicController.php index 1c7ff2d..245876f 100644 --- a/app/Http/Controllers/MusicController.php +++ b/app/Http/Controllers/MusicController.php @@ -127,7 +127,7 @@ public function store(Request $request) 'type' => 'nullable|in:public,private', 'image_id' => 'nullable|exists:images,id', 'playlist_id' => 'nullable|exists:music_playlists,id', - 'duration' => 'nullable|string|regex:/^(?:\d+:)?[0-5]?\d:[0-5]\d$/', // validates mm:ss or hh:mm:ss + 'duration' => 'nullable|integer|min:1', // validates mm:ss or hh:mm:ss ]); // Handle file upload @@ -210,7 +210,7 @@ public function update(Request $request, $id) 'file' => 'nullable|mimes:mp3,wav,ogg,flac|max:20971520', 'image_id' => 'nullable|exists:images,id', 'playlist_id' => 'nullable|exists:music_playlists,id', - 'duration' => 'nullable|string|regex:/^(?:\d+:)?[0-5]?\d:[0-5]\d$/', + 'duration' => 'nullable|integer|min:1', 'order' => 'nullable|integer', 'is_active' => 'nullable|boolean', ]); diff --git a/app/Models/Music.php b/app/Models/Music.php index 0dbb00c..123798f 100644 --- a/app/Models/Music.php +++ b/app/Models/Music.php @@ -22,7 +22,7 @@ class Music extends Model 'playlist_id', 'image_id', 'duration', 'order', 'is_active' ]; protected $casts = [ - 'duration' => 'string', + 'duration' => 'integer', 'order' => 'integer', 'is_active' => 'boolean', ]; @@ -43,10 +43,10 @@ public function tags(): BelongsToMany return $this->belongsToMany(Tag::class, 'music_tags'); } - protected $appends = ['url', 'image_url' , 'average_rating', 'user_rating', 'comments_count' , 'has_user_commented', // Add this - 'user_comment', // Add this - 'user_comment_id', // Add this - 'has_user_rated' // Add this]; + protected $appends = ['url', 'image_url' , 'average_rating', 'user_rating', 'comments_count' , 'has_user_commented', + 'user_comment', + 'user_comment_id', + 'has_user_rated' ]; public function getUrlAttribute() diff --git a/database/migrations/2026_05_22_175608_change_duration_to_integer_in_music_table.php b/database/migrations/2026_05_22_175608_change_duration_to_integer_in_music_table.php new file mode 100644 index 0000000..89c1f06 --- /dev/null +++ b/database/migrations/2026_05_22_175608_change_duration_to_integer_in_music_table.php @@ -0,0 +1,28 @@ +integer('duration')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('music', function (Blueprint $table) { + $table->string('duration')->nullable()->change(); + }); + } +}; From 8339a03168cc04cf574ddffa2c21141da207d6f3 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Sat, 23 May 2026 01:09:29 +0330 Subject: [PATCH 2/2] feat: add save feature --- app/Http/Controllers/SaveController.php | 225 ++++++++++++++++++ app/Models/BreathingTemplate.php | 4 +- app/Models/Media.php | 20 +- app/Models/Music.php | 14 +- app/Models/SavedItem.php | 49 ++++ app/Traits/HasSaves.php | 59 +++++ ..._05_22_200854_create_saved_items_table.php | 31 +++ routes/api.php | 12 +- 8 files changed, 401 insertions(+), 13 deletions(-) create mode 100644 app/Http/Controllers/SaveController.php create mode 100644 app/Models/SavedItem.php create mode 100644 app/Traits/HasSaves.php create mode 100644 database/migrations/2026_05_22_200854_create_saved_items_table.php diff --git a/app/Http/Controllers/SaveController.php b/app/Http/Controllers/SaveController.php new file mode 100644 index 0000000..8a0316d --- /dev/null +++ b/app/Http/Controllers/SaveController.php @@ -0,0 +1,225 @@ +validate([ + 'type' => 'required|string|in:music,media,breathing-template', + 'id' => 'required|integer', + ]); + + $model = $this->getModel($request->type, $request->id); + + if (!$model) { + return response()->json([ + 'message' => 'Item not found' + ], 404); + } + + $result = $model->addSave(); + + return response()->json([ + 'message' => 'Item saved successfully', + 'is_saved' => true, + 'saved_count' => $model->saved_count + ]); + } + + /** + * Unsave an item + */ + public function unsave(Request $request) + { + $request->validate([ + 'type' => 'required|string|in:music,media,breathing-template', + 'id' => 'required|integer', + ]); + + $model = $this->getModel($request->type, $request->id); + + if (!$model) { + return response()->json([ + 'message' => 'Item not found' + ], 404); + } + + $result = $model->removeSave(); + + return response()->json([ + 'message' => 'Item removed from saved', + 'is_saved' => false, + 'saved_count' => $model->saved_count + ]); + } + + /** + * Toggle save status + */ + public function toggleSave(Request $request) + { + $request->validate([ + 'type' => 'required|string|in:music,media,breathing-template', + 'id' => 'required|integer', + ]); + + $model = $this->getModel($request->type, $request->id); + + if (!$model) { + return response()->json([ + 'message' => 'Item not found' + ], 404); + } + + $result = $model->toggleSaveStatus(); + + return response()->json([ + 'message' => $result ? 'Item saved successfully' : 'Item removed from saved', + 'is_saved' => $model->is_saved, + 'saved_count' => $model->saved_count + ]); + } + + /** + * Get all saved items for the authenticated user + */ + public function mySavedItems(Request $request) + { + $type = $request->get('type'); // Optional filter by type + + $query = SavedItem::with('saveable') + ->where('user_id', auth()->id()); + + if ($type) { + $modelClass = $this->getModelClass($type); + $query->where('saveable_type', $modelClass); + } + + $savedItems = $query->latest()->paginate(20); + + return response()->json([ + 'data' => $savedItems, + 'total' => $savedItems->total(), + 'types' => [ + 'music' => 'App\\Models\\Music', + 'media' => 'App\\Models\\Media', + 'breathing-template' => 'App\\Models\\BreathingTemplate', + ] + ]); + } + /** + * Check if specific item is saved by user + */ + public function checkSaved(Request $request) + { + $request->validate([ + 'type' => 'required|string|in:music,media,breathing-template', + 'id' => 'required|integer', + ]); + + $model = $this->getModel($request->type, $request->id); + + if (!$model) { + return response()->json([ + 'message' => 'Item not found' + ], 404); + } + + return response()->json([ + 'is_saved' => $model->is_saved, + 'saved_count' => $model->saved_count + ]); + } + + private function getModel($type, $id) + { + $modelClass = $this->getModelClass($type); + return $modelClass::find($id); + } + + private function getModelClass($type) + { + $models = [ + 'music' => \App\Models\Music::class, + 'media' => \App\Models\Media::class, + 'breathing-template' => \App\Models\BreathingTemplate::class, + ]; + + return $models[$type] ?? null; + } +} + + + + + +// public function mySavedItems(Request $request) +// { +// $type = $request->get('type'); + +// $query = SavedItem::with('saveable') +// ->where('user_id', auth()->id()); + +// if ($type) { +// $modelClass = $this->getModelClass($type); +// $query->where('saveable_type', $modelClass); +// } + +// $savedItems = $query->latest()->paginate(20); + +// // Transform the response to include formatted data +// $transformedItems = $savedItems->map(function ($savedItem) { +// $item = $savedItem->saveable; + +// if (!$item) return null; + +// $baseData = [ +// 'saved_id' => $savedItem->id, +// 'saved_at' => $savedItem->created_at, +// 'type' => class_basename($savedItem->saveable_type), +// ]; + +// // Add type-specific data +// if ($item instanceof \App\Models\Music) { +// return array_merge($baseData, [ +// 'id' => $item->id, +// 'title' => $item->title, +// 'artist' => $item->artist, +// 'duration' => $item->duration_formatted ?? $item->duration, +// 'image_url' => $item->image_url, +// 'is_saved' => true, +// ]); +// } elseif ($item instanceof \App\Models\Media) { +// return array_merge($baseData, [ +// 'id' => $item->id, +// 'title' => $item->title, +// 'caption' => $item->caption, +// 'type' => $item->type, +// 'duration' => $item->duration, +// 'image_url' => $item->image->url ?? null, +// 'is_saved' => true, +// ]); +// } elseif ($item instanceof \App\Models\BreathingTemplate) { +// return array_merge($baseData, [ +// 'id' => $item->id, +// 'name' => $item->name, +// 'description' => $item->description, +// 'duration' => $item->duration, +// 'inhale' => $item->inhale, +// 'exhale' => $item->exhale, +// 'breath_hold' => $item->breath_hold, +// 'image_url' => $item->image_url, +// 'is_saved' => true, +// ]); +// } + +// return $baseData; +// })->filter(); \ No newline at end of file diff --git a/app/Models/BreathingTemplate.php b/app/Models/BreathingTemplate.php index e2f3532..271afd2 100644 --- a/app/Models/BreathingTemplate.php +++ b/app/Models/BreathingTemplate.php @@ -3,16 +3,18 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\HasSaves; class BreathingTemplate extends Model { + use HasSaves; protected $fillable = ['user_id', 'name', 'inhale', 'exhale', 'breath_hold' , 'duration' , 'description','image_id' , 'source']; public function user() { return $this->belongsTo(User::class); } - protected $appends = ['image_url']; + protected $appends = ['image_url', 'is_saved','saved_count']; public function getImageUrlAttribute() { diff --git a/app/Models/Media.php b/app/Models/Media.php index cf16fd3..951ec5b 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -5,9 +5,11 @@ use Illuminate\Database\Eloquent\Model; use App\Traits\HasRatings; use App\Traits\HasComments; +use App\Traits\HasSaves; + class Media extends Model { - use HasRatings, HasComments; + use HasRatings, HasComments,HasSaves; protected $fillable = [ 'user_id', 'image_id', @@ -21,16 +23,18 @@ class Media extends Model 'visibility', 'is_premium' ]; -protected $appends = [ - 'average_rating', + protected $appends = [ + 'average_rating', 'user_rating', 'ratings_count', 'comments_count', - 'has_user_commented', // Add this - 'user_comment', // Add this - 'user_comment_id', // Add this - 'has_user_rated' // Add this -]; + 'has_user_commented', + 'user_comment', + 'user_comment_id', + 'has_user_rated', + 'is_saved', + 'saved_count' + ]; public function image() { return $this->belongsTo(Image::class); diff --git a/app/Models/Music.php b/app/Models/Music.php index 123798f..0c7f384 100644 --- a/app/Models/Music.php +++ b/app/Models/Music.php @@ -10,10 +10,11 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; // ← Add this use App\Traits\HasRatings; use App\Traits\HasComments; +use App\Traits\HasSaves; class Music extends Model { - use HasFactory, HasRatings, HasComments; + use HasFactory, HasRatings, HasComments , HasSaves; protected $table = 'music'; @@ -43,10 +44,17 @@ public function tags(): BelongsToMany return $this->belongsToMany(Tag::class, 'music_tags'); } - protected $appends = ['url', 'image_url' , 'average_rating', 'user_rating', 'comments_count' , 'has_user_commented', + protected $appends = ['url', + 'image_url' , + 'average_rating', + 'user_rating', + 'comments_count' , + 'has_user_commented', 'user_comment', 'user_comment_id', - 'has_user_rated' + 'has_user_rated' , + 'is_saved', + 'saved_count' ]; public function getUrlAttribute() diff --git a/app/Models/SavedItem.php b/app/Models/SavedItem.php new file mode 100644 index 0000000..14d3afb --- /dev/null +++ b/app/Models/SavedItem.php @@ -0,0 +1,49 @@ +belongsTo(User::class); + } + + public function saveable(): MorphTo + { + return $this->morphTo(); + } + + // Helper to get saved items by type + public static function getSavedItemsForUser($userId, $type = null) + { + $query = self::with('saveable')->where('user_id', $userId); + + if ($type) { + $query->where('saveable_type', $type); + } + + return $query->latest()->get(); + } + + // Check if user has saved specific item + public static function isSavedByUser($userId, $saveableId, $saveableType) + { + return self::where([ + 'user_id' => $userId, + 'saveable_id' => $saveableId, + 'saveable_type' => $saveableType, + ])->exists(); + } +} diff --git a/app/Traits/HasSaves.php b/app/Traits/HasSaves.php new file mode 100644 index 0000000..c5a5144 --- /dev/null +++ b/app/Traits/HasSaves.php @@ -0,0 +1,59 @@ +morphMany(SavedItem::class, 'saveable'); + } + + public function getIsSavedAttribute() + { + if (!auth()->check()) return false; + + return $this->saves() + ->where('user_id', auth()->id()) + ->exists(); + } + + public function getSavedCountAttribute() + { + return $this->saves()->count(); + } + + public function toggleSaveStatus() + { + if ($this->getIsSavedAttribute()) { + return $this->removeSave(); + } else { + return $this->addSave(); + } + } + + public function addSave() + { + if ($this->getIsSavedAttribute()) return false; + + return SavedItem::create([ + 'user_id' => auth()->id(), + 'saveable_id' => $this->id, + 'saveable_type' => get_class($this), + ]); + } + + public function removeSave() + { + if (!$this->getIsSavedAttribute()) return false; + + return SavedItem::where([ + 'user_id' => auth()->id(), + 'saveable_id' => $this->id, + 'saveable_type' => get_class($this), + ])->delete(); + } +} \ No newline at end of file diff --git a/database/migrations/2026_05_22_200854_create_saved_items_table.php b/database/migrations/2026_05_22_200854_create_saved_items_table.php new file mode 100644 index 0000000..412a7a9 --- /dev/null +++ b/database/migrations/2026_05_22_200854_create_saved_items_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->morphs('saveable'); // saveable_id + saveable_type + $table->timestamps(); + + $table->unique(['user_id', 'saveable_id', 'saveable_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('saved_items'); + } +}; diff --git a/routes/api.php b/routes/api.php index 3794d20..fae4480 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,7 +20,7 @@ use App\Http\Controllers\RatingController; use App\Http\Controllers\CommentController; use App\Http\Controllers\MusicSubcategoryController; - +use App\Http\Controllers\SaveController; Route::get('/test-hash', function() { $plain = 'amnk1380'; @@ -201,4 +201,14 @@ Route::delete('{type}/{id}/{commentId}', [CommentController::class, 'deleteComment']); Route::get('most/{type}', [CommentController::class, 'mostCommented']); }); + + + // Save routes (works for all models) + Route::prefix('saves')->group(function () { + Route::post('/save', [SaveController::class, 'save']); + Route::post('/unsave', [SaveController::class, 'unsave']); + Route::post('/toggle', [SaveController::class, 'toggleSave']); + Route::get('/my-saved', [SaveController::class, 'mySavedItems']); + Route::post('/check', [SaveController::class, 'checkSaved']); + }); }); \ No newline at end of file