rin
Public Open-source lightweight git hosting.

fix(ui): themed toggles and radios, profile READMEs, tighter repo header

318df4d Browse files
- style input.switch and radios as custom theme-aware controls (repo
  settings, release form, new-repo visibility)
- render the <user>/<user> repository README on the profile page
- size the repo avatar to the title line so it stays on the name row
  and no longer inflates with the expanded description
- symmetric repo banner padding (top gap == bottom gap in all states)
R rin · 2026-10-16 18:54 UTC
7 files changed +261 -13
assets/input.css
+89 -1
... ... @@ -598,6 +598,86 @@ html[data-sidebar="collapsed"] .sidebar-inner {
598 598 .switch input:checked ~ .switch-thumb {
599 599 transform: translateX(0.95rem);
600 600 }
601 +/* Toggle applied straight to the <input> (repo settings, release form) */
602 +input.switch {
603 + appearance: none;
604 + -webkit-appearance: none;
605 + margin: 0;
606 + padding: 0;
607 + transition: background-color 0.15s ease, border-color 0.15s ease;
608 +}
609 +input.switch::after {
610 + content: "";
611 + position: absolute;
612 + top: 50%;
613 + left: 0.16rem;
614 + width: 0.85rem;
615 + height: 0.85rem;
616 + border-radius: 9999px;
617 + background-color: hsl(var(--muted-foreground));
618 + transform: translateY(-50%);
619 + transition: left 0.15s ease, background-color 0.15s ease;
620 +}
621 +input.switch:checked {
622 + background-color: hsl(var(--primary));
623 + border-color: hsl(var(--primary));
624 +}
625 +input.switch:checked::after {
626 + left: calc(100% - 0.85rem - 0.16rem);
627 + background-color: hsl(var(--primary-foreground));
628 +}
629 +input.switch:focus-visible {
630 + outline: 2px solid hsl(var(--ring) / 0.4);
631 + outline-offset: 2px;
632 +}
633 +input.switch:disabled {
634 + opacity: 0.5;
635 + cursor: not-allowed;
636 +}
637 +input.switch:disabled::after {
638 + opacity: 0.7;
639 +}
640 +
641 +/* Radio */
642 +input[type="radio"] {
643 + appearance: none;
644 + -webkit-appearance: none;
645 + position: relative;
646 + display: inline-block;
647 + width: 0.95rem;
648 + height: 0.95rem;
649 + margin: 0;
650 + flex-shrink: 0;
651 + cursor: pointer;
652 + border: 1px solid hsl(var(--input));
653 + border-radius: 9999px;
654 + background-color: hsl(var(--background));
655 + vertical-align: middle;
656 + transition: border-color 0.15s ease, background-color 0.15s ease;
657 +}
658 +input[type="radio"]::after {
659 + content: "";
660 + position: absolute;
661 + inset: 0;
662 + margin: auto;
663 + width: 0.42rem;
664 + height: 0.42rem;
665 + border-radius: 9999px;
666 + background-color: hsl(var(--background));
667 + transform: scale(0);
668 + transition: transform 0.12s ease;
669 +}
670 +input[type="radio"]:checked {
671 + border-color: hsl(var(--foreground));
672 + background-color: hsl(var(--foreground));
673 +}
674 +input[type="radio"]:checked::after {
675 + transform: scale(1);
676 +}
677 +input[type="radio"]:focus-visible {
678 + outline: 2px solid hsl(var(--ring) / 0.4);
679 + outline-offset: 2px;
680 +}
601 681
602 682 /* Cards */
603 683 .card {
... ... @@ -1290,7 +1370,7 @@ kbd, .kbd {
1290 1370
1291 1371 .repo-banner-main {
1292 1372 display: flex;
1293 - align-items: center;
1373 + align-items: flex-start;
1294 1374 gap: 0.7rem;
1295 1375 min-width: 0;
1296 1376 flex: 1 1 20rem;
... ... @@ -1337,7 +1417,15 @@ kbd, .kbd {
1337 1417 overflow: visible;
1338 1418 overflow-wrap: anywhere;
1339 1419 }
1420 +.repo-avatar-link {
1421 + display: flex;
1422 + align-items: center;
1423 + flex-shrink: 0;
1424 +}
1340 1425 .repo-avatar {
1426 + width: 1.65rem;
1427 + height: 1.65rem;
1428 + font-size: 0.68rem;
1341 1429 transition: width 0.2s ease, height 0.2s ease, font-size 0.2s ease;
1342 1430 }
1343 1431
internal/web/handlers/profile_readme_test.go
+49 -0
... ... @@ -0,0 +1,49 @@
1 +package handlers
2 +
3 +import (
4 + "context"
5 + "net/http"
6 + "strings"
7 + "testing"
8 +)
9 +
10 +// TestProfileReadme checks that a repository named after its owner
11 +// (<user>/<user>) has its README rendered on the profile page, GitHub style.
12 +func TestProfileReadme(t *testing.T) {
13 + app, _, userSvc, repoSvc := setupTestApp(t)
14 + ctx := context.Background()
15 +
16 + alice, err := userSvc.Register(ctx, "alice", "alice@example.com", "strongpassword", true)
17 + if err != nil {
18 + t.Fatalf("register alice: %v", err)
19 + }
20 + if _, err := repoSvc.Create(ctx, alice, "alice", "Profile repo", false); err != nil {
21 + t.Fatalf("create profile repo: %v", err)
22 + }
23 + path, _ := repoSvc.DiskPath("alice", "alice")
24 + pushCommit(t, path, "main", "README.md", "# hi alice\n\nProfile **readme** body\n")
25 +
26 + status, body := getPage(t, app, "/alice")
27 + if status != http.StatusOK {
28 + t.Fatalf("profile status %d", status)
29 + }
30 + if !strings.Contains(body, "Profile <strong>readme</strong> body") {
31 + t.Errorf("profile page missing rendered <user>/<user> README")
32 + }
33 +
34 + // A user without a <user>/<user> repository gets no README card.
35 + bob, err := userSvc.Register(ctx, "bob", "bob@example.com", "strongpassword", true)
36 + if err != nil {
37 + t.Fatalf("register bob: %v", err)
38 + }
39 + if _, err := repoSvc.Create(ctx, bob, "engine", "Not a profile repo", false); err != nil {
40 + t.Fatalf("create bob repo: %v", err)
41 + }
42 + status, body = getPage(t, app, "/bob")
43 + if status != http.StatusOK {
44 + t.Fatalf("bob profile status %d", status)
45 + }
46 + if strings.Contains(body, "Profile <strong>readme</strong> body") {
47 + t.Errorf("bob profile must not render alice's README")
48 + }
49 +}
internal/web/handlers/repo.go
+13 -0
... ... @@ -425,6 +425,19 @@ func (h *Handler) UserProfilePage(c *fiber.Ctx) error {
425 425 data["PublicRepoCount"] = publicCount
426 426 data["PrivateRepoCount"] = privateCount
427 427 data["ShowPrivateCount"] = includePrivate
428 +
429 + // GitHub-style profile README: a repository named after its owner
430 + // (<user>/<user>) has its README rendered on the profile page.
431 + if profileRepo, err := h.repoSvc.GetByOwnerAndName(c.UserContext(), profileUser.Username, profileUser.Username); err == nil && profileRepo != nil {
432 + if !profileRepo.IsPrivate || includePrivate {
433 + if diskPath, err := h.repoSvc.DiskPath(profileUser.Username, profileUser.Username); err == nil {
434 + if blob, err := git.FindReadme(c.UserContext(), diskPath, profileRepo.DefaultBranch, ""); err == nil && blob != nil && strings.TrimSpace(string(blob.Data)) != "" {
435 + data["ProfileReadmeHTML"] = views.RenderMarkdown(blob.Data)
436 + }
437 + }
438 + }
439 + }
440 +
428 441 return h.render(c, fiber.StatusOK, "profile", data)
429 442 }
430 443
internal/web/static/css/app.css
+89 -1
... ... @@ -598,6 +598,86 @@ html[data-sidebar="collapsed"] .sidebar-inner {
598 598 .switch input:checked ~ .switch-thumb {
599 599 transform: translateX(0.95rem);
600 600 }
601 +/* Toggle applied straight to the <input> (repo settings, release form) */
602 +input.switch {
603 + appearance: none;
604 + -webkit-appearance: none;
605 + margin: 0;
606 + padding: 0;
607 + transition: background-color 0.15s ease, border-color 0.15s ease;
608 +}
609 +input.switch::after {
610 + content: "";
611 + position: absolute;
612 + top: 50%;
613 + left: 0.16rem;
614 + width: 0.85rem;
615 + height: 0.85rem;
616 + border-radius: 9999px;
617 + background-color: hsl(var(--muted-foreground));
618 + transform: translateY(-50%);
619 + transition: left 0.15s ease, background-color 0.15s ease;
620 +}
621 +input.switch:checked {
622 + background-color: hsl(var(--primary));
623 + border-color: hsl(var(--primary));
624 +}
625 +input.switch:checked::after {
626 + left: calc(100% - 0.85rem - 0.16rem);
627 + background-color: hsl(var(--primary-foreground));
628 +}
629 +input.switch:focus-visible {
630 + outline: 2px solid hsl(var(--ring) / 0.4);
631 + outline-offset: 2px;
632 +}
633 +input.switch:disabled {
634 + opacity: 0.5;
635 + cursor: not-allowed;
636 +}
637 +input.switch:disabled::after {
638 + opacity: 0.7;
639 +}
640 +
641 +/* Radio */
642 +input[type="radio"] {
643 + appearance: none;
644 + -webkit-appearance: none;
645 + position: relative;
646 + display: inline-block;
647 + width: 0.95rem;
648 + height: 0.95rem;
649 + margin: 0;
650 + flex-shrink: 0;
651 + cursor: pointer;
652 + border: 1px solid hsl(var(--input));
653 + border-radius: 9999px;
654 + background-color: hsl(var(--background));
655 + vertical-align: middle;
656 + transition: border-color 0.15s ease, background-color 0.15s ease;
657 +}
658 +input[type="radio"]::after {
659 + content: "";
660 + position: absolute;
661 + inset: 0;
662 + margin: auto;
663 + width: 0.42rem;
664 + height: 0.42rem;
665 + border-radius: 9999px;
666 + background-color: hsl(var(--background));
667 + transform: scale(0);
668 + transition: transform 0.12s ease;
669 +}
670 +input[type="radio"]:checked {
671 + border-color: hsl(var(--foreground));
672 + background-color: hsl(var(--foreground));
673 +}
674 +input[type="radio"]:checked::after {
675 + transform: scale(1);
676 +}
677 +input[type="radio"]:focus-visible {
678 + outline: 2px solid hsl(var(--ring) / 0.4);
679 + outline-offset: 2px;
680 +}
601 681
602 682 /* Cards */
603 683 .card {
... ... @@ -1290,7 +1370,7 @@ kbd, .kbd {
1290 1370
1291 1371 .repo-banner-main {
1292 1372 display: flex;
1293 - align-items: center;
1373 + align-items: flex-start;
1294 1374 gap: 0.7rem;
1295 1375 min-width: 0;
1296 1376 flex: 1 1 20rem;
... ... @@ -1337,7 +1417,15 @@ kbd, .kbd {
1337 1417 overflow: visible;
1338 1418 overflow-wrap: anywhere;
1339 1419 }
1420 +.repo-avatar-link {
1421 + display: flex;
1422 + align-items: center;
1423 + flex-shrink: 0;
1424 +}
1340 1425 .repo-avatar {
1426 + width: 1.65rem;
1427 + height: 1.65rem;
1428 + font-size: 0.68rem;
1341 1429 transition: width 0.2s ease, height 0.2s ease, font-size 0.2s ease;
1342 1430 }
1343 1431
internal/web/static/js/app.js
+11 -10
... ... @@ -465,23 +465,24 @@
465 465 });
466 466
467 467 // Repo header: the description stays on the title line and expands onto its
468 - // own line when clicked. The avatar grows to match the name + description
469 - // block instead of staying centered.
468 + // own line when clicked. The avatar is sized to the title line so it always
469 + // sits on the same row as the repository name, at a modest size.
470 470 var repoAvatarTimer = null;
471 471
472 472 function sizeRepoAvatars() {
473 473 var changed = false;
474 474 document.querySelectorAll('.repo-banner').forEach(function (banner) {
475 475 var avatar = banner.querySelector('.repo-avatar');
476 - var block = banner.querySelector('.repo-head-block');
477 - if (!avatar || !block) return;
478 - var h = Math.round(block.getBoundingClientRect().height);
476 + var title = banner.querySelector('.repo-head-row nav');
477 + if (!avatar || !title) return;
478 + var h = Math.round(title.getBoundingClientRect().height);
479 479 if (h <= 0) return;
480 - var size = h + 'px';
481 - if (avatar.style.width === size) return;
482 - avatar.style.width = size;
483 - avatar.style.height = size;
484 - avatar.style.fontSize = Math.min(22, Math.max(9, Math.round(h * 0.4))) + 'px';
480 + var size = Math.min(30, Math.max(18, h));
481 + var sizePx = size + 'px';
482 + if (avatar.style.width === sizePx) return;
483 + avatar.style.width = sizePx;
484 + avatar.style.height = sizePx;
485 + avatar.style.fontSize = Math.min(20, Math.max(9, Math.round(size * 0.42))) + 'px';
485 486 changed = true;
486 487 });
487 488 return changed;
internal/web/views/pages/profile.html
+9 -0
... ... @@ -43,6 +43,15 @@
43 43 </div>
44 44 </section>
45 45
46 + {{if .ProfileReadmeHTML}}
47 + <!-- Profile README from the <user>/<user> repository -->
48 + <section class="card mb-4">
49 + <div class="card-body markdown-body">
50 + {{.ProfileReadmeHTML}}
51 + </div>
52 + </section>
53 + {{end}}
54 +
46 55 <!-- Repositories -->
47 56 <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
48 57 <h2 class="text-base font-semibold" style="margin:0;">
internal/web/views/partials/components.html
+1 -1
... ... @@ -45,7 +45,7 @@
45 45 {{define "repo_header"}}
46 46 <div class="repo-banner mb-3{{if .Repo.ForkedFromRepoID}} has-subrow{{end}}">
47 47 <div class="repo-banner-main">
48 - <a href="/{{.Repo.OwnerUsername}}" class="flex-shrink-0">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL "repo-avatar"}}</a>
48 + <a href="/{{.Repo.OwnerUsername}}" class="repo-avatar-link">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL "repo-avatar"}}</a>
49 49 <div class="min-w-0 repo-head-block">
50 50 <div class="repo-head-row">
51 51 <nav class="breadcrumb" aria-label="Repository breadcrumb">