Skip to content

Commit 102e6be

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

5 files changed

Lines changed: 179 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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 notifications.model.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 notifications %}
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+
</div>
95+
{% 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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6948,6 +6948,69 @@ def award_badge(request):
69486948
return JsonResponse({"success": False, "message": "An internal error occurred"}, status=500)
69496949

69506950

6951+
@login_required
6952+
def notification_list(request):
6953+
"""Display all notifications for the current user.
6954+
6955+
Args:
6956+
request: HttpRequest object.
6957+
6958+
Returns:
6959+
Rendered notification center page with notifications.
6960+
"""
6961+
filter_type = request.GET.get("type", "")
6962+
notifications = request.user.notifications.all()
6963+
valid_types = [t[0] for t in Notification.NOTIFICATION_TYPES]
6964+
if filter_type in valid_types:
6965+
notifications = notifications.filter(notification_type=filter_type)
6966+
context = {
6967+
"notifications": notifications,
6968+
"filter_type": filter_type,
6969+
"unread_count": request.user.notifications.filter(read=False).count(),
6970+
}
6971+
return render(request, "account/notifications.html", context)
6972+
6973+
6974+
@login_required
6975+
def mark_notifications_read(request):
6976+
"""Mark all notifications as read for the current user.
6977+
6978+
Args:
6979+
request: HttpRequest object.
6980+
6981+
Returns:
6982+
Redirect back to notification center.
6983+
"""
6984+
if request.method == "POST":
6985+
request.user.notifications.filter(read=False).update(read=True)
6986+
filter_type = request.POST.get("filter_type", "")
6987+
if filter_type:
6988+
return redirect(f"{'/account/notifications/'}?type={filter_type}")
6989+
return redirect("notification_list")
6990+
6991+
6992+
@login_required
6993+
def mark_single_notification_read(request, notification_id):
6994+
"""Mark a single notification as read.
6995+
6996+
Args:
6997+
request: HttpRequest object.
6998+
notification_id: ID of the notification to mark as read.
6999+
7000+
Returns:
7001+
Redirect back to notification center.
7002+
"""
7003+
if request.method != "POST":
7004+
return redirect("notification_list")
7005+
notification = get_object_or_404(Notification, id=notification_id, user=request.user)
7006+
notification.read = True
7007+
notification.save()
7008+
filter_type = request.POST.get("filter_type", "")
7009+
if filter_type:
7010+
return redirect(f"{'/account/notifications/'}?type={filter_type}")
7011+
return redirect("notification_list")
7012+
7013+
69517014
def notification_preferences(request):
69527015
"""
69537016
Display and update the notification preferences for the logged-in user.

0 commit comments

Comments
 (0)