feat ✨ : add buttons for read notifs

This commit is contained in:
System User 2026-03-06 13:00:14 +01:00
parent efd8871020
commit cf07ca6a6e
8 changed files with 514 additions and 31 deletions

View file

@ -3,6 +3,9 @@
namespace App\Http\Controllers\Club;
use App\Http\Controllers\Controller;
use App\Http\Requests\Club\Notifications\DeleteNotificationsRequest;
use App\Models\Notification;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\View as FacadesView;
class NotificationController extends Controller
@ -16,7 +19,10 @@ class NotificationController extends Controller
{
/** @var \App\Models\Customer $customerModel */
$customerModel = auth('club')->user();
$notificationModels = $customerModel->notifications()->orderBy('created_at', 'DESC')->get();
$notificationModels = $customerModel->notifications()
->whereNotIn('id', $customerModel->readNotifications()->pluck('notification_id'))
->orderBy('created_at', 'DESC')
->get();
return view('club.pages.notifications.index', compact('notificationModels'));
}
@ -29,4 +35,41 @@ class NotificationController extends Controller
{
return response()->json(FacadesView::shared('notifications_count', 0));
}
}
/**
* Delete notification from list
* @param Notification $notification
* @return \Illuminate\Http\JsonResponse
*/
public function destroy(Notification $notification): \Illuminate\Http\JsonResponse
{
/** @var \App\Models\Customer $customer */
$customer = Auth::guard('club')->user();
$customer->readNotifications()->syncWithoutDetaching($notification->id);
session()->flash('success', __('Notification supprimée.'));
return response()->json(['success' => true]);
}
/**
* Delete notification from list
* @param \App\Http\Requests\Club\Notifications\DeleteNotificationsRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function destroyMultiple(DeleteNotificationsRequest $request): \Illuminate\Http\JsonResponse
{
/** @var \App\Models\Customer $customer */
$customer = Auth::guard('club')->user();
$customer->readNotifications()->syncWithoutDetaching($request->validated('ids'));
$count = count($request->validated(('ids')));
session()->flash('success', trans_choice(
'{1} Notification supprimée.|[2,*] :count notifications supprimées.',
$count,
['count' => $count]
));
return response()->json(['success' => true]);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Club\Notifications;
use Illuminate\Foundation\Http\FormRequest;
class DeleteNotificationsRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return auth('club')->check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:2'],
'ids.*' => ['integer', 'exists:notifications,id'],
];
}
}

View file

@ -25,3 +25,126 @@
}
}
}
// ------------------------Style for deleting notification in notification list page -------------------
// Checkbox custom
.notif-checkbox-icon {
display: inline-flex;
width: 1.25rem;;
height: 1.25rem;;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 2px solid #adb5bd;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
&::after {
display: block;
width: 0.65rem;
height: 0.4rem;
border-bottom: 2.5px solid #fff;
border-left: 2.5px solid #fff;
margin-top: -2px;
content: '';
transform: rotate(-45deg) scale(0);
transform-origin: center;
transition: transform 0.15s ease;
}
&.is-checked {
border-color: $color-danger;
background: $color-danger;
&::after {
transform: rotate(-45deg) scale(1);
}
}
&.is-indeterminate {
border-color: $color-danger;
background: $color-danger;
&::after {
width: 0.65rem;
height: 0;
border-bottom: 2.5px solid #fff;
margin-top: 0;
transform: rotate(0deg) scale(1);
}
}
}
// display checkbox near notif
.notif-checkbox-wrapper {
display: flex;
overflow: hidden;
width: 0;
flex-shrink: 0;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: width 0.2s ease, opacity 0.2s ease;
}
// delete single
.notif-delete-wrapper {
overflow: hidden;
flex-shrink: 0;
transition: width 0.2s ease, opacity 0.2s ease;
}
.btn-delete-single {
display: inline-flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
&:hover {
background: #dc3545;
color: #fff;
}
i {
font-size: 0.75rem;
}
}
// Mode select all
#notifications-list {
&.selection-mode {
.notif-checkbox-wrapper {
width: 1.8rem;
opacity: 1;
pointer-events: auto;
}
.notification-item-wrapper {
cursor: pointer;
}
.card-notification {
pointer-events: none;
}
// hide sigle delete
.notif-delete-wrapper {
width: 0;
opacity: 0;
pointer-events: none;
}
}
}

View file

@ -9,6 +9,7 @@ import "./utilities/vue-club";
// PARTIALS
import "./partials/layout";
import './partials/delete-notifications'
// CUSTOM
import "./base/common";

View file

@ -0,0 +1,205 @@
import sweetalert from "../../modules/sweetalertConfirm";
import trans from "../../modules/trans";
interface NotificationsElements {
list: HTMLElement;
toolbar: HTMLElement;
btnToggle: HTMLButtonElement;
btnCancel: HTMLButtonElement;
btnDelete: HTMLButtonElement;
selectedCount: HTMLElement;
selectAllBox: HTMLElement;
}
// connect with elements
function resolveElements(): NotificationsElements | null {
const list = document.getElementById('notifications-list');
const toolbar = document.getElementById('selection-toolbar');
const btnToggle = document.getElementById('btn-toggle-selection');
const btnCancel = document.getElementById('btn-cancel-selection');
const btnDelete = document.getElementById('btn-delete-selected');
const selectedCount = document.getElementById('selected-count');
const selectAllBox = document.getElementById('select-all-checkbox');
if (!list || !toolbar || !btnToggle || !btnCancel || !btnDelete || !selectedCount || !selectAllBox) {
return null;
}
return {
list,
toolbar,
btnToggle: btnToggle as HTMLButtonElement,
btnCancel: btnCancel as HTMLButtonElement,
btnDelete: btnDelete as HTMLButtonElement,
selectedCount,
selectAllBox,
};
}
// selectors
function getBoxes(list: HTMLElement): HTMLElement[] {
return [...list.querySelectorAll<HTMLElement>('.notif-checkbox-icon[data-id]')];
}
function getChecked(list: HTMLElement): HTMLElement[] {
return getBoxes(list).filter(b => b.classList.contains('is-checked'));
}
function getCsrfToken(): string {
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
}
// Toolbar
function updateToolbar(els: NotificationsElements): void {
const { list, btnDelete, selectedCount, selectAllBox } = els;
const checked = getChecked(list);
const total = getBoxes(list).length;
selectedCount.textContent = String(checked.length);
btnDelete.classList.toggle('d-none', checked.length < 1);
selectAllBox.classList.remove('is-checked', 'is-indeterminate');
if (total > 0 && checked.length === total) {
selectAllBox.classList.add('is-checked');
selectAllBox.setAttribute('aria-checked', 'true');
} else if (checked.length > 0) {
selectAllBox.classList.add('is-indeterminate');
selectAllBox.setAttribute('aria-checked', 'mixed');
} else {
selectAllBox.setAttribute('aria-checked', 'false');
}
}
// Mode select all
// Enable mode
function enterSelectionMode(els: NotificationsElements): void {
els.list.classList.add('selection-mode');
els.toolbar.classList.remove('d-none');
els.toolbar.classList.add('d-flex');
els.btnToggle.classList.add('d-none');
}
// Disable mode
function exitSelectionMode(els: NotificationsElements): void {
els.list.classList.remove('selection-mode');
els.toolbar.classList.add('d-none');
els.toolbar.classList.remove('d-flex');
els.btnToggle.classList.remove('d-none');
getBoxes(els.list).forEach(b => {
b.classList.remove('is-checked');
b.setAttribute('aria-checked', 'false');
});
els.selectAllBox.classList.remove('is-checked', 'is-indeterminate');
els.selectAllBox.setAttribute('aria-checked', 'false');
updateToolbar(els);
}
// call Ajax
function fetchDelete(url: string, body: object | null = null): Promise<Response> {
return fetch(url, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'Content-Type': 'application/json',
'Accept': 'application/json',
},
...(body ? { body: JSON.stringify(body) } : {}),
});
}
//delete one by one
function deleteSingle(btn: HTMLButtonElement, els: NotificationsElements): void {
sweetalert.methods.confirm(
'Supprimer cette notification ?',
'',
btn,
() => {
fetchDelete(btn.dataset.url!)
.then(r => { if (!r.ok) throw new Error(); })
.then(() => window.location.reload())
.catch(() => sweetalert.methods.alert('Une erreur est survenue.', '', null, null, { icon: 'error' }));
}
);
}
// delete multiple
function deleteSelected(els: NotificationsElements): void {
const { list, btnDelete } = els;
const checked = getChecked(list);
const count = checked.length;
const message = count === 1
? trans.methods.__('Une notification sera supprimée.')
: trans.methods.__(':count notifications seront supprimées.').replace(':count', String(count));
sweetalert.methods.confirm(
trans.methods.__('Supprimer les notifications sélectionnées ?'),
message,
btnDelete,
() => {
fetchDelete(btnDelete.dataset.url!, { ids: checked.map(b => b.dataset.id!) })
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(() => window.location.reload())
.catch(() => sweetalert.methods.alert('Une erreur est survenue.', '', null, null, { icon: 'error' }));
}
);
}
// init
document.addEventListener("DOMContentLoaded", function () {
const els = resolveElements();
if (!els) return;
const { list, btnToggle, btnCancel, btnDelete, selectAllBox } = els;
btnToggle.addEventListener('click', () => enterSelectionMode(els));
btnCancel.addEventListener('click', () => exitSelectionMode(els));
btnDelete.addEventListener('click', () => deleteSelected(els));
selectAllBox.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation();
const boxes = getBoxes(list);
const allChecked = getChecked(list).length === boxes.length && boxes.length > 0;
boxes.forEach(b => {
b.classList.toggle('is-checked', !allChecked);
b.setAttribute('aria-checked', String(!allChecked));
});
updateToolbar(els);
});
selectAllBox.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); selectAllBox.click(); }
});
list.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
// delete single
const deleteBtn = target.closest<HTMLButtonElement>('.btn-delete-single');
if (deleteBtn) {
e.preventDefault();
e.stopPropagation();
deleteSingle(deleteBtn, els);
return;
}
// when mode select all active select all div
if (!list.classList.contains('selection-mode')) return;
const wrapper = target.closest<HTMLElement>('.notification-item-wrapper');
if (!wrapper) return;
const box = wrapper.querySelector<HTMLElement>('.notif-checkbox-icon[data-id]');
if (!box) return;
const checked = box.classList.toggle('is-checked');
box.setAttribute('aria-checked', String(checked));
updateToolbar(els);
});
});

View file

@ -31,13 +31,56 @@
<div class="col">
@if (!$notificationModels->count())
<span class="d-block h3 text-center">{{ __('Pas de nouvelles notifications') }}</span>
@else
<div class="d-flex align-items-center justify-content-between mb-3 px-1 gap-2 flex-wrap">
<button id="btn-toggle-selection" type="button" class="btn btn-outline-secondary">
<i class="fa-solid fa-circle-check me-2" aria-hidden="true"></i>
{{ __('Sélectionner') }}
</button>
<div id="selection-toolbar" class="d-none align-items-center gap-3 flex-wrap">
<label class="d-flex align-items-center gap-2 mb-0 user-select-none" style="cursor:pointer;">
<span
class="notif-checkbox-icon"
id="select-all-checkbox"
role="checkbox"
aria-checked="false"
tabindex="0"
aria-label="{{ __('Tout sélectionner') }}"
></span>
<span>{{ __('Tout sélectionner') }}</span>
</label>
<button
id="btn-delete-selected"
class="btn btn-danger d-none"
type="button"
data-url="{{ route('club.notifications.delete-multiple') }}"
data-confirm-bulk="{{ __('Supprimer les :count notifications sélectionnées ?') }}"
data-error-message="{{ __('Une erreur est survenue.') }}"
>
<i class="fa-solid fa-xmark me-2" aria-hidden="true"></i>
{{ __('Supprimer') }}
<span class="badge bg-white text-danger ms-2" id="selected-count">0</span>
</button>
<button id="btn-cancel-selection" type="button" class="btn btn-link text-secondary p-0">
{{ __('Annuler') }}
</button>
</div>
</div>
<div id="notifications-list">
@foreach ($notificationModels as $notificationModel)
@include('club.partials.card-notification', [
'outterClass' => 'w-100 my-3',
'notification' => $notificationModel,
])
@endforeach
</div>
@endif
@foreach ($notificationModels as $notificationModel)
@include('club.partials.card-notification', [
'outterClass' => 'w-100 my-3',
'notification' => $notificationModel,
])
@endforeach
</div>
</div>
</section>

View file

@ -9,31 +9,63 @@
default => throw $unhandled_type_exception,
};
@endphp
<a href="{{ $notification->content }}" title="{{ __('Go to page :page', ['page' => $message_tag]) }}" @class([
'card-notification text-bg-secondary rounded-4 text-decoration-none px-md-5 py-3',
$outterClass ?? '',
])>
<div class="notification-image px-3 text-center">
@switch($notification->notifiable_type)
@case(\App\Models\Message::class)
<img class="img-fluid" src="{{ Vite::asset('resources/assets/images/club/departaimant-sarthe.png') }}"
title="{{ __('Un magnet Départaimants de la Sarthe') }}" alt="{{ __('Magnet') }}">
@break
@default
@php throw $unhandled_type_exception; @endphp
@endswitch
<div class="notification-item-wrapper d-flex align-items-center gap-2 {{ $outterClass ?? '' }}">
{{-- Checkbox --}}
<div class="notif-checkbox-wrapper">
<span
class="notif-checkbox-icon"
data-id="{{ $notification->id }}"
role="checkbox"
aria-checked="false"
tabindex="-1"
aria-label="{{ __('Sélectionner cette notification') }}"
></span>
</div>
<div class="notification-content">
<div>
<div class="d-inline-flex flex-column flex-sm-row flex-wrap align-items-center pb-2">
<span class="text-bg-white rounded-pill px-2 py-1 text-center">{{ str($message_tag)->ucFirst() }}</span>
<span class="pt-1 pt-sm-0 px-sm-2">{{ $notification->created_at_nice_date }}</span>
<a href="{{ $notification->content }}" title="{{ __('Go to page :page', ['page' => $message_tag]) }}"
class="card-notification text-bg-secondary rounded-4 text-decoration-none px-md-5 py-3 flex-grow-1">
<div class="notification-image px-3 text-center">
@switch($notification->notifiable_type)
@case(\App\Models\Message::class)
<img class="img-fluid" src="{{ Vite::asset('resources/assets/images/club/departaimant-sarthe.png') }}"
title="{{ __('Un magnet Départaimants de la Sarthe') }}" alt="{{ __('Magnet') }}">
@break
@default
@php throw $unhandled_type_exception; @endphp
@endswitch
</div>
<div class="notification-content">
<div>
<div class="d-inline-flex flex-column flex-sm-row flex-wrap align-items-center pb-2">
<span class="text-bg-white rounded-pill px-2 py-1 text-center">{{ str($message_tag)->ucFirst() }}</span>
<span class="pt-1 pt-sm-0 px-sm-2">{{ $notification->created_at_nice_date }}</span>
</div>
<p class="m-0">{{ $message_label }}</p>
</div>
<div class="pe-3">
<i class="fa-solid fa-chevron-left fa-xl fa-flip-horizontal" aria-hidden="true"></i>
</div>
<p class="m-0">{{ $message_label }}</p>
</div>
<div class="pe-3">
<i class="fa-solid fa-chevron-left fa-xl fa-flip-horizontal" aria-hidden="true"></i>
</div>
</a>
{{-- delete button --}}
<div class="notif-delete-wrapper flex-shrink-0">
<button
type="button"
class="btn btn-delete-single btn-secondary"
data-url="{{ route('club.notifications.delete', $notification->id) }}"
data-confirm="{{ __('Supprimer cette notification ?') }}"
data-error-message="{{ __('Une erreur est survenue.') }}"
title="{{ __('Supprimer cette notification') }}"
>
<i class="fa-solid fa-xmark fa-sm" aria-hidden="true"></i>
<span class="visually-hidden">{{ __('Supprimer') }}</span>
</button>
</div>
</a>
</div>

View file

@ -52,6 +52,13 @@ Route::name('club.')->prefix('club')->group(function () {
Route::get('la-quete', [QuestController::class, 'index'])->name('quests.index');
Route::get('les-jeux', [GameController::class, 'index'])->name('games.index');
// * NOTIFICATIONS
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])
->name('notifications.delete');
Route::delete('/notifications', [NotificationController::class, 'destroyMultiple'])
->name('notifications.delete-multiple');
// * NEWS CLUB
Route::get('articles/{news_club_slug}', [SurveyController::class, 'show'])->name('news.show');
Route::post('sondage/{news_club}/reponse', [SurveyController::class, 'store'])->name('surveys.store');