FossilRepo
Add contribution heatmap, technotes, custom error pages, keyboard nav Contribution heatmap: - GitHub-style daily activity grid on user profile page - 365 days of data, color-coded by commit count - Tooltips showing date and count on hover Technotes: - New /fossil/technotes/ view for Fossil's blog-like entries - Lists all technotes with timestamps, users, descriptions Custom error pages: - Branded 403, 404, 500 pages with navigation buttons - Extends base.html for consistent dark theme Keyboard navigation: - j/k to move between timeline entries - Enter to open the focused checkin - / to open global search (added earlier)
Commit
8586c2d9dd835ecc5c14a086fb0e546c43f18dc630654333d48fce3c85a746ff
Parent
711e261845fb8c8…
9 files changed
+46
+1
+23
+19
+15
+15
+12
+24
+33
+46
| --- fossil/reader.py | ||
| +++ fossil/reader.py | ||
| @@ -328,13 +328,59 @@ | ||
| 328 | 328 | result["forum_count"] = row[0] if row else 0 |
| 329 | 329 | |
| 330 | 330 | # Ticket-related event count |
| 331 | 331 | row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='t'", (username,)).fetchone() |
| 332 | 332 | result["ticket_count"] = row[0] if row else 0 |
| 333 | + # Daily activity heatmap (last 365 days) | |
| 334 | + result["daily_activity"] = {} | |
| 335 | + try: | |
| 336 | + rows = self.conn.execute( | |
| 337 | + """ | |
| 338 | + SELECT date(event.mtime - 0.5) as day, count(*) as cnt | |
| 339 | + FROM event | |
| 340 | + WHERE event.user = ? AND event.type = 'ci' | |
| 341 | + AND event.mtime > julianday('now') - 365 | |
| 342 | + GROUP BY day ORDER BY day | |
| 343 | + """, | |
| 344 | + (username,), | |
| 345 | + ).fetchall() | |
| 346 | + for r in rows: | |
| 347 | + if r["day"]: | |
| 348 | + result["daily_activity"][r["day"]] = r["cnt"] | |
| 349 | + except sqlite3.OperationalError: | |
| 350 | + pass | |
| 333 | 351 | except sqlite3.OperationalError: |
| 334 | 352 | pass |
| 335 | 353 | return result |
| 354 | + | |
| 355 | + def get_technotes(self, limit: int = 50) -> list[dict]: | |
| 356 | + """Get technotes (timestamped blog-like entries).""" | |
| 357 | + notes = [] | |
| 358 | + try: | |
| 359 | + rows = self.conn.execute( | |
| 360 | + """ | |
| 361 | + SELECT blob.uuid, event.mtime, event.user, event.comment | |
| 362 | + FROM event | |
| 363 | + JOIN blob ON event.objid = blob.rid | |
| 364 | + WHERE event.type = 'e' | |
| 365 | + ORDER BY event.mtime DESC | |
| 366 | + LIMIT ? | |
| 367 | + """, | |
| 368 | + (limit,), | |
| 369 | + ).fetchall() | |
| 370 | + for r in rows: | |
| 371 | + notes.append( | |
| 372 | + { | |
| 373 | + "uuid": r["uuid"], | |
| 374 | + "timestamp": _julian_to_datetime(r["mtime"]), | |
| 375 | + "user": r["user"] or "", | |
| 376 | + "comment": r["comment"] or "", | |
| 377 | + } | |
| 378 | + ) | |
| 379 | + except sqlite3.OperationalError: | |
| 380 | + pass | |
| 381 | + return notes | |
| 336 | 382 | |
| 337 | 383 | def get_commit_activity(self, weeks: int = 52) -> list[dict]: |
| 338 | 384 | """Get weekly commit counts for the last N weeks. Returns [{week, count}].""" |
| 339 | 385 | activity = [] |
| 340 | 386 | try: |
| 341 | 387 |
| --- fossil/reader.py | |
| +++ fossil/reader.py | |
| @@ -328,13 +328,59 @@ | |
| 328 | result["forum_count"] = row[0] if row else 0 |
| 329 | |
| 330 | # Ticket-related event count |
| 331 | row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='t'", (username,)).fetchone() |
| 332 | result["ticket_count"] = row[0] if row else 0 |
| 333 | except sqlite3.OperationalError: |
| 334 | pass |
| 335 | return result |
| 336 | |
| 337 | def get_commit_activity(self, weeks: int = 52) -> list[dict]: |
| 338 | """Get weekly commit counts for the last N weeks. Returns [{week, count}].""" |
| 339 | activity = [] |
| 340 | try: |
| 341 |
| --- fossil/reader.py | |
| +++ fossil/reader.py | |
| @@ -328,13 +328,59 @@ | |
| 328 | result["forum_count"] = row[0] if row else 0 |
| 329 | |
| 330 | # Ticket-related event count |
| 331 | row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='t'", (username,)).fetchone() |
| 332 | result["ticket_count"] = row[0] if row else 0 |
| 333 | # Daily activity heatmap (last 365 days) |
| 334 | result["daily_activity"] = {} |
| 335 | try: |
| 336 | rows = self.conn.execute( |
| 337 | """ |
| 338 | SELECT date(event.mtime - 0.5) as day, count(*) as cnt |
| 339 | FROM event |
| 340 | WHERE event.user = ? AND event.type = 'ci' |
| 341 | AND event.mtime > julianday('now') - 365 |
| 342 | GROUP BY day ORDER BY day |
| 343 | """, |
| 344 | (username,), |
| 345 | ).fetchall() |
| 346 | for r in rows: |
| 347 | if r["day"]: |
| 348 | result["daily_activity"][r["day"]] = r["cnt"] |
| 349 | except sqlite3.OperationalError: |
| 350 | pass |
| 351 | except sqlite3.OperationalError: |
| 352 | pass |
| 353 | return result |
| 354 | |
| 355 | def get_technotes(self, limit: int = 50) -> list[dict]: |
| 356 | """Get technotes (timestamped blog-like entries).""" |
| 357 | notes = [] |
| 358 | try: |
| 359 | rows = self.conn.execute( |
| 360 | """ |
| 361 | SELECT blob.uuid, event.mtime, event.user, event.comment |
| 362 | FROM event |
| 363 | JOIN blob ON event.objid = blob.rid |
| 364 | WHERE event.type = 'e' |
| 365 | ORDER BY event.mtime DESC |
| 366 | LIMIT ? |
| 367 | """, |
| 368 | (limit,), |
| 369 | ).fetchall() |
| 370 | for r in rows: |
| 371 | notes.append( |
| 372 | { |
| 373 | "uuid": r["uuid"], |
| 374 | "timestamp": _julian_to_datetime(r["mtime"]), |
| 375 | "user": r["user"] or "", |
| 376 | "comment": r["comment"] or "", |
| 377 | } |
| 378 | ) |
| 379 | except sqlite3.OperationalError: |
| 380 | pass |
| 381 | return notes |
| 382 | |
| 383 | def get_commit_activity(self, weeks: int = 52) -> list[dict]: |
| 384 | """Get weekly commit counts for the last N weeks. Returns [{week, count}].""" |
| 385 | activity = [] |
| 386 | try: |
| 387 |
+1
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -20,10 +20,11 @@ | ||
| 20 | 20 | path("forum/", views.forum_list, name="forum"), |
| 21 | 21 | path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), |
| 22 | 22 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 23 | 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | + path("technotes/", views.technote_list, name="technotes"), | |
| 25 | 26 | path("search/", views.search, name="search"), |
| 26 | 27 | path("stats/", views.repo_stats, name="stats"), |
| 27 | 28 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 28 | 29 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 29 | 30 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 30 | 31 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -20,10 +20,11 @@ | |
| 20 | path("forum/", views.forum_list, name="forum"), |
| 21 | path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), |
| 22 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | path("search/", views.search, name="search"), |
| 26 | path("stats/", views.repo_stats, name="stats"), |
| 27 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 28 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 29 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 30 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -20,10 +20,11 @@ | |
| 20 | path("forum/", views.forum_list, name="forum"), |
| 21 | path("forum/<str:thread_uuid>/", views.forum_thread, name="forum_thread"), |
| 22 | path("user/<str:username>/", views.user_activity, name="user_activity"), |
| 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | path("technotes/", views.technote_list, name="technotes"), |
| 26 | path("search/", views.search, name="search"), |
| 27 | path("stats/", views.repo_stats, name="stats"), |
| 28 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 29 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 30 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 31 |
+23
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -850,22 +850,45 @@ | ||
| 850 | 850 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 851 | 851 | |
| 852 | 852 | with reader: |
| 853 | 853 | activity = reader.get_user_activity(username) |
| 854 | 854 | |
| 855 | + import json | |
| 856 | + | |
| 857 | + heatmap_json = json.dumps(activity.get("daily_activity", {})) | |
| 858 | + | |
| 855 | 859 | return render( |
| 856 | 860 | request, |
| 857 | 861 | "fossil/user_activity.html", |
| 858 | 862 | { |
| 859 | 863 | "project": project, |
| 860 | 864 | "fossil_repo": fossil_repo, |
| 861 | 865 | "username": username, |
| 862 | 866 | "activity": activity, |
| 867 | + "heatmap_json": heatmap_json, | |
| 863 | 868 | "active_tab": "timeline", |
| 864 | 869 | }, |
| 865 | 870 | ) |
| 866 | 871 | |
| 872 | + | |
| 873 | +# --- Technotes --- | |
| 874 | + | |
| 875 | + | |
| 876 | +@login_required | |
| 877 | +def technote_list(request, slug): | |
| 878 | + P.PROJECT_VIEW.check(request.user) | |
| 879 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 880 | + | |
| 881 | + with reader: | |
| 882 | + notes = reader.get_technotes() | |
| 883 | + | |
| 884 | + return render( | |
| 885 | + request, | |
| 886 | + "fossil/technote_list.html", | |
| 887 | + {"project": project, "notes": notes, "active_tab": "wiki"}, | |
| 888 | + ) | |
| 889 | + | |
| 867 | 890 | |
| 868 | 891 | # --- Search --- |
| 869 | 892 | |
| 870 | 893 | |
| 871 | 894 | @login_required |
| 872 | 895 | |
| 873 | 896 | ADDED templates/403.html |
| 874 | 897 | ADDED templates/404.html |
| 875 | 898 | ADDED templates/500.html |
| 876 | 899 | ADDED templates/fossil/technote_list.html |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -850,22 +850,45 @@ | |
| 850 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 851 | |
| 852 | with reader: |
| 853 | activity = reader.get_user_activity(username) |
| 854 | |
| 855 | return render( |
| 856 | request, |
| 857 | "fossil/user_activity.html", |
| 858 | { |
| 859 | "project": project, |
| 860 | "fossil_repo": fossil_repo, |
| 861 | "username": username, |
| 862 | "activity": activity, |
| 863 | "active_tab": "timeline", |
| 864 | }, |
| 865 | ) |
| 866 | |
| 867 | |
| 868 | # --- Search --- |
| 869 | |
| 870 | |
| 871 | @login_required |
| 872 | |
| 873 | DDED templates/403.html |
| 874 | DDED templates/404.html |
| 875 | DDED templates/500.html |
| 876 | DDED templates/fossil/technote_list.html |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -850,22 +850,45 @@ | |
| 850 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 851 | |
| 852 | with reader: |
| 853 | activity = reader.get_user_activity(username) |
| 854 | |
| 855 | import json |
| 856 | |
| 857 | heatmap_json = json.dumps(activity.get("daily_activity", {})) |
| 858 | |
| 859 | return render( |
| 860 | request, |
| 861 | "fossil/user_activity.html", |
| 862 | { |
| 863 | "project": project, |
| 864 | "fossil_repo": fossil_repo, |
| 865 | "username": username, |
| 866 | "activity": activity, |
| 867 | "heatmap_json": heatmap_json, |
| 868 | "active_tab": "timeline", |
| 869 | }, |
| 870 | ) |
| 871 | |
| 872 | |
| 873 | # --- Technotes --- |
| 874 | |
| 875 | |
| 876 | @login_required |
| 877 | def technote_list(request, slug): |
| 878 | P.PROJECT_VIEW.check(request.user) |
| 879 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 880 | |
| 881 | with reader: |
| 882 | notes = reader.get_technotes() |
| 883 | |
| 884 | return render( |
| 885 | request, |
| 886 | "fossil/technote_list.html", |
| 887 | {"project": project, "notes": notes, "active_tab": "wiki"}, |
| 888 | ) |
| 889 | |
| 890 | |
| 891 | # --- Search --- |
| 892 | |
| 893 | |
| 894 | @login_required |
| 895 | |
| 896 | DDED templates/403.html |
| 897 | DDED templates/404.html |
| 898 | DDED templates/500.html |
| 899 | DDED templates/fossil/technote_list.html |
+19
| --- a/templates/403.html | ||
| +++ b/templates/403.html | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Access Denied — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="flex flex-col items-center justify-center py-20"> | |
| 6 | + <div class="text-6/div> | |
| 7 | + <h1 clasbrand mb-4">403</div> | |
| 8 | + <h1 class="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1> | |
| 9 | + -2">Access Denied</h1> | |
| 10 | + 6 text-center max-w-md"> | |
| 11 | + You don't have perm | |
| 12 | + </p> | |
| 13 | + <div class="flex gap-3"> | |
| 14 | +x gap-3 jubrand px-4 py-2-[var(--brandbg-brand-hover">Go to Dashboard</a> | |
| 15 | + <button onclick="history.back()l lang="en"> | |
| 16 | +<head> | |
| 17 | + <<!DOCTYPE html> | |
| 18 | +<h-[var(--brand)] px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition">Go Home</a> | |
| 19 | + <a href="/auth/login/" class="rounded-md bg-graygray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">G |
| --- a/templates/403.html | |
| +++ b/templates/403.html | |
| @@ -0,0 +1,19 @@ | |
| --- a/templates/403.html | |
| +++ b/templates/403.html | |
| @@ -0,0 +1,19 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Access Denied — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="flex flex-col items-center justify-center py-20"> |
| 6 | <div class="text-6/div> |
| 7 | <h1 clasbrand mb-4">403</div> |
| 8 | <h1 class="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1> |
| 9 | -2">Access Denied</h1> |
| 10 | 6 text-center max-w-md"> |
| 11 | You don't have perm |
| 12 | </p> |
| 13 | <div class="flex gap-3"> |
| 14 | x gap-3 jubrand px-4 py-2-[var(--brandbg-brand-hover">Go to Dashboard</a> |
| 15 | <button onclick="history.back()l lang="en"> |
| 16 | <head> |
| 17 | <<!DOCTYPE html> |
| 18 | <h-[var(--brand)] px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition">Go Home</a> |
| 19 | <a href="/auth/login/" class="rounded-md bg-graygray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">G |
+15
| --- a/templates/404.html | ||
| +++ b/templates/404.html | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Page Not Found — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="flex flex-col items-center justify-center py-20"> | |
| 6 | + <div class="text-6/div> | |
| 7 | + <h1 clasbrand mb-4">404</div> | |
| 8 | + <h1 class="text-2xl font-bold text-gray-100 mb-2">Page Not Found</h1> | |
| 9 | + 2">Page Not Found</h1> | |
| 10 | + 6 text-center max-w-md"> | |
| 11 | + The page you're looking for doesn't exist or has been moved. | |
| 12 | + </p> | |
| 13 | + <div class="flex gap-3"> | |
| 14 | + <a hrebrand px-4 py-2-md bg-gray-800 px-5 py-2.5 white hover:bg-brand-700 px-4 py-2-md bg-gray-800 px-5 py-2.5 text-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button> | |
| 15 | + </ |
| --- a/templates/404.html | |
| +++ b/templates/404.html | |
| @@ -0,0 +1,15 @@ | |
| --- a/templates/404.html | |
| +++ b/templates/404.html | |
| @@ -0,0 +1,15 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Page Not Found — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="flex flex-col items-center justify-center py-20"> |
| 6 | <div class="text-6/div> |
| 7 | <h1 clasbrand mb-4">404</div> |
| 8 | <h1 class="text-2xl font-bold text-gray-100 mb-2">Page Not Found</h1> |
| 9 | 2">Page Not Found</h1> |
| 10 | 6 text-center max-w-md"> |
| 11 | The page you're looking for doesn't exist or has been moved. |
| 12 | </p> |
| 13 | <div class="flex gap-3"> |
| 14 | <a hrebrand px-4 py-2-md bg-gray-800 px-5 py-2.5 white hover:bg-brand-700 px-4 py-2-md bg-gray-800 px-5 py-2.5 text-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button> |
| 15 | </ |
+15
| --- a/templates/500.html | ||
| +++ b/templates/500.html | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Server Error — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<div class="flex flex-col items-center justify-center py-20"> | |
| 6 | + <div class="text-6/div> | |
| 7 | + <h1 clasbrand mb-4">500</div> | |
| 8 | + <h1 class="text-2xl font-bold text-gray-100 mb-2">Something Went Wrong</h1> | |
| 9 | + ething Went Wrong</h1> | |
| 10 | + 6 text-center max-w-md"> | |
| 11 | + An unexpected error occurred. The team has been notified. | |
| 12 | + </p> | |
| 13 | + <div class="flex gap-3"> | |
| 14 | +x gap-3 jubrand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button> | |
| 15 | + </ |
| --- a/templates/500.html | |
| +++ b/templates/500.html | |
| @@ -0,0 +1,15 @@ | |
| --- a/templates/500.html | |
| +++ b/templates/500.html | |
| @@ -0,0 +1,15 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Server Error — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="flex flex-col items-center justify-center py-20"> |
| 6 | <div class="text-6/div> |
| 7 | <h1 clasbrand mb-4">500</div> |
| 8 | <h1 class="text-2xl font-bold text-gray-100 mb-2">Something Went Wrong</h1> |
| 9 | ething Went Wrong</h1> |
| 10 | 6 text-center max-w-md"> |
| 11 | An unexpected error occurred. The team has been notified. |
| 12 | </p> |
| 13 | <div class="flex gap-3"> |
| 14 | x gap-3 jubrand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button> |
| 15 | </ |
| --- a/templates/fossil/technote_list.html | ||
| +++ b/templates/fossil/technote_list.html | ||
| @@ -0,0 +1,12 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% load fossil_filters %} | |
| 3 | +{% block title %}Technotes — {{ project.name }} — Fossilrepo{% endblock %} | |
| 4 | + | |
| 5 | +{% block content %} | |
| 6 | +<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.naiv class="flex ite mb-4">Technotes</h2> | |
| 7 | +x-5 py-4ml" %} | |
| 8 | +{% load fossil_{% extends "base.html" %} | |
| 9 | +{%a> | |
| 10 | + checkincheckin_uuid=note.uuid %}" hover:text-brandadiv> | |
| 11 | + {% empty %} | |
| 12 | +500 py-8 tendblock %} |
| --- a/templates/fossil/technote_list.html | |
| +++ b/templates/fossil/technote_list.html | |
| @@ -0,0 +1,12 @@ | |
| --- a/templates/fossil/technote_list.html | |
| +++ b/templates/fossil/technote_list.html | |
| @@ -0,0 +1,12 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Technotes — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.naiv class="flex ite mb-4">Technotes</h2> |
| 7 | x-5 py-4ml" %} |
| 8 | {% load fossil_{% extends "base.html" %} |
| 9 | {%a> |
| 10 | checkincheckin_uuid=note.uuid %}" hover:text-brandadiv> |
| 11 | {% empty %} |
| 12 | 500 py-8 tendblock %} |
| --- templates/fossil/timeline.html | ||
| +++ templates/fossil/timeline.html | ||
| @@ -25,6 +25,30 @@ | ||
| 25 | 25 | class="inline-flex items-center rounded-md bg-gray-800 px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700"> |
| 26 | 26 | Load more |
| 27 | 27 | </a> |
| 28 | 28 | </div> |
| 29 | 29 | {% endif %} |
| 30 | + | |
| 31 | +<script> | |
| 32 | + // Keyboard navigation: j/k to move between timeline entries | |
| 33 | + (function() { | |
| 34 | + let current = -1; | |
| 35 | + const rows = document.querySelectorAll('.tl-row'); | |
| 36 | + function highlight(idx) { | |
| 37 | + rows.forEach(r => r.style.background = ''); | |
| 38 | + if (idx >= 0 && idx < rows.length) { | |
| 39 | + rows[idx].style.background = 'rgba(220,57,76,0.06)'; | |
| 40 | + rows[idx].scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| 41 | + } | |
| 42 | + } | |
| 43 | + document.addEventListener('keydown', function(e) { | |
| 44 | + if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return; | |
| 45 | + if (e.key === 'j') { current = Math.min(current + 1, rows.length - 1); highlight(current); } | |
| 46 | + else if (e.key === 'k') { current = Math.max(current - 1, 0); highlight(current); } | |
| 47 | + else if (e.key === 'Enter' && current >= 0) { | |
| 48 | + const link = rows[current].querySelector('a[href*="/checkin/"]'); | |
| 49 | + if (link) window.location = link.href; | |
| 50 | + } | |
| 51 | + }); | |
| 52 | + })(); | |
| 53 | +</script> | |
| 30 | 54 | {% endblock %} |
| 31 | 55 |
| --- templates/fossil/timeline.html | |
| +++ templates/fossil/timeline.html | |
| @@ -25,6 +25,30 @@ | |
| 25 | class="inline-flex items-center rounded-md bg-gray-800 px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700"> |
| 26 | Load more |
| 27 | </a> |
| 28 | </div> |
| 29 | {% endif %} |
| 30 | {% endblock %} |
| 31 |
| --- templates/fossil/timeline.html | |
| +++ templates/fossil/timeline.html | |
| @@ -25,6 +25,30 @@ | |
| 25 | class="inline-flex items-center rounded-md bg-gray-800 px-4 py-2 text-sm text-gray-400 hover:text-white border border-gray-700"> |
| 26 | Load more |
| 27 | </a> |
| 28 | </div> |
| 29 | {% endif %} |
| 30 | |
| 31 | <script> |
| 32 | // Keyboard navigation: j/k to move between timeline entries |
| 33 | (function() { |
| 34 | let current = -1; |
| 35 | const rows = document.querySelectorAll('.tl-row'); |
| 36 | function highlight(idx) { |
| 37 | rows.forEach(r => r.style.background = ''); |
| 38 | if (idx >= 0 && idx < rows.length) { |
| 39 | rows[idx].style.background = 'rgba(220,57,76,0.06)'; |
| 40 | rows[idx].scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| 41 | } |
| 42 | } |
| 43 | document.addEventListener('keydown', function(e) { |
| 44 | if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return; |
| 45 | if (e.key === 'j') { current = Math.min(current + 1, rows.length - 1); highlight(current); } |
| 46 | else if (e.key === 'k') { current = Math.max(current - 1, 0); highlight(current); } |
| 47 | else if (e.key === 'Enter' && current >= 0) { |
| 48 | const link = rows[current].querySelector('a[href*="/checkin/"]'); |
| 49 | if (link) window.location = link.href; |
| 50 | } |
| 51 | }); |
| 52 | })(); |
| 53 | </script> |
| 54 | {% endblock %} |
| 55 |
| --- templates/fossil/user_activity.html | ||
| +++ templates/fossil/user_activity.html | ||
| @@ -2,10 +2,43 @@ | ||
| 2 | 2 | {% block title %}{{ username }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | + | |
| 8 | +{% if heatmap_json and heatmap_json != "{}" %} | |
| 9 | +<div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6"> | |
| 10 | + <h3 class="text-sm font-medium text-gray-300 mb-3">Contribution Activity</h3> | |
| 11 | + <div class="overflow-x-auto"> | |
| 12 | + <div id="heatmap" style="display:flex; gap:2px; flex-wrap:wrap;"></div> | |
| 13 | + </div> | |
| 14 | +</div> | |
| 15 | +<script> | |
| 16 | + (function() { | |
| 17 | + const data = {{ heatmap_json|safe }}; | |
| 18 | + const container = document.getElementById('heatmap'); | |
| 19 | + const today = new Date(); | |
| 20 | + for (let i = 364; i >= 0; i--) { | |
| 21 | + const d = new Date(today); | |
| 22 | + d.setDate(d.getDate() - i); | |
| 23 | + const key = d.toISOString().split('T')[0]; | |
| 24 | + const count = data[key] || 0; | |
| 25 | + const el = document.createElement('div'); | |
| 26 | + el.style.width = '10px'; | |
| 27 | + el.style.height = '10px'; | |
| 28 | + el.style.borderRadius = '2px'; | |
| 29 | + el.title = key + ': ' + count + ' commit' + (count !== 1 ? 's' : ''); | |
| 30 | + if (count === 0) el.style.background = '#1f2937'; | |
| 31 | + else if (count <= 2) el.style.background = '#5b2130'; | |
| 32 | + else if (count <= 5) el.style.background = '#8B3138'; | |
| 33 | + else if (count <= 10) el.style.background = '#DC394C'; | |
| 34 | + else el.style.background = '#e8677a'; | |
| 35 | + container.appendChild(el); | |
| 36 | + } | |
| 37 | + })(); | |
| 38 | +</script> | |
| 39 | +{% endif %} | |
| 7 | 40 | |
| 8 | 41 | <div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> |
| 9 | 42 | <!-- Main content --> |
| 10 | 43 | <div class="lg:col-span-2"> |
| 11 | 44 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 12 | 45 |
| --- templates/fossil/user_activity.html | |
| +++ templates/fossil/user_activity.html | |
| @@ -2,10 +2,43 @@ | |
| 2 | {% block title %}{{ username }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> |
| 9 | <!-- Main content --> |
| 10 | <div class="lg:col-span-2"> |
| 11 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 12 |
| --- templates/fossil/user_activity.html | |
| +++ templates/fossil/user_activity.html | |
| @@ -2,10 +2,43 @@ | |
| 2 | {% block title %}{{ username }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | {% if heatmap_json and heatmap_json != "{}" %} |
| 9 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6"> |
| 10 | <h3 class="text-sm font-medium text-gray-300 mb-3">Contribution Activity</h3> |
| 11 | <div class="overflow-x-auto"> |
| 12 | <div id="heatmap" style="display:flex; gap:2px; flex-wrap:wrap;"></div> |
| 13 | </div> |
| 14 | </div> |
| 15 | <script> |
| 16 | (function() { |
| 17 | const data = {{ heatmap_json|safe }}; |
| 18 | const container = document.getElementById('heatmap'); |
| 19 | const today = new Date(); |
| 20 | for (let i = 364; i >= 0; i--) { |
| 21 | const d = new Date(today); |
| 22 | d.setDate(d.getDate() - i); |
| 23 | const key = d.toISOString().split('T')[0]; |
| 24 | const count = data[key] || 0; |
| 25 | const el = document.createElement('div'); |
| 26 | el.style.width = '10px'; |
| 27 | el.style.height = '10px'; |
| 28 | el.style.borderRadius = '2px'; |
| 29 | el.title = key + ': ' + count + ' commit' + (count !== 1 ? 's' : ''); |
| 30 | if (count === 0) el.style.background = '#1f2937'; |
| 31 | else if (count <= 2) el.style.background = '#5b2130'; |
| 32 | else if (count <= 5) el.style.background = '#8B3138'; |
| 33 | else if (count <= 10) el.style.background = '#DC394C'; |
| 34 | else el.style.background = '#e8677a'; |
| 35 | container.appendChild(el); |
| 36 | } |
| 37 | })(); |
| 38 | </script> |
| 39 | {% endif %} |
| 40 | |
| 41 | <div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> |
| 42 | <!-- Main content --> |
| 43 | <div class="lg:col-span-2"> |
| 44 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 45 |