FossilRepo

Redesign sidebar + add source archive downloads on releases Sidebar: replaced bare << toggle with branded fossilrepo header (logo + wordmark when expanded, icon when collapsed). Moved collapse toggle to bottom-pinned position with animated chevron rotation. Cleaner collapsed state with icon-only nav and tooltips. Releases: source code download buttons (tar.gz + zip) on release detail when a checkin is linked. Uses fossil tarball/zip CLI commands. Added tarball() and zip_archive() methods to FossilCLI.

lmata 2026-04-07 16:44 trunk
Commit 92d0b9acd965625cd5bda641155edb9ead4e86677091973d68644a1909041ba3
--- fossil/cli.py
+++ fossil/cli.py
@@ -86,10 +86,38 @@
8686
if result.returncode == 0:
8787
return result.stdout
8888
except (FileNotFoundError, subprocess.TimeoutExpired):
8989
pass
9090
return ""
91
+
92
+ def tarball(self, repo_path: Path, checkin: str) -> bytes:
93
+ """Generate a tar.gz archive of a checkin. Returns raw bytes."""
94
+ result = subprocess.run(
95
+ [self.binary, "tarball", checkin, "-R", str(repo_path), "/dev/stdout"],
96
+ capture_output=True,
97
+ timeout=120,
98
+ env=self._env,
99
+ )
100
+ if result.returncode != 0:
101
+ return b""
102
+ return result.stdout
103
+
104
+ def zip_archive(self, repo_path: Path, checkin: str) -> bytes:
105
+ """Generate a zip archive of a checkin. Returns raw bytes."""
106
+ import tempfile
107
+
108
+ with tempfile.NamedTemporaryFile(suffix=".zip", delete=True) as tmp:
109
+ result = subprocess.run(
110
+ [self.binary, "zip", checkin, tmp.name, "-R", str(repo_path)],
111
+ capture_output=True,
112
+ text=True,
113
+ timeout=120,
114
+ env=self._env,
115
+ )
116
+ if result.returncode != 0:
117
+ return b""
118
+ return Path(tmp.name).read_bytes()
91119
92120
def blame(self, repo_path: Path, filename: str) -> list[dict]:
93121
"""Run fossil blame on a file. Returns [{user, uuid, line_num, text}].
94122
95123
Requires creating a temp checkout since blame needs an open checkout.
96124
--- fossil/cli.py
+++ fossil/cli.py
@@ -86,10 +86,38 @@
86 if result.returncode == 0:
87 return result.stdout
88 except (FileNotFoundError, subprocess.TimeoutExpired):
89 pass
90 return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
92 def blame(self, repo_path: Path, filename: str) -> list[dict]:
93 """Run fossil blame on a file. Returns [{user, uuid, line_num, text}].
94
95 Requires creating a temp checkout since blame needs an open checkout.
96
--- fossil/cli.py
+++ fossil/cli.py
@@ -86,10 +86,38 @@
86 if result.returncode == 0:
87 return result.stdout
88 except (FileNotFoundError, subprocess.TimeoutExpired):
89 pass
90 return ""
91
92 def tarball(self, repo_path: Path, checkin: str) -> bytes:
93 """Generate a tar.gz archive of a checkin. Returns raw bytes."""
94 result = subprocess.run(
95 [self.binary, "tarball", checkin, "-R", str(repo_path), "/dev/stdout"],
96 capture_output=True,
97 timeout=120,
98 env=self._env,
99 )
100 if result.returncode != 0:
101 return b""
102 return result.stdout
103
104 def zip_archive(self, repo_path: Path, checkin: str) -> bytes:
105 """Generate a zip archive of a checkin. Returns raw bytes."""
106 import tempfile
107
108 with tempfile.NamedTemporaryFile(suffix=".zip", delete=True) as tmp:
109 result = subprocess.run(
110 [self.binary, "zip", checkin, tmp.name, "-R", str(repo_path)],
111 capture_output=True,
112 text=True,
113 timeout=120,
114 env=self._env,
115 )
116 if result.returncode != 0:
117 return b""
118 return Path(tmp.name).read_bytes()
119
120 def blame(self, repo_path: Path, filename: str) -> list[dict]:
121 """Run fossil blame on a file. Returns [{user, uuid, line_num, text}].
122
123 Requires creating a temp checkout since blame needs an open checkout.
124
--- fossil/urls.py
+++ fossil/urls.py
@@ -78,10 +78,11 @@
7878
path("releases/<str:tag_name>/", views.release_detail, name="release_detail"),
7979
path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"),
8080
path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"),
8181
path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"),
8282
path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"),
83
+ path("releases/<str:tag_name>/source.<str:fmt>", views.release_source_archive, name="release_source_archive"),
8384
# CI Status API
8485
path("api/status", views.status_check_api, name="status_check_api"),
8586
path("api/status/<str:checkin_uuid>/badge.svg", views.status_badge, name="status_badge"),
8687
# API Tokens
8788
path("tokens/", views.api_token_list, name="api_tokens"),
8889
--- fossil/urls.py
+++ fossil/urls.py
@@ -78,10 +78,11 @@
78 path("releases/<str:tag_name>/", views.release_detail, name="release_detail"),
79 path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"),
80 path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"),
81 path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"),
82 path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"),
 
83 # CI Status API
84 path("api/status", views.status_check_api, name="status_check_api"),
85 path("api/status/<str:checkin_uuid>/badge.svg", views.status_badge, name="status_badge"),
86 # API Tokens
87 path("tokens/", views.api_token_list, name="api_tokens"),
88
--- fossil/urls.py
+++ fossil/urls.py
@@ -78,10 +78,11 @@
78 path("releases/<str:tag_name>/", views.release_detail, name="release_detail"),
79 path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"),
80 path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"),
81 path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"),
82 path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"),
83 path("releases/<str:tag_name>/source.<str:fmt>", views.release_source_archive, name="release_source_archive"),
84 # CI Status API
85 path("api/status", views.status_check_api, name="status_check_api"),
86 path("api/status/<str:checkin_uuid>/badge.svg", views.status_badge, name="status_badge"),
87 # API Tokens
88 path("tokens/", views.api_token_list, name="api_tokens"),
89
--- fossil/views.py
+++ fossil/views.py
@@ -3159,10 +3159,45 @@
31593159
# Increment download count atomically
31603160
ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
31613161
31623162
return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
31633163
3164
+
3165
+def release_source_archive(request, slug, tag_name, fmt):
3166
+ """Download source archive (tar.gz or zip) for a release's checkin."""
3167
+ from django.http import FileResponse
3168
+
3169
+ project, fossil_repo = _get_project_and_repo(slug, request, "read")
3170
+
3171
+ from fossil.releases import Release
3172
+
3173
+ release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3174
+
3175
+ if not release.checkin_uuid:
3176
+ raise Http404("No checkin linked to this release.")
3177
+
3178
+ from .cli import FossilCLI
3179
+
3180
+ cli = FossilCLI()
3181
+ if fmt == "tar.gz":
3182
+ data = cli.tarball(fossil_repo.full_path, release.checkin_uuid)
3183
+ content_type = "application/gzip"
3184
+ filename = f"{project.slug}-{tag_name}.tar.gz"
3185
+ elif fmt == "zip":
3186
+ data = cli.zip_archive(fossil_repo.full_path, release.checkin_uuid)
3187
+ content_type = "application/zip"
3188
+ filename = f"{project.slug}-{tag_name}.zip"
3189
+ else:
3190
+ raise Http404
3191
+
3192
+ if not data:
3193
+ raise Http404("Failed to generate archive.")
3194
+
3195
+ import io
3196
+
3197
+ return FileResponse(io.BytesIO(data), as_attachment=True, filename=filename, content_type=content_type)
3198
+
31643199
31653200
# --- CI Status Check API ---
31663201
31673202
31683203
@csrf_exempt
31693204
--- fossil/views.py
+++ fossil/views.py
@@ -3159,10 +3159,45 @@
3159 # Increment download count atomically
3160 ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
3161
3162 return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
3163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3164
3165 # --- CI Status Check API ---
3166
3167
3168 @csrf_exempt
3169
--- fossil/views.py
+++ fossil/views.py
@@ -3159,10 +3159,45 @@
3159 # Increment download count atomically
3160 ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
3161
3162 return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
3163
3164
3165 def release_source_archive(request, slug, tag_name, fmt):
3166 """Download source archive (tar.gz or zip) for a release's checkin."""
3167 from django.http import FileResponse
3168
3169 project, fossil_repo = _get_project_and_repo(slug, request, "read")
3170
3171 from fossil.releases import Release
3172
3173 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3174
3175 if not release.checkin_uuid:
3176 raise Http404("No checkin linked to this release.")
3177
3178 from .cli import FossilCLI
3179
3180 cli = FossilCLI()
3181 if fmt == "tar.gz":
3182 data = cli.tarball(fossil_repo.full_path, release.checkin_uuid)
3183 content_type = "application/gzip"
3184 filename = f"{project.slug}-{tag_name}.tar.gz"
3185 elif fmt == "zip":
3186 data = cli.zip_archive(fossil_repo.full_path, release.checkin_uuid)
3187 content_type = "application/zip"
3188 filename = f"{project.slug}-{tag_name}.zip"
3189 else:
3190 raise Http404
3191
3192 if not data:
3193 raise Http404("Failed to generate archive.")
3194
3195 import io
3196
3197 return FileResponse(io.BytesIO(data), as_attachment=True, filename=filename, content_type=content_type)
3198
3199
3200 # --- CI Status Check API ---
3201
3202
3203 @csrf_exempt
3204
--- templates/fossil/release_detail.html
+++ templates/fossil/release_detail.html
@@ -61,10 +61,33 @@
6161
{{ body_html }}
6262
</div>
6363
</div>
6464
{% endif %}
6565
</div>
66
+
67
+<!-- Source Archives -->
68
+{% if release.checkin_uuid %}
69
+<div class="mt-6">
70
+ <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Source Code</h3>
71
+ <div class="flex gap-3">
72
+ <a href="{% url 'fossil:release_source_archive' slug=project.slug tag_name=release.tag_name fmt='tar.gz' %}"
73
+ class="inline-flex items-center gap-2 rounded-md bg-gray-800 border border-gray-700 px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 hover:border-gray-600">
74
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
75
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
76
+ </svg>
77
+ Source code (tar.gz)
78
+ </a>
79
+ <a href="{% url 'fossil:release_source_archive' slug=project.slug tag_name=release.tag_name fmt='zip' %}"
80
+ class="inline-flex items-center gap-2 rounded-md bg-gray-800 border border-gray-700 px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 hover:border-gray-600">
81
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
82
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
83
+ </svg>
84
+ Source code (zip)
85
+ </a>
86
+ </div>
87
+</div>
88
+{% endif %}
6689
6790
<!-- Assets -->
6891
<div class="mt-6">
6992
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
7093
Assets
7194
--- templates/fossil/release_detail.html
+++ templates/fossil/release_detail.html
@@ -61,10 +61,33 @@
61 {{ body_html }}
62 </div>
63 </div>
64 {% endif %}
65 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
67 <!-- Assets -->
68 <div class="mt-6">
69 <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
70 Assets
71
--- templates/fossil/release_detail.html
+++ templates/fossil/release_detail.html
@@ -61,10 +61,33 @@
61 {{ body_html }}
62 </div>
63 </div>
64 {% endif %}
65 </div>
66
67 <!-- Source Archives -->
68 {% if release.checkin_uuid %}
69 <div class="mt-6">
70 <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Source Code</h3>
71 <div class="flex gap-3">
72 <a href="{% url 'fossil:release_source_archive' slug=project.slug tag_name=release.tag_name fmt='tar.gz' %}"
73 class="inline-flex items-center gap-2 rounded-md bg-gray-800 border border-gray-700 px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 hover:border-gray-600">
74 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
75 <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
76 </svg>
77 Source code (tar.gz)
78 </a>
79 <a href="{% url 'fossil:release_source_archive' slug=project.slug tag_name=release.tag_name fmt='zip' %}"
80 class="inline-flex items-center gap-2 rounded-md bg-gray-800 border border-gray-700 px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 hover:border-gray-600">
81 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
82 <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
83 </svg>
84 Source code (zip)
85 </a>
86 </div>
87 </div>
88 {% endif %}
89
90 <!-- Assets -->
91 <div class="mt-6">
92 <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
93 Assets
94
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -1,21 +1,24 @@
11
<aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
22
:class="collapsed ? 'w-14' : 'w-60'"
33
class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
4
- <nav class="flex-1 px-2 py-4 space-y-1">
5
-
6
- <!-- Collapse toggle -->
7
- <button @click="collapsed = !collapsed; localStorage.setItem('sidebarCollapsed', collapsed)"
8
- class="flex items-center justify-center w-full rounded-md px-2 py-2 text-gray-500 hover:text-white hover:bg-gray-800 mb-2"
9
- :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
10
- <svg x-show="!collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
11
- <path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
12
- </svg>
13
- <svg x-show="collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none">
14
- <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
15
- </svg>
16
- </button>
4
+
5
+ <!-- Sidebar header — branded -->
6
+ <div class="px-3 py-4 border-b border-gray-800 flex-shrink-0">
7
+ <a href="{% url 'dashboard' %}" class="flex items-center gap-2" :title="collapsed ? 'Fossilrepo' : ''">
8
+ <svg class="h-6 w-6 flex-shrink-0 text-brand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
9
+ <circle cx="12" cy="12" r="10" stroke-opacity="0.6"/>
10
+ <circle cx="12" cy="12" r="4" fill="currentColor" fill-opacity="0.3"/>
11
+ <path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke-opacity="0.4"/>
12
+ </svg>
13
+ <span x-show="!collapsed" class="text-sm font-bold tracking-tight">
14
+ <span class="text-gray-100">fossil</span><span class="text-brand">repo</span>
15
+ </span>
16
+ </a>
17
+ </div>
18
+
19
+ <nav class="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
1720
1821
<!-- Dashboard -->
1922
<a href="{% url 'dashboard' %}"
2023
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
2124
:title="collapsed ? 'Dashboard' : ''">
@@ -182,6 +185,18 @@
182185
</div>
183186
</div>
184187
{% endif %}
185188
186189
</nav>
190
+
191
+ <!-- Bottom-pinned collapse toggle -->
192
+ <div class="flex-shrink-0 border-t border-gray-800 px-2 py-2">
193
+ <button @click="collapsed = !collapsed; localStorage.setItem('sidebarCollapsed', collapsed)"
194
+ class="flex items-center gap-2 w-full rounded-md px-2 py-2 text-xs text-gray-500 hover:text-white hover:bg-gray-800 transition-colors"
195
+ :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
196
+ <svg :class="collapsed && 'rotate-180'" class="h-4 w-4 flex-shrink-0 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
197
+ <path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
198
+ </svg>
199
+ <span x-show="!collapsed" class="truncate">Collapse</span>
200
+ </button>
201
+ </div>
187202
</aside>
188203
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -1,21 +1,24 @@
1 <aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
2 :class="collapsed ? 'w-14' : 'w-60'"
3 class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
4 <nav class="flex-1 px-2 py-4 space-y-1">
5
6 <!-- Collapse toggle -->
7 <button @click="collapsed = !collapsed; localStorage.setItem('sidebarCollapsed', collapsed)"
8 class="flex items-center justify-center w-full rounded-md px-2 py-2 text-gray-500 hover:text-white hover:bg-gray-800 mb-2"
9 :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
10 <svg x-show="!collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
11 <path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
12 </svg>
13 <svg x-show="collapsed" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="display:none">
14 <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
15 </svg>
16 </button>
 
 
 
17
18 <!-- Dashboard -->
19 <a href="{% url 'dashboard' %}"
20 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
21 :title="collapsed ? 'Dashboard' : ''">
@@ -182,6 +185,18 @@
182 </div>
183 </div>
184 {% endif %}
185
186 </nav>
 
 
 
 
 
 
 
 
 
 
 
 
187 </aside>
188
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -1,21 +1,24 @@
1 <aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
2 :class="collapsed ? 'w-14' : 'w-60'"
3 class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
4
5 <!-- Sidebar header — branded -->
6 <div class="px-3 py-4 border-b border-gray-800 flex-shrink-0">
7 <a href="{% url 'dashboard' %}" class="flex items-center gap-2" :title="collapsed ? 'Fossilrepo' : ''">
8 <svg class="h-6 w-6 flex-shrink-0 text-brand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
9 <circle cx="12" cy="12" r="10" stroke-opacity="0.6"/>
10 <circle cx="12" cy="12" r="4" fill="currentColor" fill-opacity="0.3"/>
11 <path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke-opacity="0.4"/>
12 </svg>
13 <span x-show="!collapsed" class="text-sm font-bold tracking-tight">
14 <span class="text-gray-100">fossil</span><span class="text-brand">repo</span>
15 </span>
16 </a>
17 </div>
18
19 <nav class="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
20
21 <!-- Dashboard -->
22 <a href="{% url 'dashboard' %}"
23 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
24 :title="collapsed ? 'Dashboard' : ''">
@@ -182,6 +185,18 @@
185 </div>
186 </div>
187 {% endif %}
188
189 </nav>
190
191 <!-- Bottom-pinned collapse toggle -->
192 <div class="flex-shrink-0 border-t border-gray-800 px-2 py-2">
193 <button @click="collapsed = !collapsed; localStorage.setItem('sidebarCollapsed', collapsed)"
194 class="flex items-center gap-2 w-full rounded-md px-2 py-2 text-xs text-gray-500 hover:text-white hover:bg-gray-800 transition-colors"
195 :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'">
196 <svg :class="collapsed && 'rotate-180'" class="h-4 w-4 flex-shrink-0 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
197 <path stroke-linecap="round" stroke-linejoin="round" d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" />
198 </svg>
199 <span x-show="!collapsed" class="truncate">Collapse</span>
200 </button>
201 </div>
202 </aside>
203

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button