Skip to content

Commit 70b53e8

Browse files
committed
feat: add notification center with unread badge and mark-as-read
1 parent c94caf8 commit 70b53e8

5 files changed

Lines changed: 217 additions & 6 deletions

File tree

web/context_processors.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,9 @@ def last_modified(request):
1919
def invitation_notifications(request):
2020
if request.user.is_authenticated:
2121
pending_invites = request.user.received_group_invites.filter(status="pending").count()
22-
return {"pending_invites_count": pending_invites}
22+
unread_notifications = request.user.notifications.filter(read=False).count()
23+
return {
24+
"pending_invites_count": pending_invites,
25+
"unread_notifications_count": unread_notifications,
26+
}
2327
return {}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
{% extends "base.html" %}
2+
{% load static %}
3+
4+
{% block title %}Notifications{% endblock title %}
5+
6+
{% block content %}
7+
<div class="container mx-auto px-4 py-8 max-w-3xl">
8+
<div class="flex items-center justify-between mb-6">
9+
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
10+
<i class="fas fa-bell mr-2 text-teal-500"></i> Notifications
11+
{% if unread_count %}
12+
<span class="ml-2 text-sm font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200 px-2 py-0.5 rounded-full">
13+
{{ unread_count }} unread
14+
</span>
15+
{% endif %}
16+
</h1>
17+
{% if unread_count %}
18+
<form method="post" action="{% url 'mark_notifications_read' %}">
19+
{% csrf_token %}
20+
<input type="hidden" name="filter_type" value="{{ filter_type }}">
21+
<input type="hidden" name="page" value="{{ page_obj.number }}">
22+
<button type="submit"
23+
class="text-sm px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition-colors duration-200">
24+
Mark all as read
25+
</button>
26+
</form>
27+
{% endif %}
28+
</div>
29+
30+
<!-- Filter Tabs -->
31+
<div class="flex gap-2 mb-6 flex-wrap">
32+
<a href="{% url 'notification_list' %}"
33+
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors duration-200
34+
{% if not filter_type %}bg-teal-600 text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}">
35+
All
36+
</a>
37+
{% for type, label in notification_types %}
38+
<a href="{% url 'notification_list' %}?type={{ type }}"
39+
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors duration-200
40+
{% if filter_type == type %}bg-teal-600 text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}">
41+
{{ label }}
42+
</a>
43+
{% endfor %}
44+
</div>
45+
46+
<!-- Notification List -->
47+
<div class="space-y-3">
48+
{% for notification in page_obj %}
49+
<div class="flex items-start gap-4 p-4 rounded-lg border transition-colors duration-200
50+
{% if not notification.read %}bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800{% else %}bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700{% endif %}">
51+
<!-- Icon -->
52+
<div class="flex-shrink-0 mt-0.5">
53+
{% if notification.notification_type == "success" %}
54+
<i class="fas fa-check-circle text-green-500 text-lg"></i>
55+
{% elif notification.notification_type == "warning" %}
56+
<i class="fas fa-exclamation-triangle text-yellow-500 text-lg"></i>
57+
{% elif notification.notification_type == "error" %}
58+
<i class="fas fa-times-circle text-red-500 text-lg"></i>
59+
{% else %}
60+
<i class="fas fa-info-circle text-teal-500 text-lg"></i>
61+
{% endif %}
62+
</div>
63+
<!-- Content -->
64+
<div class="flex-1 min-w-0">
65+
<div class="flex items-center justify-between gap-2">
66+
<p class="font-semibold text-gray-900 dark:text-white text-sm">{{ notification.title }}</p>
67+
<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
68+
{{ notification.created_at|timesince }} ago
69+
</span>
70+
</div>
71+
<p class="text-sm text-gray-600 dark:text-gray-300 mt-1">{{ notification.message }}</p>
72+
</div>
73+
<!-- Mark as read -->
74+
{% if not notification.read %}
75+
<form method="post" action="{% url 'mark_single_notification_read' notification.id %}">
76+
{% csrf_token %}
77+
<input type="hidden" name="filter_type" value="{{ filter_type }}">
78+
<input type="hidden" name="page" value="{{ page_obj.number }}">
79+
<button type="submit"
80+
title="Mark as read"
81+
aria-label="Mark notification as read"
82+
class="flex-shrink-0 text-teal-500 hover:text-teal-700 transition-colors duration-200">
83+
<i class="fas fa-check text-sm"></i>
84+
</button>
85+
</form>
86+
{% endif %}
87+
</div>
88+
{% empty %}
89+
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
90+
<i class="fas fa-bell-slash text-4xl text-gray-300 dark:text-gray-600 mb-4"></i>
91+
<p class="text-gray-500 dark:text-gray-400 font-medium">No notifications yet</p>
92+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">You're all caught up!</p>
93+
</div>
94+
{% endfor %}
95+
</div>
96+
<!-- Pagination -->
97+
{% if page_obj.has_other_pages %}
98+
<div class="flex justify-center gap-2 mt-6">
99+
{% if page_obj.has_previous %}
100+
<a href="?page={{ page_obj.previous_page_number }}{% if filter_type %}&type={{ filter_type }}{% endif %}"
101+
class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-teal-300">
102+
Previous
103+
</a>
104+
{% endif %}
105+
<span class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
106+
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
107+
</span>
108+
{% if page_obj.has_next %}
109+
<a href="?page={{ page_obj.next_page_number }}{% if filter_type %}&type={{ filter_type }}{% endif %}"
110+
class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-teal-300">
111+
Next
112+
</a>
113+
{% endif %}
114+
</div>
115+
{% endif %}
116+
</div>
117+
{% endblock content %}

web/templates/base.html

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -375,13 +375,14 @@
375375
</span>
376376
{% endif %}
377377
</a>
378-
<!-- New Notification Button for Invitations -->
379-
<a href="{% url 'user_invitations' %}"
380-
class="relative hover:underline flex items-center p-2 hover:bg-teal-700 rounded-lg">
378+
<!-- Notification Center Button -->
379+
<a href="{% url 'notification_list' %}"
380+
class="relative hover:underline flex items-center p-2 hover:bg-teal-700 rounded-lg"
381+
title="Notifications">
381382
<i class="fas fa-bell"></i>
382-
{% if pending_invites_count %}
383+
{% if unread_notifications_count %}
383384
<span class="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-600 flex items-center justify-center text-white text-xs">
384-
{{ pending_invites_count }}
385+
{{ unread_notifications_count }}
385386
</span>
386387
{% endif %}
387388
</a>

web/urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
features_page,
3434
grade_link,
3535
notification_preferences,
36+
notification_list,
37+
mark_notifications_read,
38+
mark_single_notification_read,
3639
sales_analytics,
3740
sales_data,
3841
streak_detail,
@@ -91,6 +94,13 @@
9194
path("accounts/signup/", views.signup_view, name="account_signup"), # Our custom signup view
9295
path("accounts/", include("allauth.urls")),
9396
path("account/notification-preferences/", notification_preferences, name="notification_preferences"),
97+
path("account/notifications/", notification_list, name="notification_list"),
98+
path("account/notifications/mark-all-read/", mark_notifications_read, name="mark_notifications_read"),
99+
path(
100+
"account/notifications/<int:notification_id>/read/",
101+
mark_single_notification_read,
102+
name="mark_single_notification_read",
103+
),
94104
path("profile/", views.profile, name="profile"),
95105
path("accounts/profile/", views.profile, name="accounts_profile"),
96106
path("accounts/delete/", views.delete_account, name="delete_account"),

web/views.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
JsonResponse,
4747
)
4848
from django.shortcuts import get_object_or_404, redirect, render
49+
from django.urls import reverse
4950
from django.template.loader import render_to_string
5051
from django.urls import NoReverseMatch, reverse, reverse_lazy
5152
from django.utils import timezone
@@ -6948,6 +6949,84 @@ def award_badge(request):
69486949
return JsonResponse({"success": False, "message": "An internal error occurred"}, status=500)
69496950

69506951

6952+
@login_required
6953+
def notification_list(request):
6954+
"""Display all notifications for the current user.
6955+
6956+
Args:
6957+
request: HttpRequest object.
6958+
6959+
Returns:
6960+
Rendered notification center page with notifications.
6961+
"""
6962+
from django.core.paginator import Paginator
6963+
filter_type = request.GET.get("type", "")
6964+
notifications = request.user.notifications.all()
6965+
valid_types = [t[0] for t in Notification.NOTIFICATION_TYPES]
6966+
if filter_type in valid_types:
6967+
notifications = notifications.filter(notification_type=filter_type)
6968+
paginator = Paginator(notifications, 20)
6969+
page_number = request.GET.get("page")
6970+
page_obj = paginator.get_page(page_number)
6971+
context = {
6972+
"page_obj": page_obj,
6973+
"filter_type": filter_type,
6974+
"unread_count": request.user.notifications.filter(read=False).count(),
6975+
"notification_types": Notification.NOTIFICATION_TYPES,
6976+
}
6977+
return render(request, "account/notifications.html", context)
6978+
6979+
6980+
@login_required
6981+
@require_POST
6982+
def mark_notifications_read(request):
6983+
"""Mark all notifications as read for the current user.
6984+
6985+
Args:
6986+
request: HttpRequest object.
6987+
6988+
Returns:
6989+
Redirect back to notification center.
6990+
"""
6991+
from django.utils import timezone
6992+
request.user.notifications.filter(read=False).update(read=True, updated_at=timezone.now())
6993+
filter_type = request.POST.get("filter_type", "")
6994+
page = request.POST.get("page", "")
6995+
params = []
6996+
if filter_type:
6997+
params.append(f"type={filter_type}")
6998+
if page:
6999+
params.append(f"page={page}")
7000+
query = f"?{'&'.join(params)}" if params else ""
7001+
return redirect(f"{reverse('notification_list')}{query}")
7002+
7003+
7004+
@login_required
7005+
@require_POST
7006+
def mark_single_notification_read(request, notification_id):
7007+
"""Mark a single notification as read.
7008+
7009+
Args:
7010+
request: HttpRequest object.
7011+
notification_id: ID of the notification to mark as read.
7012+
7013+
Returns:
7014+
Redirect back to notification center.
7015+
"""
7016+
notification = get_object_or_404(Notification, id=notification_id, user=request.user)
7017+
notification.read = True
7018+
notification.save()
7019+
filter_type = request.POST.get("filter_type", "")
7020+
page = request.POST.get("page", "")
7021+
params = []
7022+
if filter_type:
7023+
params.append(f"type={filter_type}")
7024+
if page:
7025+
params.append(f"page={page}")
7026+
query = f"?{'&'.join(params)}" if params else ""
7027+
return redirect(f"{reverse('notification_list')}{query}")
7028+
7029+
69517030
def notification_preferences(request):
69527031
"""
69537032
Display and update the notification preferences for the logged-in user.

0 commit comments

Comments
 (0)