Skip to content

Commit 617d072

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

5 files changed

Lines changed: 206 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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
<button type="submit"
22+
class="text-sm px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition-colors duration-200">
23+
Mark all as read
24+
</button>
25+
</form>
26+
{% endif %}
27+
</div>
28+
29+
<!-- Filter Tabs -->
30+
<div class="flex gap-2 mb-6 flex-wrap">
31+
<a href="{% url 'notification_list' %}"
32+
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors duration-200
33+
{% 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 %}">
34+
All
35+
</a>
36+
{% for type, label in notification_types %}
37+
<a href="{% url 'notification_list' %}?type={{ type }}"
38+
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors duration-200
39+
{% 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 %}">
40+
{{ label }}
41+
</a>
42+
{% endfor %}
43+
</div>
44+
45+
<!-- Notification List -->
46+
<div class="space-y-3">
47+
{% for notification in page_obj %}
48+
<div class="flex items-start gap-4 p-4 rounded-lg border transition-colors duration-200
49+
{% 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 %}">
50+
<!-- Icon -->
51+
<div class="flex-shrink-0 mt-0.5">
52+
{% if notification.notification_type == "success" %}
53+
<i class="fas fa-check-circle text-green-500 text-lg"></i>
54+
{% elif notification.notification_type == "warning" %}
55+
<i class="fas fa-exclamation-triangle text-yellow-500 text-lg"></i>
56+
{% elif notification.notification_type == "error" %}
57+
<i class="fas fa-times-circle text-red-500 text-lg"></i>
58+
{% else %}
59+
<i class="fas fa-info-circle text-blue-500 text-lg"></i>
60+
{% endif %}
61+
</div>
62+
<!-- Content -->
63+
<div class="flex-1 min-w-0">
64+
<div class="flex items-center justify-between gap-2">
65+
<p class="font-semibold text-gray-900 dark:text-white text-sm">{{ notification.title }}</p>
66+
<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
67+
{{ notification.created_at|timesince }} ago
68+
</span>
69+
</div>
70+
<p class="text-sm text-gray-600 dark:text-gray-300 mt-1">{{ notification.message }}</p>
71+
</div>
72+
<!-- Mark as read -->
73+
{% if not notification.read %}
74+
<form method="post" action="{% url 'mark_single_notification_read' notification.id %}">
75+
{% csrf_token %}
76+
<input type="hidden" name="filter_type" value="{{ filter_type }}">
77+
<button type="submit"
78+
title="Mark as read"
79+
aria-label="Mark notification as read"
80+
class="flex-shrink-0 text-teal-500 hover:text-teal-700 transition-colors duration-200">
81+
<i class="fas fa-check text-sm"></i>
82+
</button>
83+
</form>
84+
{% endif %}
85+
</div>
86+
{% empty %}
87+
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
88+
<i class="fas fa-bell-slash text-4xl text-gray-300 dark:text-gray-600 mb-4"></i>
89+
<p class="text-gray-500 dark:text-gray-400 font-medium">No notifications yet</p>
90+
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">You're all caught up!</p>
91+
</div>
92+
{% endfor %}
93+
</div>
94+
<!-- Pagination -->
95+
{% if page_obj.has_other_pages %}
96+
<div class="flex justify-center gap-2 mt-6">
97+
{% if page_obj.has_previous %}
98+
<a href="?page={{ page_obj.previous_page_number }}{% if filter_type %}&type={{ filter_type }}{% endif %}"
99+
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">
100+
Previous
101+
</a>
102+
{% endif %}
103+
<span class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
104+
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
105+
</span>
106+
{% if page_obj.has_next %}
107+
<a href="?page={{ page_obj.next_page_number }}{% if filter_type %}&type={{ filter_type }}{% endif %}"
108+
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">
109+
Next
110+
</a>
111+
{% endif %}
112+
</div>
113+
{% endif %}
114+
</div>
115+
{% 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: 70 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,75 @@ 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+
def mark_notifications_read(request):
6982+
"""Mark all notifications as read for the current user.
6983+
6984+
Args:
6985+
request: HttpRequest object.
6986+
6987+
Returns:
6988+
Redirect back to notification center.
6989+
"""
6990+
if request.method == "POST":
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+
if filter_type:
6995+
return redirect(f"{reverse('notification_list')}?type={filter_type}")
6996+
return redirect("notification_list")
6997+
6998+
6999+
@login_required
7000+
def mark_single_notification_read(request, notification_id):
7001+
"""Mark a single notification as read.
7002+
7003+
Args:
7004+
request: HttpRequest object.
7005+
notification_id: ID of the notification to mark as read.
7006+
7007+
Returns:
7008+
Redirect back to notification center.
7009+
"""
7010+
if request.method != "POST":
7011+
return redirect("notification_list")
7012+
notification = get_object_or_404(Notification, id=notification_id, user=request.user)
7013+
notification.read = True
7014+
notification.save()
7015+
filter_type = request.POST.get("filter_type", "")
7016+
if filter_type:
7017+
return redirect(f"{reverse('notification_list')}?type={filter_type}")
7018+
return redirect("notification_list")
7019+
7020+
69517021
def notification_preferences(request):
69527022
"""
69537023
Display and update the notification preferences for the logged-in user.

0 commit comments

Comments
 (0)