feat: funcionalidad de red social (feed, amigos, búsqueda)

- Migraciones: posts, post_likes, post_comments
- Modelos Post, PostLike, PostComment con relaciones
- User: métodos friendshipWith, isFriendWith, friendIds, posts
- PostController: feed, crear publicación con imagen, like toggle, comentarios, eliminar
- FriendshipController: listar solicitudes, enviar, aceptar, rechazar, toggle AJAX
- SocialProfileController: perfil público con publicaciones y estado de amistad
- UserSearchController: búsqueda AJAX por nombre con estado de amistad
- FriendRequestNotification: notificación al recibir solicitud
- Vistas: feed/index, feed/_post, friendships/index, social/profile
- Header: barra de búsqueda con autocomplete AJAX (agregar amigo / mensaje)
- Menú lateral: Comunidad, Amigos, Mensajes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:09:04 -06:00
parent 0f7de9b4d8
commit 97c04fbfb4
22 changed files with 1005 additions and 308 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
@endif
@if($msg->attachment)
@if($msg->attachment_type === 'image')
<img src="{{ route('files.serve', ['path' => $msg->attachment]) }}" class="img-fluid rounded mt-1">
<img src="{{ route('files.serve', ['path' => $msg->attachment]) }}" class="img-fluid rounded mt-1" style="max-width:240px; cursor:pointer" onclick="window.open(this.src,'_blank')">
@elseif($msg->attachment_type === 'audio')
<audio controls class="mt-1" style="max-width:250px">
<source src="{{ route('files.serve', ['path' => $msg->attachment]) }}">
+86
View File
@@ -0,0 +1,86 @@
<div class="card shadow-sm mb-3" id="post-{{ $post->id }}">
<div class="card-body pb-2">
{{-- Cabecera --}}
<div class="d-flex align-items-center mb-2">
<a href="{{ route('social.profile', $post->author) }}">
<img src="{{ $post->author->profile_photo_url }}"
class="rounded-circle mr-2" width="40" height="40" style="object-fit:cover">
</a>
<div class="flex-grow-1">
<a href="{{ route('social.profile', $post->author) }}" class="font-weight-bold text-dark d-block">
{{ $post->author->name }} {{ $post->author->apellidoPaterno }}
</a>
<small class="text-muted">{{ $post->created_at->diffForHumans() }}</small>
</div>
@if($post->user_id === auth()->id())
<form action="{{ route('posts.destroy', $post) }}" method="POST"
onsubmit="return confirm('¿Eliminar publicación?')">
@csrf @method('DELETE')
<button class="btn btn-sm text-muted" title="Eliminar">
<i class="fas fa-trash-alt fa-sm"></i>
</button>
</form>
@endif
</div>
{{-- Contenido --}}
@if($post->body)
<p class="mb-2">{{ $post->body }}</p>
@endif
@if($post->image_path)
<img src="{{ \Illuminate\Support\Facades\Storage::url($post->image_path) }}"
class="img-fluid rounded mb-2" style="max-height:500px; width:100%; object-fit:cover; cursor:pointer"
onclick="window.open(this.src,'_blank')">
@endif
{{-- Contadores --}}
<div class="d-flex align-items-center text-muted small border-top pt-2">
<span class="mr-3">
<i class="fas fa-heart text-danger mr-1"></i>
<span id="like-count-{{ $post->id }}">{{ $post->likes->count() }}</span> Me gusta
</span>
<span>
<i class="fas fa-comment mr-1"></i>
{{ $post->comments->count() }} comentarios
</span>
</div>
</div>
{{-- Botones de acción --}}
<div class="card-footer bg-white py-1 d-flex border-top-0">
@php $liked = $post->isLikedBy(auth()->user()); @endphp
<button class="btn btn-sm flex-fill like-btn {{ $liked ? 'text-danger' : 'text-muted' }}"
data-post-id="{{ $post->id }}">
<i class="{{ $liked ? 'fas' : 'far' }} fa-heart mr-1"></i>
Me gusta (<span class="like-count">{{ $post->likes->count() }}</span>)
</button>
<button class="btn btn-sm flex-fill text-muted toggle-comments" data-post-id="{{ $post->id }}">
<i class="far fa-comment mr-1"></i> Comentar
</button>
</div>
{{-- Sección de comentarios --}}
<div id="comments-{{ $post->id }}" class="d-none px-3 pb-3">
<div id="comment-list-{{ $post->id }}" class="mb-2">
@foreach($post->comments as $comment)
<div class="d-flex align-items-start mb-2">
<img src="{{ $comment->author->profile_photo_url }}"
class="rounded-circle mr-2" width="28" height="28" style="object-fit:cover">
<div class="bg-light rounded px-2 py-1 flex-grow-1">
<strong class="small">{{ $comment->author->name }}</strong>
<div class="small">{{ $comment->body }}</div>
</div>
</div>
@endforeach
</div>
<form class="comment-form d-flex" data-post-id="{{ $post->id }}">
@csrf
<img src="{{ auth()->user()->profile_photo_url }}"
class="rounded-circle mr-2" width="28" height="28" style="object-fit:cover">
<input type="text" name="body" class="form-control form-control-sm rounded-pill"
placeholder="Escribe un comentario...">
</form>
</div>
</div>
+104
View File
@@ -0,0 +1,104 @@
@extends('layouts.landing')
@section('title', 'Feed')
@section('content')
<div class="container" style="max-width:680px">
{{-- Crear publicación --}}
<div class="card shadow-sm mb-4">
<div class="card-body">
<form action="{{ route('posts.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="d-flex align-items-start">
<img src="{{ auth()->user()->profile_photo_url }}"
class="rounded-circle mr-3" width="42" height="42" style="object-fit:cover">
<div class="flex-grow-1">
<textarea name="body" class="form-control border-0 bg-light rounded-pill px-3 py-2"
rows="2" placeholder="¿Qué estás pensando, {{ auth()->user()->name }}?"
style="resize:none"></textarea>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mt-2 pt-2 border-top">
<label class="btn btn-light btn-sm mb-0">
<i class="fas fa-image text-success mr-1"></i> Foto
<input type="file" name="image" accept="image/*" hidden id="post-image-input">
</label>
<span id="post-image-name" class="text-muted small flex-grow-1 ml-2"></span>
<button type="submit" class="btn btn-primary btn-sm rounded-pill px-4">Publicar</button>
</div>
</form>
</div>
</div>
{{-- Feed --}}
@forelse($posts as $post)
@include('feed._post', ['post' => $post])
@empty
<div class="text-center text-muted py-5">
<i class="fas fa-users fa-3x mb-3 d-block"></i>
<p>Aún no hay publicaciones. Agrega amigos para ver su contenido.</p>
<a href="{{ route('users.search') }}" class="btn btn-primary">Buscar personas</a>
</div>
@endforelse
<div class="d-flex justify-content-center mt-3">
{{ $posts->links() }}
</div>
</div>
@endsection
@section('scripts')
<script>
document.getElementById('post-image-input')?.addEventListener('change', function () {
document.getElementById('post-image-name').textContent = this.files[0]?.name ?? '';
});
// Like toggle
document.querySelectorAll('.like-btn').forEach(btn => {
btn.addEventListener('click', function () {
const postId = this.dataset.postId;
axios.post(`/posts/${postId}/like`).then(res => {
this.querySelector('.like-count').textContent = res.data.count;
this.classList.toggle('text-danger', res.data.liked);
this.classList.toggle('text-muted', !res.data.liked);
this.querySelector('i').classList.toggle('fas', res.data.liked);
this.classList.toggle('far', !res.data.liked);
});
});
});
// Comentarios: toggle visibilidad
document.querySelectorAll('.toggle-comments').forEach(btn => {
btn.addEventListener('click', function () {
const box = document.getElementById('comments-' + this.dataset.postId);
box.classList.toggle('d-none');
});
});
// Enviar comentario
document.querySelectorAll('.comment-form').forEach(form => {
form.addEventListener('submit', function (e) {
e.preventDefault();
const postId = this.dataset.postId;
const input = this.querySelector('input[name="body"]');
if (!input.value.trim()) return;
axios.post(`/posts/${postId}/comments`, { body: input.value })
.then(res => {
const list = document.getElementById('comment-list-' + postId);
const item = document.createElement('div');
item.className = 'd-flex align-items-start mb-2';
item.innerHTML = `
<img src="${res.data.avatar}" class="rounded-circle mr-2" width="28" height="28" style="object-fit:cover">
<div class="bg-light rounded px-2 py-1 flex-grow-1">
<strong class="small">${res.data.author}</strong>
<div class="small">${res.data.body}</div>
</div>`;
list.appendChild(item);
input.value = '';
});
});
});
</script>
@endsection
@@ -0,0 +1,47 @@
@extends('layouts.landing')
@section('title', 'Solicitudes de Amistad')
@section('content')
<div class="container" style="max-width:680px">
<div class="card shadow-sm">
<div class="card-header bg-white font-weight-bold">
<i class="fas fa-user-friends mr-2 text-primary"></i> Solicitudes de amistad
</div>
<div class="card-body p-0">
@forelse($requests as $friendship)
<div class="d-flex align-items-center p-3 border-bottom">
<a href="{{ route('social.profile', $friendship->sender) }}">
<img src="{{ $friendship->sender->profile_photo_url }}"
class="rounded-circle mr-3" width="50" height="50" style="object-fit:cover">
</a>
<div class="flex-grow-1">
<a href="{{ route('social.profile', $friendship->sender) }}" class="font-weight-bold text-dark">
{{ $friendship->sender->name }} {{ $friendship->sender->apellidoPaterno }}
</a>
<div class="text-muted small">{{ $friendship->created_at->diffForHumans() }}</div>
</div>
<div class="d-flex">
<form action="{{ route('friendships.accept', $friendship) }}" method="POST" class="mr-2">
@csrf
<button class="btn btn-primary btn-sm">
<i class="fas fa-check mr-1"></i> Aceptar
</button>
</form>
<form action="{{ route('friendships.destroy', $friendship) }}" method="POST">
@csrf @method('DELETE')
<button class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times mr-1"></i> Rechazar
</button>
</form>
</div>
</div>
@empty
<div class="text-center text-muted py-5">
<i class="fas fa-user-check fa-3x mb-3 d-block text-success"></i>
No tienes solicitudes pendientes.
</div>
@endforelse
</div>
</div>
</div>
@endsection
@@ -9,17 +9,17 @@
</button>
<!-- Topbar Search -->
<form class="d-none d-sm-inline-block form-inline mr-auto ml-md-3 my-2 my-md-0 mw-100 navbar-search">
<div class="d-none d-sm-inline-block form-inline mr-auto ml-md-3 my-2 my-md-0 mw-100 navbar-search position-relative">
<div class="input-group">
<input type="text" class="form-control bg-light border-0 small" placeholder="Buscar..."
aria-label="Search" aria-describedby="basic-addon2">
<input type="text" id="global-search" class="form-control bg-light border-0 small"
placeholder="Buscar personas..." autocomplete="off" style="min-width:220px">
<div class="input-group-append">
<button class="btn btn-primary" type="button">
<i class="fas fa-search fa-sm"></i>
</button>
<span class="btn btn-primary"><i class="fas fa-search fa-sm"></i></span>
</div>
</div>
</form>
<div id="search-results" class="shadow bg-white rounded position-absolute w-100 d-none"
style="top:100%; left:0; z-index:9999; max-height:320px; overflow-y:auto"></div>
</div>
<ul class="navbar-nav ml-auto">
@@ -293,4 +293,84 @@ function markAllNotifsRead() {
.forEach(el => el.classList.remove('font-weight-bold'));
}).catch(() => {});
}
// ── Búsqueda de usuarios ─────────────────────────────────
(function () {
const input = document.getElementById('global-search');
const results = document.getElementById('search-results');
if (!input) return;
let timer;
function friendshipLabel(f) {
if (f.status === 'accepted') return { label: 'Amigos', cls: 'btn-outline-secondary', icon: 'fa-user-check' };
if (f.status === 'pending' && !f.is_receiver) return { label: 'Solicitud enviada', cls: 'btn-outline-secondary', icon: 'fa-user-clock' };
if (f.status === 'pending' && f.is_receiver) return { label: 'Aceptar', cls: 'btn-success', icon: 'fa-user-plus' };
return { label: 'Agregar', cls: 'btn-primary', icon: 'fa-user-plus' };
}
function renderResults(users) {
if (!users.length) {
results.innerHTML = '<div class="p-3 text-muted small text-center">Sin resultados</div>';
results.classList.remove('d-none');
return;
}
results.innerHTML = users.map(u => {
const btn = friendshipLabel(u.friendship);
return `
<div class="d-flex align-items-center px-3 py-2 border-bottom search-result-item">
<a href="${u.profile_url}" class="d-flex align-items-center flex-grow-1 text-dark text-decoration-none">
<img src="${u.avatar}" class="rounded-circle mr-2" width="36" height="36" style="object-fit:cover">
<span class="font-weight-bold small">${u.name}</span>
</a>
<div class="d-flex align-items-center" style="gap:.4rem">
<button class="btn btn-sm ${btn.cls} friend-toggle-btn"
data-user-id="${u.id}"
data-friendship-id="${u.friendship.id ?? ''}"
data-status="${u.friendship.status}"
data-is-receiver="${u.friendship.is_receiver}">
<i class="fas ${btn.icon} mr-1"></i>${btn.label}
</button>
${u.friendship.status === 'accepted'
? `<a href="${u.chat_url}" class="btn btn-sm btn-outline-primary"><i class="fas fa-comment"></i></a>`
: ''}
</div>
</div>`;
}).join('');
results.classList.remove('d-none');
// Bind toggle buttons
results.querySelectorAll('.friend-toggle-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.stopPropagation();
const userId = this.dataset.userId;
axios.post(`/friends/${userId}/toggle`)
.then(res => {
// Re-search to refresh states
doSearch(input.value);
});
});
});
}
function doSearch(q) {
if (q.length < 2) { results.classList.add('d-none'); return; }
axios.get('/search', { params: { q } }).then(res => renderResults(res.data));
}
input.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(() => doSearch(this.value.trim()), 300);
});
document.addEventListener('click', function (e) {
if (!input.contains(e.target) && !results.contains(e.target)) {
results.classList.add('d-none');
}
});
input.addEventListener('focus', function () {
if (this.value.trim().length >= 2) results.classList.remove('d-none');
});
})();
</script>
@@ -16,6 +16,29 @@
<i class="fas fa-fw fa-tachometer-alt"></i>
<span>Inicio</span></a>
</li>
<!-- Divider -->
<hr class="sidebar-divider">
<!-- Social -->
<li class="nav-item">
<a class="nav-link" href="{{ route('feed') }}">
<i class="fas fa-fw fa-stream"></i>
<span>Comunidad</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('friendships.index') }}">
<i class="fas fa-fw fa-user-friends"></i>
<span>Amigos</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('chat.index') }}">
<i class="fas fa-fw fa-comment-dots"></i>
<span>Mensajes</span>
</a>
</li>
<!-- Divider -->
<hr class="sidebar-divider">
<!-- Heading -->
+138
View File
@@ -0,0 +1,138 @@
@extends('layouts.landing')
@section('title', $user->name)
@section('content')
<div class="container" style="max-width:680px">
{{-- Tarjeta de perfil --}}
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex align-items-center">
<img src="{{ $user->profile_photo_url }}"
class="rounded-circle mr-4" width="72" height="72" style="object-fit:cover">
<div class="flex-grow-1">
<h5 class="mb-0 font-weight-bold">
{{ $user->name }} {{ $user->apellidoPaterno }} {{ $user->apellidoMaterno }}
</h5>
<small class="text-muted">{{ $user->email }}</small>
</div>
@if($user->id !== auth()->id())
<div class="d-flex flex-column align-items-end" style="gap:.5rem">
@php
$me = auth()->user();
$status = $friendship?->status ?? 'none';
$isSender = $friendship?->sender_id === $me->id;
@endphp
{{-- Botón de amistad --}}
@if($status === 'accepted')
<form action="{{ route('friendships.destroy', $friendship) }}" method="POST">
@csrf @method('DELETE')
<button class="btn btn-outline-secondary btn-sm">
<i class="fas fa-user-check mr-1"></i> Amigos
</button>
</form>
@elseif($status === 'pending' && $isSender)
<form action="{{ route('friendships.destroy', $friendship) }}" method="POST">
@csrf @method('DELETE')
<button class="btn btn-outline-secondary btn-sm">
<i class="fas fa-user-clock mr-1"></i> Cancelar solicitud
</button>
</form>
@elseif($status === 'pending' && !$isSender)
<div class="d-flex" style="gap:.4rem">
<form action="{{ route('friendships.accept', $friendship) }}" method="POST">
@csrf
<button class="btn btn-primary btn-sm">
<i class="fas fa-check mr-1"></i> Aceptar
</button>
</form>
<form action="{{ route('friendships.destroy', $friendship) }}" method="POST">
@csrf @method('DELETE')
<button class="btn btn-outline-secondary btn-sm">Rechazar</button>
</form>
</div>
@else
<form action="{{ route('friendships.send', $user) }}" method="POST">
@csrf
<button class="btn btn-primary btn-sm">
<i class="fas fa-user-plus mr-1"></i> Agregar amigo
</button>
</form>
@endif
{{-- Botón de mensaje (solo si son amigos) --}}
@if($status === 'accepted')
<a href="{{ route('chat.show', $user) }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-comment mr-1"></i> Mensaje
</a>
@endif
</div>
@endif
</div>
</div>
</div>
{{-- Publicaciones del usuario --}}
@forelse($posts as $post)
@include('feed._post', ['post' => $post])
@empty
<div class="text-center text-muted py-4">
<i class="fas fa-stream fa-2x mb-2 d-block"></i>
Este usuario no tiene publicaciones aún.
</div>
@endforelse
<div class="d-flex justify-content-center mt-3">
{{ $posts->links() }}
</div>
</div>
@endsection
@section('scripts')
<script>
// Like toggle (mismo que feed)
document.querySelectorAll('.like-btn').forEach(btn => {
btn.addEventListener('click', function () {
const postId = this.dataset.postId;
axios.post(`/posts/${postId}/like`).then(res => {
this.querySelector('.like-count').textContent = res.data.count;
document.getElementById('like-count-' + postId).textContent = res.data.count;
this.classList.toggle('text-danger', res.data.liked);
this.classList.toggle('text-muted', !res.data.liked);
this.querySelector('i').classList.toggle('fas', res.data.liked);
this.querySelector('i').classList.toggle('far', !res.data.liked);
});
});
});
document.querySelectorAll('.toggle-comments').forEach(btn => {
btn.addEventListener('click', function () {
document.getElementById('comments-' + this.dataset.postId).classList.toggle('d-none');
});
});
document.querySelectorAll('.comment-form').forEach(form => {
form.addEventListener('submit', function (e) {
e.preventDefault();
const postId = this.dataset.postId;
const input = this.querySelector('input[name="body"]');
if (!input.value.trim()) return;
axios.post(`/posts/${postId}/comments`, { body: input.value }).then(res => {
const list = document.getElementById('comment-list-' + postId);
const item = document.createElement('div');
item.className = 'd-flex align-items-start mb-2';
item.innerHTML = `
<img src="${res.data.avatar}" class="rounded-circle mr-2" width="28" height="28" style="object-fit:cover">
<div class="bg-light rounded px-2 py-1 flex-grow-1">
<strong class="small">${res.data.author}</strong>
<div class="small">${res.data.body}</div>
</div>`;
list.appendChild(item);
input.value = '';
});
});
});
</script>
@endsection