Skip to content

Commit 682e128

Browse files
Add dark mode with user profile preference (#113)
* Initial plan * Add dark mode feature with user profile preference Co-authored-by: alexeygrigorev <875246+alexeygrigorev@users.noreply.github.com> * Remove collected static files from git tracking and update .gitignore Co-authored-by: alexeygrigorev <875246+alexeygrigorev@users.noreply.github.com> * Improve dark mode toggle: use hidden input for CSRF token and update UI dynamically Co-authored-by: alexeygrigorev <875246+alexeygrigorev@users.noreply.github.com> * Add dark mode support for unauthenticated users with localStorage and improve dark mode styling Co-authored-by: alexeygrigorev <875246+alexeygrigorev@users.noreply.github.com> * Use data attributes for authentication check and toggle URL in dark mode JS Co-authored-by: alexeygrigorev <875246+alexeygrigorev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexeygrigorev <875246+alexeygrigorev@users.noreply.github.com>
1 parent 5b832d8 commit 682e128

10 files changed

Lines changed: 439 additions & 8 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
/notebooks/data/
22

3+
# Collected static files
4+
/static/
5+
36
# Byte-compiled / optimized / DLL files
47
__pycache__/
58
*.py[cod]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.4 on 2025-11-29 22:25
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('accounts', '0003_customuser_certificate_name'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='customuser',
15+
name='dark_mode',
16+
field=models.BooleanField(default=False, help_text='Enable dark mode theme', verbose_name='Dark mode'),
17+
),
18+
]

accounts/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ class CustomUser(AbstractUser):
1818
null=True,
1919
help_text="Your actual name that will appear on your certificates"
2020
)
21+
dark_mode = models.BooleanField(
22+
verbose_name="Dark mode",
23+
default=False,
24+
help_text="Enable dark mode theme"
25+
)
2126

2227
def __str__(self):
2328
# safest is to display something stable

accounts/tests.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,51 @@
1-
from django.test import TestCase
1+
from django.test import TestCase, Client
2+
from django.urls import reverse
3+
from accounts.models import CustomUser
24

3-
# Create your tests here.
5+
6+
class DarkModeToggleTestCase(TestCase):
7+
def setUp(self):
8+
self.client = Client()
9+
self.user = CustomUser.objects.create_user(
10+
username='testuser',
11+
email='test@example.com',
12+
password='testpass123'
13+
)
14+
15+
def test_toggle_dark_mode_unauthenticated(self):
16+
"""Test that unauthenticated users cannot toggle dark mode"""
17+
response = self.client.post(reverse('toggle_dark_mode'))
18+
self.assertEqual(response.status_code, 302)
19+
20+
def test_toggle_dark_mode_authenticated(self):
21+
"""Test that authenticated users can toggle dark mode"""
22+
self.client.force_login(self.user)
23+
24+
self.assertFalse(self.user.dark_mode)
25+
26+
response = self.client.post(reverse('toggle_dark_mode'))
27+
self.assertEqual(response.status_code, 200)
28+
29+
self.user.refresh_from_db()
30+
self.assertTrue(self.user.dark_mode)
31+
32+
response = self.client.post(reverse('toggle_dark_mode'))
33+
self.assertEqual(response.status_code, 200)
34+
35+
self.user.refresh_from_db()
36+
self.assertFalse(self.user.dark_mode)
37+
38+
def test_toggle_dark_mode_get_not_allowed(self):
39+
"""Test that GET requests are not allowed"""
40+
self.client.force_login(self.user)
41+
response = self.client.get(reverse('toggle_dark_mode'))
42+
self.assertEqual(response.status_code, 405)
43+
44+
def test_dark_mode_default_value(self):
45+
"""Test that dark_mode defaults to False"""
46+
user = CustomUser.objects.create_user(
47+
username='newuser',
48+
email='new@example.com',
49+
password='testpass123'
50+
)
51+
self.assertFalse(user.dark_mode)

accounts/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
path('login/', views.social_login_view, name='login'),
66
path('email/', views.disabled),
77
path('password/reset/', views.disabled),
8+
path('toggle-dark-mode/', views.toggle_dark_mode, name='toggle_dark_mode'),
89
]

accounts/views.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,24 @@
77

88
from django.conf import settings
99
from django.urls import reverse
10-
from django.http import HttpResponse
10+
from django.http import HttpResponse, JsonResponse
11+
from django.contrib.auth.decorators import login_required
12+
from django.views.decorators.http import require_POST
1113

1214

1315
def disabled(request):
1416
return HttpResponse("This URL is disabled", status=403)
1517

1618

19+
@login_required
20+
@require_POST
21+
def toggle_dark_mode(request):
22+
user = request.user
23+
user.dark_mode = not user.dark_mode
24+
user.save(update_fields=['dark_mode'])
25+
return JsonResponse({'dark_mode': user.dark_mode})
26+
27+
1728
async def social_login_view(request):
1829
providers = await get_available_providers()
1930

course_management/context_processors.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33

44
def export_settings(request):
5+
dark_mode = False
6+
if request.user.is_authenticated:
7+
dark_mode = request.user.dark_mode
58
return {
6-
"VERSION": settings.VERSION
9+
"VERSION": settings.VERSION,
10+
"DARK_MODE": dark_mode,
711
}

courses/static/courses.css

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,4 +190,250 @@ nav .breadcrumbs ul li:not(:first-child)::before {
190190
.lip-links {
191191
display: none;
192192
margin-top: 10px;
193+
}
194+
195+
/* Dark Mode Styles */
196+
html.dark-mode,
197+
body.dark-mode {
198+
background-color: #1a1a2e;
199+
color: #eaeaea;
200+
}
201+
202+
body.dark-mode .bg-dark {
203+
background-color: #16213e !important;
204+
}
205+
206+
body.dark-mode .border-dark {
207+
border-color: #0f3460 !important;
208+
}
209+
210+
body.dark-mode .form-container {
211+
background-color: #16213e !important;
212+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
213+
border-color: #0f3460 !important;
214+
}
215+
216+
body.dark-mode a {
217+
color: #64b5f6;
218+
}
219+
220+
body.dark-mode a:hover {
221+
color: #90caf9;
222+
}
223+
224+
body.dark-mode header nav,
225+
body.dark-mode .breadcrumbs,
226+
body.dark-mode .breadcrumbs a {
227+
color: #eaeaea;
228+
}
229+
230+
body.dark-mode nav .breadcrumbs ul li:not(:first-child)::before {
231+
color: #eaeaea;
232+
}
233+
234+
body.dark-mode .form-control {
235+
background-color: #1a1a2e;
236+
border-color: #0f3460;
237+
color: #eaeaea;
238+
}
239+
240+
body.dark-mode .form-control:focus {
241+
background-color: #1a1a2e;
242+
border-color: #64b5f6;
243+
color: #eaeaea;
244+
}
245+
246+
body.dark-mode .form-check-label {
247+
color: #b0b0b0;
248+
}
249+
250+
body.dark-mode footer {
251+
color: #b0b0b0;
252+
}
253+
254+
body.dark-mode footer a {
255+
color: #64b5f6;
256+
}
257+
258+
body.dark-mode .text-muted {
259+
color: #b0b0b0 !important;
260+
}
261+
262+
body.dark-mode .container .row {
263+
border-bottom-color: #0f3460;
264+
}
265+
266+
/* Fix white/light backgrounds in dark mode */
267+
body.dark-mode .bg-white {
268+
background-color: #1e2a45 !important;
269+
}
270+
271+
body.dark-mode .bg-light {
272+
background-color: #16213e !important;
273+
}
274+
275+
body.dark-mode .bg-info {
276+
background-color: #0d47a1 !important;
277+
color: #eaeaea;
278+
}
279+
280+
body.dark-mode .alert {
281+
background-color: #1a1a2e;
282+
border-color: #0f3460;
283+
color: #eaeaea;
284+
}
285+
286+
body.dark-mode .alert-info {
287+
background-color: #1a365d;
288+
border-color: #2c5282;
289+
color: #90cdf4;
290+
}
291+
292+
body.dark-mode .alert-success {
293+
background-color: #1c4532;
294+
border-color: #276749;
295+
color: #9ae6b4;
296+
}
297+
298+
body.dark-mode .alert-warning {
299+
background-color: #5d4037;
300+
border-color: #795548;
301+
color: #ffcc80;
302+
}
303+
304+
body.dark-mode .alert-danger {
305+
background-color: #742a2a;
306+
border-color: #9b2c2c;
307+
color: #feb2b2;
308+
}
309+
310+
/* Subtle correct/incorrect answer colors for dark mode */
311+
body.dark-mode .option-answer-correct {
312+
background-color: #1c4532;
313+
color: #9ae6b4;
314+
}
315+
316+
body.dark-mode .option-answer-incorrect {
317+
background-color: #742a2a;
318+
color: #feb2b2;
319+
}
320+
321+
body.dark-mode .error-message {
322+
background-color: #5d4037;
323+
border-color: #795548;
324+
color: #ffcc80;
325+
}
326+
327+
body.dark-mode .social-icon {
328+
color: #b0b0b0;
329+
}
330+
331+
body.dark-mode .social-icon:hover {
332+
color: #eaeaea;
333+
}
334+
335+
body.dark-mode h1, body.dark-mode h2, body.dark-mode h3,
336+
body.dark-mode h4, body.dark-mode h5, body.dark-mode h6 {
337+
color: #eaeaea;
338+
}
339+
340+
body.dark-mode .table {
341+
color: #eaeaea;
342+
}
343+
344+
body.dark-mode .table-bordered {
345+
border-color: #0f3460;
346+
}
347+
348+
body.dark-mode .table-bordered td,
349+
body.dark-mode .table-bordered th {
350+
border-color: #0f3460;
351+
}
352+
353+
body.dark-mode .btn-outline-secondary {
354+
color: #b0b0b0;
355+
border-color: #0f3460;
356+
}
357+
358+
body.dark-mode .btn-outline-secondary:hover {
359+
background-color: #0f3460;
360+
color: #eaeaea;
361+
}
362+
363+
body.dark-mode .btn-secondary {
364+
background-color: #4a5568;
365+
border-color: #4a5568;
366+
color: #eaeaea;
367+
}
368+
369+
body.dark-mode .btn-secondary:hover {
370+
background-color: #5a6578;
371+
border-color: #5a6578;
372+
}
373+
374+
body.dark-mode .card {
375+
background-color: #16213e;
376+
border-color: #0f3460;
377+
}
378+
379+
body.dark-mode .card-header {
380+
background-color: #1a1a2e;
381+
border-color: #0f3460;
382+
}
383+
384+
body.dark-mode .card-body {
385+
background-color: #16213e;
386+
}
387+
388+
body.dark-mode .list-group-item {
389+
background-color: #16213e;
390+
border-color: #0f3460;
391+
color: #eaeaea;
392+
}
393+
394+
/* Badges in dark mode */
395+
body.dark-mode .badge {
396+
color: #eaeaea;
397+
}
398+
399+
body.dark-mode .badge.bg-secondary {
400+
background-color: #4a5568 !important;
401+
}
402+
403+
body.dark-mode .badge.bg-success {
404+
background-color: #276749 !important;
405+
}
406+
407+
body.dark-mode .badge.bg-info {
408+
background-color: #2c5282 !important;
409+
}
410+
411+
body.dark-mode .badge.bg-warning {
412+
background-color: #975a16 !important;
413+
color: #fefcbf;
414+
}
415+
416+
/* Row striping in dark mode */
417+
body.dark-mode .row.bg-light,
418+
body.dark-mode .row.bg-white,
419+
body.dark-mode div.bg-white,
420+
body.dark-mode div.bg-light {
421+
background-color: transparent !important;
422+
}
423+
424+
body.dark-mode .p-2.bg-white {
425+
background-color: #1e2a45 !important;
426+
}
427+
428+
/* Border colors */
429+
body.dark-mode .border-bottom {
430+
border-bottom-color: #0f3460 !important;
431+
}
432+
433+
body.dark-mode .border {
434+
border-color: #0f3460 !important;
435+
}
436+
437+
body.dark-mode .rounded {
438+
border-color: #0f3460;
193439
}

0 commit comments

Comments
 (0)