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.
Commit
92d0b9acd965625cd5bda641155edb9ead4e86677091973d68644a1909041ba3
Parent
d6043155cd326e8…
5 files changed
+28
+1
+35
+23
+28
-13
+28
| --- fossil/cli.py | ||
| +++ fossil/cli.py | ||
| @@ -86,10 +86,38 @@ | ||
| 86 | 86 | if result.returncode == 0: |
| 87 | 87 | return result.stdout |
| 88 | 88 | except (FileNotFoundError, subprocess.TimeoutExpired): |
| 89 | 89 | pass |
| 90 | 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() | |
| 91 | 119 | |
| 92 | 120 | def blame(self, repo_path: Path, filename: str) -> list[dict]: |
| 93 | 121 | """Run fossil blame on a file. Returns [{user, uuid, line_num, text}]. |
| 94 | 122 | |
| 95 | 123 | Requires creating a temp checkout since blame needs an open checkout. |
| 96 | 124 |
| --- 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 |
+1
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -78,10 +78,11 @@ | ||
| 78 | 78 | path("releases/<str:tag_name>/", views.release_detail, name="release_detail"), |
| 79 | 79 | path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"), |
| 80 | 80 | path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"), |
| 81 | 81 | path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"), |
| 82 | 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"), | |
| 83 | 84 | # CI Status API |
| 84 | 85 | path("api/status", views.status_check_api, name="status_check_api"), |
| 85 | 86 | path("api/status/<str:checkin_uuid>/badge.svg", views.status_badge, name="status_badge"), |
| 86 | 87 | # API Tokens |
| 87 | 88 | path("tokens/", views.api_token_list, name="api_tokens"), |
| 88 | 89 |
| --- 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 |
+35
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -3159,10 +3159,45 @@ | ||
| 3159 | 3159 | # Increment download count atomically |
| 3160 | 3160 | ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1) |
| 3161 | 3161 | |
| 3162 | 3162 | return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name) |
| 3163 | 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 | + | |
| 3164 | 3199 | |
| 3165 | 3200 | # --- CI Status Check API --- |
| 3166 | 3201 | |
| 3167 | 3202 | |
| 3168 | 3203 | @csrf_exempt |
| 3169 | 3204 |
| --- 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 @@ | ||
| 61 | 61 | {{ body_html }} |
| 62 | 62 | </div> |
| 63 | 63 | </div> |
| 64 | 64 | {% endif %} |
| 65 | 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 %} | |
| 66 | 89 | |
| 67 | 90 | <!-- Assets --> |
| 68 | 91 | <div class="mt-6"> |
| 69 | 92 | <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"> |
| 70 | 93 | Assets |
| 71 | 94 |
| --- 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 |
+28
-13
| --- templates/includes/sidebar.html | ||
| +++ templates/includes/sidebar.html | ||
| @@ -1,21 +1,24 @@ | ||
| 1 | 1 | <aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }" |
| 2 | 2 | :class="collapsed ? 'w-14' : 'w-60'" |
| 3 | 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> | |
| 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"> | |
| 17 | 20 | |
| 18 | 21 | <!-- Dashboard --> |
| 19 | 22 | <a href="{% url 'dashboard' %}" |
| 20 | 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 %}" |
| 21 | 24 | :title="collapsed ? 'Dashboard' : ''"> |
| @@ -182,6 +185,18 @@ | ||
| 182 | 185 | </div> |
| 183 | 186 | </div> |
| 184 | 187 | {% endif %} |
| 185 | 188 | |
| 186 | 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> | |
| 187 | 202 | </aside> |
| 188 | 203 |
| --- 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 |