fix/feat: búsqueda móvil, chat solo amigos y bloqueo si se eliminó amistad

- Header: búsqueda móvil con panel deslizable (fixed bajo la topbar), comparte la lógica AJAX con desktop via initSearchWidget()
- ChatController: index muestra solo amigos; show pasa isFriend y friendship a la vista; send valida amistad con abort_unless
- chat/index: muestra amigos con @forelse y mensaje vacío si no tiene
- chat/show: si no son amigos muestra banner de bloqueo con botón contextual (enviar solicitud / cancelar / aceptar según estado)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 17:40:16 -06:00
parent e0205ec725
commit f81237b23c
4 changed files with 151 additions and 83 deletions
@@ -28,24 +28,25 @@
<ul class="navbar-nav ml-auto">
<!-- Nav Item - Search (XS) -->
<li class="nav-item dropdown no-arrow d-sm-none">
<a class="nav-link dropdown-toggle" href="#" id="searchDropdown" role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<li class="nav-item no-arrow d-sm-none position-relative">
<a class="nav-link" href="#" id="mobile-search-toggle" role="button">
<i class="fas fa-search fa-fw"></i>
</a>
<div class="dropdown-menu dropdown-menu-right p-3 shadow animated--grow-in"
aria-labelledby="searchDropdown">
<form class="form-inline mr-auto w-100 navbar-search">
<div class="input-group">
<input type="text" class="form-control bg-light border-0 small"
placeholder="Buscar..." aria-label="Search">
<div class="input-group-append">
<button class="btn btn-primary" type="button">
<i class="fas fa-search fa-sm"></i>
</button>
</div>
<div id="mobile-search-panel" class="d-none"
style="position:fixed; top:56px; left:0; right:0; z-index:9998;
background:#fff; padding:.75rem 1rem; box-shadow:0 4px 12px rgba(0,0,0,.15)">
<div class="input-group">
<input type="text" id="global-search-mobile"
class="form-control bg-light border-0"
placeholder="Buscar personas..." autocomplete="off">
<div class="input-group-append">
<span class="btn btn-primary"><i class="fas fa-search fa-sm"></i></span>
</div>
</form>
</div>
<div id="search-results-mobile" class="bg-white mt-1 d-none"
style="border:1px solid rgba(0,0,0,.1); border-radius:8px;
box-shadow:0 4px 16px rgba(0,0,0,.1);
max-height:280px; overflow-y:auto"></div>
</div>
</li>
@@ -306,34 +307,31 @@ function markAllNotifsRead() {
}
// ── Búsqueda de usuarios ─────────────────────────────────
(function () {
const input = document.getElementById('global-search');
const results = document.getElementById('search-results');
if (!input) return;
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 initSearchWidget(inputEl, resultsEl) {
if (!inputEl || !resultsEl) 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 = `
resultsEl.innerHTML = `
<div class="px-4 py-3 text-center">
<i class="fas fa-search text-muted mb-2 d-block" style="font-size:1.4rem"></i>
<span class="text-muted small">Sin resultados</span>
</div>`;
results.classList.remove('d-none');
resultsEl.classList.remove('d-none');
return;
}
results.innerHTML =
resultsEl.innerHTML =
`<div class="px-3 pt-2 pb-1" style="font-size:.7rem;font-weight:600;letter-spacing:.05em;color:#aaa;text-transform:uppercase">Personas</div>` +
users.map((u, i) => {
const btn = friendshipLabel(u.friendship);
const btn = friendshipLabel(u.friendship);
const isLast = i === users.length - 1;
return `
<div class="d-flex align-items-center px-3 py-2 search-result-item"
@@ -364,47 +362,71 @@ function markAllNotifsRead() {
</div>
</div>`;
}).join('');
results.classList.remove('d-none');
resultsEl.classList.remove('d-none');
// Bind toggle buttons
results.querySelectorAll('.friend-toggle-btn').forEach(btn => {
resultsEl.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);
});
axios.post(`/friends/${this.dataset.userId}/toggle`)
.then(() => doSearch(inputEl.value));
});
});
}
function doSearch(q) {
if (q.length < 2) { results.classList.add('d-none'); return; }
if (q.length < 2) { resultsEl.classList.add('d-none'); return; }
axios.get('/search', { params: { q } }).then(res => renderResults(res.data));
}
input.addEventListener('input', function () {
inputEl.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(() => doSearch(this.value.trim()), 300);
});
// Evita que el dropdown se cierre al hacer mousedown dentro de él
// (mousedown dispara antes que click, y blur del input cierra antes de registrar la acción)
let interactingWithResults = false;
results.addEventListener('mousedown', () => { interactingWithResults = true; });
document.addEventListener('mouseup', () => { interactingWithResults = false; });
inputEl.addEventListener('focus', function () {
if (this.value.trim().length >= 2) resultsEl.classList.remove('d-none');
});
// Evita cierre al interactuar con el dropdown
let busy = false;
resultsEl.addEventListener('mousedown', () => { busy = true; });
document.addEventListener('mouseup', () => { busy = false; });
document.addEventListener('click', function (e) {
if (interactingWithResults) return;
if (!input.contains(e.target) && !results.contains(e.target)) {
results.classList.add('d-none');
if (busy) return;
if (!inputEl.contains(e.target) && !resultsEl.contains(e.target)) {
resultsEl.classList.add('d-none');
}
});
}
// Desktop
initSearchWidget(
document.getElementById('global-search'),
document.getElementById('search-results')
);
// Móvil: toggle del panel y widget de búsqueda
(function () {
const toggle = document.getElementById('mobile-search-toggle');
const panel = document.getElementById('mobile-search-panel');
const mInput = document.getElementById('global-search-mobile');
const mResult = document.getElementById('search-results-mobile');
if (!toggle) return;
toggle.addEventListener('click', function (e) {
e.preventDefault();
const isHidden = panel.classList.contains('d-none');
panel.classList.toggle('d-none', !isHidden);
if (!isHidden) { mInput.focus(); }
});
// Cerrar panel al tocar fuera
document.addEventListener('click', function (e) {
if (!panel.contains(e.target) && !toggle.contains(e.target)) {
panel.classList.add('d-none');
}
});
input.addEventListener('focus', function () {
if (this.value.trim().length >= 2) results.classList.remove('d-none');
});
initSearchWidget(mInput, mResult);
})();
</script>