diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 09a203e3..c796fbf8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,12 @@ "Bash(git add *)", "Bash(git commit -m ' *)", "Bash(git push *)", - "Bash(php artisan *)" + "Bash(php artisan *)", + "Bash(ls resources/views/chat/)", + "Bash(ls resources/views/)", + "Bash(npm run *)", + "Bash(mv database/migrations/2026_04_28_170416_create_post_likes_table.php database/migrations/2026_04_28_170418_create_post_likes_table.php)", + "Bash(mv database/migrations/2026_04_28_170417_create_post_comments_table.php database/migrations/2026_04_28_170419_create_post_comments_table.php)" ] } } diff --git a/app/Http/Controllers/FriendshipController.php b/app/Http/Controllers/FriendshipController.php new file mode 100644 index 00000000..8df5db85 --- /dev/null +++ b/app/Http/Controllers/FriendshipController.php @@ -0,0 +1,100 @@ +user() + ->receivedFriendRequests() + ->where('status', 'pending') + ->with('sender') + ->latest() + ->get(); + + return view('friendships.index', compact('requests')); + } + + // Enviar solicitud + public function send(User $user) + { + $me = auth()->user(); + + if ($me->id === $user->id) { + return back()->with('error', 'No puedes enviarte una solicitud a ti mismo.'); + } + + $existing = $me->friendshipWith($user); + if ($existing) { + return back()->with('error', 'Ya existe una relación con este usuario.'); + } + + $friendship = Friendship::create([ + 'sender_id' => $me->id, + 'receiver_id' => $user->id, + 'status' => 'pending', + ]); + + $user->notify(new FriendRequestNotification($me)); + + return back()->with('success', 'Solicitud de amistad enviada.'); + } + + // Aceptar solicitud + public function accept(Friendship $friendship) + { + abort_unless($friendship->receiver_id === auth()->id(), 403); + + $friendship->update(['status' => 'accepted']); + + return back()->with('success', 'Solicitud aceptada.'); + } + + // Rechazar o cancelar solicitud + public function destroy(Friendship $friendship) + { + $me = auth()->id(); + abort_unless( + $friendship->sender_id === $me || $friendship->receiver_id === $me, + 403 + ); + + $friendship->delete(); + + return back()->with('success', 'Solicitud eliminada.'); + } + + // AJAX: enviar/cancelar solicitud desde búsqueda o perfil + public function toggle(User $user) + { + $me = auth()->user(); + + if ($me->id === $user->id) { + return response()->json(['error' => 'No permitido'], 422); + } + + $existing = $me->friendshipWith($user); + + if ($existing) { + $existing->delete(); + return response()->json(['status' => 'none']); + } + + Friendship::create([ + 'sender_id' => $me->id, + 'receiver_id' => $user->id, + 'status' => 'pending', + ]); + + $user->notify(new FriendRequestNotification($me)); + + return response()->json(['status' => 'pending']); + } +} diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php new file mode 100644 index 00000000..75e893aa --- /dev/null +++ b/app/Http/Controllers/PostController.php @@ -0,0 +1,104 @@ +user(); + $friendIds = $me->friendIds(); + $ids = array_merge($friendIds, [$me->id]); + + $posts = Post::whereIn('user_id', $ids) + ->with(['author', 'likes', 'comments.author']) + ->latest() + ->paginate(15); + + return view('feed.index', compact('posts')); + } + + public function store(Request $request) + { + $request->validate([ + 'body' => 'nullable|string|max:2000', + 'image' => 'nullable|image|max:5120', + ]); + + if (!$request->filled('body') && !$request->hasFile('image')) { + return back()->with('error', 'Escribe algo o adjunta una imagen.'); + } + + $imagePath = null; + if ($request->hasFile('image')) { + $imagePath = $request->file('image')->store('posts', 'public'); + } + + Post::create([ + 'user_id' => auth()->id(), + 'body' => $request->body, + 'image_path' => $imagePath, + ]); + + return back()->with('success', 'Publicación creada.'); + } + + public function like(Post $post) + { + $userId = auth()->id(); + $liked = PostLike::where('user_id', $userId)->where('post_id', $post->id)->first(); + + if ($liked) { + $liked->delete(); + $liked = false; + } else { + PostLike::create(['user_id' => $userId, 'post_id' => $post->id]); + $liked = true; + } + + return response()->json([ + 'liked' => $liked, + 'count' => $post->likes()->count(), + ]); + } + + public function comment(Request $request, Post $post) + { + $request->validate(['body' => 'required|string|max:500']); + + $comment = PostComment::create([ + 'user_id' => auth()->id(), + 'post_id' => $post->id, + 'body' => $request->body, + ]); + + $comment->load('author'); + + return response()->json([ + 'id' => $comment->id, + 'body' => $comment->body, + 'author' => $comment->author->name, + 'avatar' => $comment->author->profile_photo_url, + 'created_at' => $comment->created_at->diffForHumans(), + ]); + } + + public function destroy(Post $post) + { + abort_unless($post->user_id === auth()->id(), 403); + + if ($post->image_path) { + Storage::disk('public')->delete($post->image_path); + } + + $post->delete(); + + return back()->with('success', 'Publicación eliminada.'); + } +} diff --git a/app/Http/Controllers/SocialProfileController.php b/app/Http/Controllers/SocialProfileController.php new file mode 100644 index 00000000..48ec820e --- /dev/null +++ b/app/Http/Controllers/SocialProfileController.php @@ -0,0 +1,20 @@ +user(); + $friendship = $me->friendshipWith($user); + $posts = $user->posts() + ->with(['author', 'likes', 'comments.author']) + ->latest() + ->paginate(10); + + return view('social.profile', compact('user', 'friendship', 'posts')); + } +} diff --git a/app/Http/Controllers/UserSearchController.php b/app/Http/Controllers/UserSearchController.php new file mode 100644 index 00000000..86dcc652 --- /dev/null +++ b/app/Http/Controllers/UserSearchController.php @@ -0,0 +1,49 @@ +get('q', '')); + $me = auth()->user(); + + if (strlen($q) < 2) { + return response()->json([]); + } + + $users = User::where('id', '!=', $me->id) + ->where(function ($query) use ($q) { + $query->where('name', 'like', "%{$q}%") + ->orWhere('apellidoPaterno', 'like', "%{$q}%") + ->orWhere('apellidoMaterno', 'like', "%{$q}%"); + }) + ->limit(8) + ->get(); + + return response()->json( + $users->map(function (User $user) use ($me) { + $friendship = $me->friendshipWith($user); + $status = $friendship?->status ?? 'none'; + $isReceiver = $friendship?->receiver_id === $me->id; + + return [ + 'id' => $user->id, + 'name' => $user->name . ' ' . $user->apellidoPaterno, + 'avatar' => $user->profile_photo_url, + 'profile_url' => route('social.profile', $user), + 'chat_url' => route('chat.show', $user), + 'friendship' => [ + 'status' => $status, + 'id' => $friendship?->id, + 'is_receiver' => $isReceiver, + ], + ]; + }) + ); + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php new file mode 100644 index 00000000..5f7812ba --- /dev/null +++ b/app/Models/Post.php @@ -0,0 +1,32 @@ +belongsTo(User::class, 'user_id'); + } + + public function likes(): HasMany + { + return $this->hasMany(PostLike::class); + } + + public function comments(): HasMany + { + return $this->hasMany(PostComment::class)->with('author')->orderBy('created_at'); + } + + public function isLikedBy(User $user): bool + { + return $this->likes()->where('user_id', $user->id)->exists(); + } +} diff --git a/app/Models/PostComment.php b/app/Models/PostComment.php new file mode 100644 index 00000000..05faef1b --- /dev/null +++ b/app/Models/PostComment.php @@ -0,0 +1,16 @@ +belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/PostLike.php b/app/Models/PostLike.php new file mode 100644 index 00000000..85055f64 --- /dev/null +++ b/app/Models/PostLike.php @@ -0,0 +1,10 @@ +belongsToMany(Alumno::class); } + public function posts(): HasMany + { + return $this->hasMany(Post::class); + } + + public function sentFriendRequests(): HasMany + { + return $this->hasMany(Friendship::class, 'sender_id'); + } + + public function receivedFriendRequests(): HasMany + { + return $this->hasMany(Friendship::class, 'receiver_id'); + } + + public function friendshipWith(User $user): ?Friendship + { + return Friendship::where(function ($q) use ($user) { + $q->where('sender_id', $this->id)->where('receiver_id', $user->id); + }) + ->orWhere(function ($q) use ($user) { + $q->where('sender_id', $user->id)->where('receiver_id', $this->id); + }) + ->first(); + } + + public function isFriendWith(User $user): bool + { + $friendship = $this->friendshipWith($user); + return $friendship?->status === 'accepted'; + } + + public function friendIds(): array + { + $sent = Friendship::where('sender_id', $this->id)->where('status', 'accepted')->pluck('receiver_id'); + $received = Friendship::where('receiver_id', $this->id)->where('status', 'accepted')->pluck('sender_id'); + return $sent->merge($received)->unique()->values()->all(); + } + protected static function booted() { static::creating(function ($user) { diff --git a/app/Notifications/FriendRequestNotification.php b/app/Notifications/FriendRequestNotification.php new file mode 100644 index 00000000..e691f61d --- /dev/null +++ b/app/Notifications/FriendRequestNotification.php @@ -0,0 +1,30 @@ + $this->sender->name . ' te envió una solicitud de amistad', + 'body' => null, + 'url' => route('friendships.index'), + 'icon' => 'fa-user-plus', + 'color' => 'primary', + ]; + } +} diff --git a/database/migrations/2026_04_28_170416_create_posts_table.php b/database/migrations/2026_04_28_170416_create_posts_table.php new file mode 100644 index 00000000..70cb3366 --- /dev/null +++ b/database/migrations/2026_04_28_170416_create_posts_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->text('body')->nullable(); + $table->string('image_path')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/database/migrations/2026_04_28_170418_create_post_likes_table.php b/database/migrations/2026_04_28_170418_create_post_likes_table.php new file mode 100644 index 00000000..8e01c00c --- /dev/null +++ b/database/migrations/2026_04_28_170418_create_post_likes_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('post_id')->constrained()->onDelete('cascade'); + $table->unique(['user_id', 'post_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('post_likes'); + } +}; diff --git a/database/migrations/2026_04_28_170419_create_post_comments_table.php b/database/migrations/2026_04_28_170419_create_post_comments_table.php new file mode 100644 index 00000000..971d1175 --- /dev/null +++ b/database/migrations/2026_04_28_170419_create_post_comments_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('post_id')->constrained()->onDelete('cascade'); + $table->text('body'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('post_comments'); + } +}; diff --git a/package-lock.json b/package-lock.json index 441fd8ba..b7595cbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -969,18 +969,6 @@ "node": ">=0.4.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1335,18 +1323,6 @@ "node": ">=8" } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/laravel-echo": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.0.tgz", @@ -1381,280 +1357,6 @@ "vite": "^7.0.0" } }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/resources/views/chat/show.blade.php b/resources/views/chat/show.blade.php index 3acec9ad..a09642e6 100644 --- a/resources/views/chat/show.blade.php +++ b/resources/views/chat/show.blade.php @@ -27,7 +27,7 @@ @endif @if($msg->attachment) @if($msg->attachment_type === 'image') - + @elseif($msg->attachment_type === 'audio')