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
@@ -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>