rin
Public Open-source lightweight git hosting.

feat(profile): hero layout with large avatar and polished repo cards

c5ae13e Browse files
- profile hero card: 136px avatar with ring, name, bio and info chips
- repository cards: visibility badge, whole-card link, hover accent
- sync avatar/img rules into assets/input.css (had drifted from app.css)
R rin · 2026-10-16 16:31 UTC
12 files changed +669 -318
README.md
+1 -1
... ... @@ -8,7 +8,7 @@ Polar is a small, fast alternative to heavyweight Git servers: push over HTTP, b
8 8
9 9 - **One binary, zero dependencies** — `CGO_ENABLED=0` build, pure-Go SQLite (`modernc.org/sqlite`) in WAL mode, bare repositories stored on disk.
10 10 - **Git Smart HTTP** — anonymous read access for public repositories, token-authenticated push, per-user personal access tokens, and browser-assisted sign-in via the OAuth 2.0 Device Authorization Grant (RFC 8628).
11 -- **Code browsing** — file tree, blob viewer with server-side syntax highlighting (Chroma), commit history, diffs, branches, tags, and zip archives. The **New file** / **New folder** buttons let you create content right in the browser — changes are committed straight to the selected branch, no local checkout required.
11 +- **Code browsing** — file tree, blob viewer with server-side syntax highlighting (Chroma), commit history, diffs, branches, tags, and zip archives. The **New file**, **New folder** and **Upload files** actions let you create or upload content right in the browser — changes are committed straight to the selected branch, no local checkout required.
12 12 - **Collaboration** — issues with comments and open/close state; pull requests with comments, approvals and change requests; forks with cross-fork pull requests; per-repository collaborators with read/write roles; and releases with changelogs.
13 13 - **Polished UI** — `html/template` + htmx, no SPA build step; Markdown rendered with Goldmark and sanitized with bluemonday; Hugeicons outline sprite; self-hosted Inter and JetBrains Mono fonts (Cyrillic subsets included).
14 14 - **Command palette** — press `Ctrl+K` / `Cmd+K` for quick navigation and actions.
assets/input.css
+74 -60
... ... @@ -677,12 +677,62 @@ kbd, .kbd {
677 677 color: hsl(var(--foreground));
678 678 text-transform: uppercase;
679 679 flex-shrink: 0;
680 + overflow: hidden;
681 +}
682 +.avatar-img {
683 + width: 100%;
684 + height: 100%;
685 + object-fit: cover;
686 + display: block;
680 687 }
681 688 .avatar-lg {
682 - width: 2.2rem;
683 - height: 2.2rem;
689 + width: 2.3rem;
690 + height: 2.3rem;
684 691 font-size: 0.9rem;
685 692 }
693 +.avatar-xl {
694 + width: 4.25rem;
695 + height: 4.25rem;
696 + font-size: 1.5rem;
697 +}
698 +.avatar-xxl {
699 + width: 8.5rem;
700 + height: 8.5rem;
701 + font-size: 2.75rem;
702 + box-shadow: 0 0 0 3px hsl(var(--background)), 0 0 0 4px hsl(var(--border));
703 +}
704 +.profile-chip {
705 + display: inline-flex;
706 + align-items: center;
707 + gap: 0.35rem;
708 + padding: 0.28rem 0.65rem;
709 + border: 1px solid hsl(var(--border));
710 + border-radius: 9999px;
711 + background: hsl(var(--muted) / 0.25);
712 + font-size: 0.75rem;
713 + color: hsl(var(--muted-foreground));
714 + text-decoration: none;
715 +}
716 +.profile-chip a {
717 + color: inherit;
718 + text-decoration: none;
719 +}
720 +.profile-chip a:hover {
721 + color: hsl(var(--foreground));
722 +}
723 +.repo-card {
724 + transition: border-color 0.15s ease;
725 +}
726 +.repo-card:hover {
727 + border-color: hsl(var(--foreground) / 0.3);
728 +}
729 +@media (max-width: 640px) {
730 + .avatar-xxl {
731 + width: 6rem;
732 + height: 6rem;
733 + font-size: 2rem;
734 + }
735 +}
686 736
687 737 /* Separator */
688 738 .separator {
... ... @@ -1246,56 +1296,6 @@ kbd, .kbd {
1246 1296
1247 1297
1248 1298
1249 -.repo-layout {
1250 - display: grid;
1251 - grid-template-columns: minmax(0, 1fr) 280px;
1252 - gap: 1rem;
1253 - align-items: start;
1254 -}
1255 -.repo-main {
1256 - min-width: 0;
1257 -}
1258 -.repo-side {
1259 - display: flex;
1260 - flex-direction: column;
1261 - gap: 0.9rem;
1262 - position: sticky;
1263 - top: 0.75rem;
1264 -}
1265 -.side-card {
1266 - border: 1px solid hsl(var(--border));
1267 - border-radius: calc(var(--radius) * 1.3);
1268 - background: hsl(var(--card));
1269 - overflow: hidden;
1270 -}
1271 -.side-card-header {
1272 - display: flex;
1273 - align-items: center;
1274 - gap: 0.4rem;
1275 - padding: 0.5rem 0.8rem;
1276 - font-size: 0.78rem;
1277 - font-weight: 600;
1278 - border-bottom: 1px solid hsl(var(--border));
1279 - background: hsl(var(--muted) / 0.3);
1280 -}
1281 -.side-card-body {
1282 - display: flex;
1283 - flex-direction: column;
1284 - gap: 0.55rem;
1285 - padding: 0.7rem 0.8rem;
1286 - font-size: 0.8rem;
1287 -}
1288 -.meta-row {
1289 - display: flex;
1290 - align-items: center;
1291 - justify-content: space-between;
1292 - gap: 0.5rem;
1293 - color: hsl(var(--muted-foreground));
1294 -}
1295 -.meta-row a,
1296 -.meta-row b {
1297 - color: hsl(var(--foreground));
1298 -}
1299 1299 .clone-box {
1300 1300 display: flex;
1301 1301 align-items: center;
... ... @@ -1314,12 +1314,26 @@ kbd, .kbd {
1314 1314 white-space: nowrap;
1315 1315 font-size: 0.72rem;
1316 1316 }
1317 -@media (max-width: 1000px) {
1318 - .repo-layout {
1319 - grid-template-columns: 1fr;
1320 - }
1321 - .repo-side {
1322 - position: static;
1323 - order: -1;
1324 - }
1317 +.upload-drop {
1318 + display: flex;
1319 + flex-direction: column;
1320 + align-items: center;
1321 + gap: 0.35rem;
1322 + text-align: center;
1323 + padding: 1.6rem 1rem;
1324 + border: 1.5px dashed hsl(var(--border));
1325 + border-radius: calc(var(--radius) * 1.3);
1326 + background: hsl(var(--muted) / 0.2);
1327 + color: hsl(var(--muted-foreground));
1328 + cursor: pointer;
1329 + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
1330 +}
1331 +.upload-drop:hover {
1332 + border-color: hsl(var(--foreground) / 0.4);
1333 + color: hsl(var(--foreground));
1334 +}
1335 +.upload-drop.drag {
1336 + border-color: hsl(var(--foreground) / 0.6);
1337 + background: hsl(var(--muted) / 0.5);
1338 + color: hsl(var(--foreground));
1325 1339 }
internal/git/git.go
+57 -18
... ... @@ -1047,44 +1047,78 @@ func mergeRefs(ctx context.Context, repoPath, baseRef, headRef, commitMsg, autho
1047 1047 return mergeHash, nil
1048 1048 }
1049 1049
1050 +// FileToWrite is a single file change for WriteFilesCommit.
1051 +type FileToWrite struct {
1052 + Path string
1053 + Content []byte
1054 +}
1055 +
1050 1056 // WriteFileCommit creates or replaces filePath on branch with content and
1051 1057 // commits the change. It works on a bare repository directly through git
1052 1058 // plumbing and a temporary index file, so no working tree is required.
1053 1059 func WriteFileCommit(ctx context.Context, repoPath, branch, filePath string, content []byte, commitMsg, authorName, authorEmail string) (string, error) {
1060 + return WriteFilesCommit(ctx, repoPath, branch, []FileToWrite{{Path: filePath, Content: content}}, commitMsg, authorName, authorEmail)
1061 +}
1062 +
1063 +// WriteFilesCommit creates or replaces several files on branch in a single
1064 +// commit, again via git plumbing and a temporary index file.
1065 +func WriteFilesCommit(ctx context.Context, repoPath, branch string, files []FileToWrite, commitMsg, authorName, authorEmail string) (string, error) {
1054 1066 if err := ValidateRef(branch); err != nil {
1055 1067 return "", err
1056 1068 }
1057 - clean, err := CleanSubPath(filePath)
1058 - if err != nil || clean == "" {
1069 + if len(files) == 0 {
1059 1070 return "", ErrInvalidPath
1060 1071 }
1061 - for _, segment := range strings.Split(clean, "/") {
1062 - if segment == ".git" {
1072 + cleaned := make([]FileToWrite, 0, len(files))
1073 + seen := make(map[string]bool, len(files))
1074 + for _, f := range files {
1075 + clean, err := CleanSubPath(f.Path)
1076 + if err != nil || clean == "" {
1063 1077 return "", ErrInvalidPath
1064 1078 }
1079 + for _, segment := range strings.Split(clean, "/") {
1080 + if segment == ".git" {
1081 + return "", ErrInvalidPath
1082 + }
1083 + }
1084 + if seen[clean] {
1085 + return "", ErrInvalidPath
1086 + }
1087 + seen[clean] = true
1088 + cleaned = append(cleaned, FileToWrite{Path: clean, Content: f.Content})
1065 1089 }
1066 1090
1091 + baseHash := ""
1067 1092 baseOut, err := runGit(ctx, repoPath, 5*time.Second, "rev-parse", "refs/heads/"+branch)
1068 1093 if err != nil {
1069 - return "", fmt.Errorf("rev-parse %s: %w", branch, err)
1070 - }
1071 - baseHash := strings.TrimSpace(string(baseOut))
1072 -
1073 - blobOut, err := runGitFull(ctx, repoPath, 10*time.Second, nil, content, "hash-object", "-w", "--stdin")
1074 - if err != nil {
1075 - return "", fmt.Errorf("hash-object: %w", err)
1094 + // An empty repository has no branches yet — create the branch with
1095 + // this commit. A missing branch in a non-empty repository stays an
1096 + // error so typos never silently spawn new refs.
1097 + if branches, lerr := ListBranches(ctx, repoPath); lerr != nil || len(branches) > 0 {
1098 + return "", fmt.Errorf("rev-parse %s: %w", branch, err)
1099 + }
1100 + } else {
1101 + baseHash = strings.TrimSpace(string(baseOut))
1076 1102 }
1077 - blobHash := strings.TrimSpace(string(blobOut))
1078 1103
1079 1104 indexPath := filepath.Join(os.TempDir(), fmt.Sprintf("polar-index-%d-%d", time.Now().UnixNano(), os.Getpid()))
1080 1105 defer os.Remove(indexPath)
1081 1106 indexEnv := []string{"GIT_INDEX_FILE=" + indexPath}
1082 1107
1083 - if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "read-tree", baseHash); err != nil {
1084 - return "", fmt.Errorf("read-tree: %w", err)
1108 + if baseHash != "" {
1109 + if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "read-tree", baseHash); err != nil {
1110 + return "", fmt.Errorf("read-tree: %w", err)
1111 + }
1085 1112 }
1086 - if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "update-index", "--add", "--cacheinfo", "100644,"+blobHash+","+clean); err != nil {
1087 - return "", fmt.Errorf("update-index: %w", err)
1113 + for _, f := range cleaned {
1114 + blobOut, err := runGitFull(ctx, repoPath, 10*time.Second, nil, f.Content, "hash-object", "-w", "--stdin")
1115 + if err != nil {
1116 + return "", fmt.Errorf("hash-object: %w", err)
1117 + }
1118 + blobHash := strings.TrimSpace(string(blobOut))
1119 + if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "update-index", "--add", "--cacheinfo", "100644,"+blobHash+","+f.Path); err != nil {
1120 + return "", fmt.Errorf("update-index: %w", err)
1121 + }
1088 1122 }
1089 1123 treeOut, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "write-tree")
1090 1124 if err != nil {
... ... @@ -1093,7 +1127,7 @@ func WriteFileCommit(ctx context.Context, repoPath, branch, filePath string, con
1093 1127 treeHash := strings.TrimSpace(string(treeOut))
1094 1128
1095 1129 if commitMsg == "" {
1096 - commitMsg = "Add " + clean
1130 + commitMsg = "Add " + cleaned[0].Path
1097 1131 }
1098 1132 authorEnv := []string{
1099 1133 "GIT_AUTHOR_NAME=" + authorName,
... ... @@ -1101,7 +1135,12 @@ func WriteFileCommit(ctx context.Context, repoPath, branch, filePath string, con
1101 1135 "GIT_COMMITTER_NAME=" + authorName,
1102 1136 "GIT_COMMITTER_EMAIL=" + authorEmail,
1103 1137 }
1104 - commitOut, err := runGitFull(ctx, repoPath, 10*time.Second, authorEnv, nil, "commit-tree", treeHash, "-p", baseHash, "-m", commitMsg)
1138 + commitArgs := []string{"commit-tree", treeHash}
1139 + if baseHash != "" {
1140 + commitArgs = append(commitArgs, "-p", baseHash)
1141 + }
1142 + commitArgs = append(commitArgs, "-m", commitMsg)
1143 + commitOut, err := runGitFull(ctx, repoPath, 10*time.Second, authorEnv, nil, commitArgs...)
1105 1144 if err != nil {
1106 1145 return "", fmt.Errorf("commit-tree: %w", err)
1107 1146 }
internal/git/write_file_test.go
+61 -0
... ... @@ -93,3 +93,64 @@ func TestWriteFileCommit(t *testing.T) {
93 93 t.Fatalf("expected missing branch to be rejected")
94 94 }
95 95 }
96 +
97 +// TestWriteFilesCommit commits several files (binary content included) in a
98 +// single commit and rejects invalid batches.
99 +func TestWriteFilesCommit(t *testing.T) {
100 + ctx := context.Background()
101 + tmp := t.TempDir()
102 +
103 + repoPath := filepath.Join(tmp, "repo.git")
104 + if err := InitBare(ctx, repoPath, "main"); err != nil {
105 + t.Fatalf("InitBare: %v", err)
106 + }
107 + if _, err := WriteFileCommit(ctx, repoPath, "main", "README.md", []byte("# Repo\n"), "Initial commit", "Alice", "alice@example.com"); err != nil {
108 + t.Fatalf("seed commit: %v", err)
109 + }
110 +
111 + binary := []byte{0x00, 0x01, 0xFF, 0xFE, 'p', 'o', 'l', 'a', 'r'}
112 + hash, err := WriteFilesCommit(ctx, repoPath, "main", []FileToWrite{
113 + {Path: "assets/logo.bin", Content: binary},
114 + {Path: "docs/a.md", Content: []byte("A\n")},
115 + {Path: "docs/b.md", Content: []byte("B\n")},
116 + }, "Upload 3 files", "Bob", "bob@example.com")
117 + if err != nil {
118 + t.Fatalf("WriteFilesCommit: %v", err)
119 + }
120 + if hash == "" {
121 + t.Fatalf("expected commit hash")
122 + }
123 + if n := CountCommits(ctx, repoPath, "main"); n != 2 {
124 + t.Fatalf("expected 2 commits, got %d", n)
125 + }
126 + for path, want := range map[string][]byte{
127 + "assets/logo.bin": binary,
128 + "docs/a.md": []byte("A\n"),
129 + "docs/b.md": []byte("B\n"),
130 + } {
131 + blob, err := ReadBlob(ctx, repoPath, "main", path)
132 + if err != nil {
133 + t.Fatalf("ReadBlob %s: %v", path, err)
134 + }
135 + if string(blob.Data) != string(want) {
136 + t.Fatalf("unexpected blob %s: %q", path, string(blob.Data))
137 + }
138 + }
139 + out, err := runGit(ctx, repoPath, 10_000_000_000, "log", "-1", "--format=%s|%an", "main")
140 + if err != nil {
141 + t.Fatalf("log: %v", err)
142 + }
143 + if line := strings.TrimSpace(string(out)); line != "Upload 3 files|Bob" {
144 + t.Fatalf("unexpected commit metadata: %s", line)
145 + }
146 +
147 + if _, err := WriteFilesCommit(ctx, repoPath, "main", []FileToWrite{{Path: "a.txt", Content: []byte("1")}, {Path: "a.txt", Content: []byte("2")}}, "", "Alice", "alice@example.com"); err == nil {
148 + t.Fatalf("expected duplicate paths to be rejected")
149 + }
150 + if _, err := WriteFilesCommit(ctx, repoPath, "main", []FileToWrite{{Path: "../evil.txt", Content: []byte("x")}}, "", "Alice", "alice@example.com"); err == nil {
151 + t.Fatalf("expected invalid path to be rejected")
152 + }
153 + if _, err := WriteFilesCommit(ctx, repoPath, "main", nil, "", "Alice", "alice@example.com"); err == nil {
154 + t.Fatalf("expected empty batch to be rejected")
155 + }
156 +}
internal/i18n/i18n.go
+24 -2
... ... @@ -368,7 +368,18 @@ var translations = map[Lang]map[string]string{
368 368 "flash.path_invalid": "Некорректный путь.",
369 369 "flash.file_create_failed": "Не удалось создать файл.",
370 370 "flash.folder_create_failed": "Не удалось создать папку.",
371 - "flash.repo_settings_saved": "Настройки репозитория сохранены.",
371 + "file.upload_file": "Загрузить файлы",
372 + "file.upload_dest": "Папка назначения",
373 + "file.upload_dest_ph": "напр. docs/images (необязательно)",
374 + "file.upload_pick": "Выберите файлы или перетащите сюда",
375 + "file.upload_hint": "До 25 файлов, до 50 МБ суммарно.",
376 + "file.upload_submit": "Загрузить",
377 + "file.upload_replace_hint": "Существующие файлы будут перезаписаны.",
378 + "flash.upload_done": "Загружено файлов: %d.",
379 + "flash.upload_none": "Файлы не выбраны.",
380 + "flash.upload_failed": "Не удалось загрузить файлы.",
381 + "flash.upload_too_big": "Слишком большой объём — лимит 50 МБ.",
382 + "flash.upload_too_many": "Слишком много файлов: максимум %d.", "flash.repo_settings_saved": "Настройки репозитория сохранены.",
372 383 "flash.repo_rename_failed": "Не удалось переименовать: %s",
373 384 "flash.repo_renamed": "Репозиторий переименован в %s.",
374 385 "flash.repo_confirm_mismatch": "Введённое имя не совпадает с названием репозитория.",
... ... @@ -851,7 +862,18 @@ var translations = map[Lang]map[string]string{
851 862 "flash.path_invalid": "Invalid path.",
852 863 "flash.file_create_failed": "Could not create the file.",
853 864 "flash.folder_create_failed": "Could not create the folder.",
854 - "flash.repo_settings_saved": "Repository settings saved.",
865 + "file.upload_file": "Upload files",
866 + "file.upload_dest": "Destination folder",
867 + "file.upload_dest_ph": "e.g. docs/images (optional)",
868 + "file.upload_pick": "Choose files or drag them here",
869 + "file.upload_hint": "Up to 25 files, 50 MB in total.",
870 + "file.upload_submit": "Upload",
871 + "file.upload_replace_hint": "Existing files will be overwritten.",
872 + "flash.upload_done": "Uploaded %d file(s).",
873 + "flash.upload_none": "No files selected.",
874 + "flash.upload_failed": "Could not upload the files.",
875 + "flash.upload_too_big": "Upload is too large — 50 MB limit.",
876 + "flash.upload_too_many": "Too many files: %d max.", "flash.repo_settings_saved": "Repository settings saved.",
855 877 "flash.repo_rename_failed": "Could not rename: %s",
856 878 "flash.repo_renamed": "Repository renamed to %s.",
857 879 "flash.repo_confirm_mismatch": "The name you typed does not match the repository name.",
internal/web/handlers/handlers.go
+2 -0
... ... @@ -123,6 +123,8 @@ func (h *Handler) RegisterRoutes(app *fiber.App) {
123 123 app.Post("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoCreateFileSubmit)
124 124 app.Get("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoNewFolderPage)
125 125 app.Post("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoCreateFolderSubmit)
126 + app.Get("/:owner/:repo/upload", middleware.RequireAuth(), h.RepoUploadPage)
127 + app.Post("/:owner/:repo/upload", middleware.RequireAuth(), h.RepoUploadSubmit)
126 128
127 129 app.Post("/:owner/:repo/fork", middleware.RequireAuth(), h.RepoForkSubmit)
128 130 app.Get("/:owner/:repo/forks", h.RepoForksPage)
internal/web/handlers/repo_files.go
+137 -0
... ... @@ -2,6 +2,7 @@ package handlers
2 2
3 3 import (
4 4 "fmt"
5 + "io"
5 6 "net/url"
6 7 "strings"
7 8
... ... @@ -129,3 +130,139 @@ func (h *Handler) RepoCreateFolderSubmit(c *fiber.Ctx) error {
129 130 h.setFlashT(c, "flash.folder_created")
130 131 return c.Redirect(fmt.Sprintf("/%s/%s/tree/%s/%s", r.OwnerUsername, r.Name, ref, clean), fiber.StatusSeeOther)
131 132 }
133 +
134 +// Upload limits for a single multipart request.
135 +const (
136 + maxUploadFiles = 25
137 + maxUploadTotal = 50 << 20 // 50 MB across all files
138 +)
139 +
140 +// RepoUploadPage renders the file upload form.
141 +// GET /:owner/:repo/upload
142 +func (h *Handler) RepoUploadPage(c *fiber.Ctx) error {
143 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
144 + if err != nil {
145 + return nil
146 + }
147 +
148 + ref := strings.TrimSpace(c.Query("ref"))
149 + if ref == "" {
150 + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
151 + }
152 + subPath := strings.Trim(strings.ReplaceAll(c.Query("path"), "\\", "/"), "/")
153 +
154 + data := h.baseData(c, h.tr(c, "file.upload_file"))
155 + data["Repo"] = r
156 + data["Ref"] = ref
157 + data["SubPath"] = subPath
158 + data["ActiveTab"] = "code"
159 + data["CanWrite"] = true
160 + data["MaxFiles"] = maxUploadFiles
161 + data["Branches"], _ = git.ListBranches(c.UserContext(), diskPath)
162 + return h.render(c, fiber.StatusOK, "file_upload", data)
163 +}
164 +
165 +// RepoUploadSubmit stores uploaded files on the selected branch in a single
166 +// commit. Existing files with the same path are replaced.
167 +// POST /:owner/:repo/upload
168 +func (h *Handler) RepoUploadSubmit(c *fiber.Ctx) error {
169 + r, diskPath, err := h.loadAuthorizedRepo(c, true)
170 + if err != nil {
171 + return nil
172 + }
173 + u := middleware.CurrentUser(c)
174 +
175 + ref := strings.TrimSpace(c.FormValue("ref"))
176 + if ref == "" || !git.BranchExists(c.UserContext(), diskPath, ref) {
177 + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch)
178 + }
179 + dest := strings.Trim(strings.ReplaceAll(strings.TrimSpace(c.FormValue("path")), "\\", "/"), "/")
180 +
181 + backTo := fmt.Sprintf("/%s/%s/upload?ref=%s", r.OwnerUsername, r.Name, url.QueryEscape(ref))
182 + if dest != "" {
183 + backTo += "&path=" + url.QueryEscape(dest)
184 + }
185 +
186 + cleanDest := ""
187 + if dest != "" {
188 + cleanDest, err = git.CleanSubPath(dest)
189 + if err != nil {
190 + h.setFlashT(c, "flash.path_invalid")
191 + return c.Redirect(backTo, fiber.StatusSeeOther)
192 + }
193 + }
194 +
195 + form, ferr := c.MultipartForm()
196 + if ferr != nil {
197 + h.setFlashT(c, "flash.upload_failed")
198 + return c.Redirect(backTo, fiber.StatusSeeOther)
199 + }
200 + headers := form.File["files"]
201 + if len(headers) == 0 {
202 + h.setFlashT(c, "flash.upload_none")
203 + return c.Redirect(backTo, fiber.StatusSeeOther)
204 + }
205 + if len(headers) > maxUploadFiles {
206 + h.setFlashT(c, "flash.upload_too_many", maxUploadFiles)
207 + return c.Redirect(backTo, fiber.StatusSeeOther)
208 + }
209 +
210 + files := make([]git.FileToWrite, 0, len(headers))
211 + total := 0
212 + for _, fh := range headers {
213 + name := strings.TrimSpace(strings.ReplaceAll(fh.Filename, "\\", "/"))
214 + if idx := strings.LastIndex(name, "/"); idx >= 0 {
215 + name = name[idx+1:]
216 + }
217 + if name == "" || name == "." || name == ".." {
218 + continue
219 + }
220 + full := name
221 + if cleanDest != "" {
222 + full = cleanDest + "/" + name
223 + }
224 + clean, cerr := git.CleanSubPath(full)
225 + if cerr != nil || clean == "" {
226 + continue
227 + }
228 + f, oerr := fh.Open()
229 + if oerr != nil {
230 + continue
231 + }
232 + content, rerr := io.ReadAll(io.LimitReader(f, maxUploadTotal+1))
233 + f.Close()
234 + if rerr != nil {
235 + continue
236 + }
237 + total += len(content)
238 + if total > maxUploadTotal {
239 + h.setFlashT(c, "flash.upload_too_big")
240 + return c.Redirect(backTo, fiber.StatusSeeOther)
241 + }
242 + files = append(files, git.FileToWrite{Path: clean, Content: content})
243 + }
244 + if len(files) == 0 {
245 + h.setFlashT(c, "flash.upload_none")
246 + return c.Redirect(backTo, fiber.StatusSeeOther)
247 + }
248 +
249 + message := strings.TrimSpace(c.FormValue("message"))
250 + if message == "" {
251 + if len(files) == 1 {
252 + message = "Add " + files[0].Path
253 + } else {
254 + message = fmt.Sprintf("Add %d files", len(files))
255 + }
256 + }
257 + if _, werr := git.WriteFilesCommit(c.UserContext(), diskPath, ref, files, message, u.Username, u.Email); werr != nil {
258 + h.setFlashT(c, "flash.upload_failed")
259 + return c.Redirect(backTo, fiber.StatusSeeOther)
260 + }
261 +
262 + h.setFlashT(c, "flash.upload_done", len(files))
263 + target := fmt.Sprintf("/%s/%s/tree/%s", r.OwnerUsername, r.Name, ref)
264 + if cleanDest != "" {
265 + target += "/" + cleanDest
266 + }
267 + return c.Redirect(target, fiber.StatusSeeOther)
268 +}
internal/web/static/css/app.css
+60 -58
... ... @@ -695,6 +695,44 @@ kbd, .kbd {
695 695 height: 4.25rem;
696 696 font-size: 1.5rem;
697 697 }
698 +.avatar-xxl {
699 + width: 8.5rem;
700 + height: 8.5rem;
701 + font-size: 2.75rem;
702 + box-shadow: 0 0 0 3px hsl(var(--background)), 0 0 0 4px hsl(var(--border));
703 +}
704 +.profile-chip {
705 + display: inline-flex;
706 + align-items: center;
707 + gap: 0.35rem;
708 + padding: 0.28rem 0.65rem;
709 + border: 1px solid hsl(var(--border));
710 + border-radius: 9999px;
711 + background: hsl(var(--muted) / 0.25);
712 + font-size: 0.75rem;
713 + color: hsl(var(--muted-foreground));
714 + text-decoration: none;
715 +}
716 +.profile-chip a {
717 + color: inherit;
718 + text-decoration: none;
719 +}
720 +.profile-chip a:hover {
721 + color: hsl(var(--foreground));
722 +}
723 +.repo-card {
724 + transition: border-color 0.15s ease;
725 +}
726 +.repo-card:hover {
727 + border-color: hsl(var(--foreground) / 0.3);
728 +}
729 +@media (max-width: 640px) {
730 + .avatar-xxl {
731 + width: 6rem;
732 + height: 6rem;
733 + font-size: 2rem;
734 + }
735 +}
698 736
699 737
700 738 /* Separator */
... ... @@ -1259,56 +1297,6 @@ kbd, .kbd {
1259 1297
1260 1298
1261 1299
1262 -.repo-layout {
1263 - display: grid;
1264 - grid-template-columns: minmax(0, 1fr) 280px;
1265 - gap: 1rem;
1266 - align-items: start;
1267 -}
1268 -.repo-main {
1269 - min-width: 0;
1270 -}
1271 -.repo-side {
1272 - display: flex;
1273 - flex-direction: column;
1274 - gap: 0.9rem;
1275 - position: sticky;
1276 - top: 0.75rem;
1277 -}
1278 -.side-card {
1279 - border: 1px solid hsl(var(--border));
1280 - border-radius: calc(var(--radius) * 1.3);
1281 - background: hsl(var(--card));
1282 - overflow: hidden;
1283 -}
1284 -.side-card-header {
1285 - display: flex;
1286 - align-items: center;
1287 - gap: 0.4rem;
1288 - padding: 0.5rem 0.8rem;
1289 - font-size: 0.78rem;
1290 - font-weight: 600;
1291 - border-bottom: 1px solid hsl(var(--border));
1292 - background: hsl(var(--muted) / 0.3);
1293 -}
1294 -.side-card-body {
1295 - display: flex;
1296 - flex-direction: column;
1297 - gap: 0.55rem;
1298 - padding: 0.7rem 0.8rem;
1299 - font-size: 0.8rem;
1300 -}
1301 -.meta-row {
1302 - display: flex;
1303 - align-items: center;
1304 - justify-content: space-between;
1305 - gap: 0.5rem;
1306 - color: hsl(var(--muted-foreground));
1307 -}
1308 -.meta-row a,
1309 -.meta-row b {
1310 - color: hsl(var(--foreground));
1311 -}
1312 1300 .clone-box {
1313 1301 display: flex;
1314 1302 align-items: center;
... ... @@ -1327,12 +1315,26 @@ kbd, .kbd {
1327 1315 white-space: nowrap;
1328 1316 font-size: 0.72rem;
1329 1317 }
1330 -@media (max-width: 1000px) {
1331 - .repo-layout {
1332 - grid-template-columns: 1fr;
1333 - }
1334 - .repo-side {
1335 - position: static;
1336 - order: -1;
1337 - }
1318 +.upload-drop {
1319 + display: flex;
1320 + flex-direction: column;
1321 + align-items: center;
1322 + gap: 0.35rem;
1323 + text-align: center;
1324 + padding: 1.6rem 1rem;
1325 + border: 1.5px dashed hsl(var(--border));
1326 + border-radius: calc(var(--radius) * 1.3);
1327 + background: hsl(var(--muted) / 0.2);
1328 + color: hsl(var(--muted-foreground));
1329 + cursor: pointer;
1330 + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
1331 +}
1332 +.upload-drop:hover {
1333 + border-color: hsl(var(--foreground) / 0.4);
1334 + color: hsl(var(--foreground));
1335 +}
1336 +.upload-drop.drag {
1337 + border-color: hsl(var(--foreground) / 0.6);
1338 + background: hsl(var(--muted) / 0.5);
1339 + color: hsl(var(--foreground));
1338 1340 }
internal/web/static/icons/hugeicons-sprite.svg
+3 -1
... ... @@ -135,7 +135,9 @@
135 135 <symbol id="hi-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
136 136 <path d="m8 8-4.5 4L8 16m8-8 4.5 4L16 16m-2.5-10-3 12"/>
137 137 </symbol>
138 - <symbol id="hi-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
138 + <symbol id="hi-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
139 + <path d="M12 15.5V4m0 0L7.5 8.5M12 4l4.5 4.5M4.5 19.5h15"/>
140 + </symbol> <symbol id="hi-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
139 141 <path d="M12 4v11.5m0 0-4.5-4.5M12 15.5l4.5-4.5M4.5 19.5h15"/>
140 142 </symbol>
141 143 <symbol id="hi-history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
internal/web/views/pages/file_upload.html
+81 -0
... ... @@ -0,0 +1,81 @@
1 +{{define "content"}}
2 +<div class="container">
3 + {{template "repo_header" .}}
4 +
5 + <div class="card" style="max-width:680px;">
6 + <div class="card-header" style="padding:0.6rem 0.9rem;">
7 + <span class="font-semibold text-sm flex items-center gap-2">
8 + {{icon "upload" "size-4"}}
9 + {{t .Lang "file.upload_file"}}
10 + </span>
11 + <span class="font-mono text-xs text-muted">{{.Repo.OwnerUsername}}/{{.Repo.Name}}</span>
12 + </div>
13 + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/upload" enctype="multipart/form-data" class="card-body flex flex-col gap-3">
14 + <input type="hidden" name="_csrf" value="{{.CSRFToken}}">
15 +
16 + <div class="flex flex-col gap-1">
17 + <label class="upload-drop" id="upload-drop" for="files">
18 + {{icon "upload" "size-5"}}
19 + <span class="text-sm font-medium">{{t .Lang "file.upload_pick"}}</span>
20 + <span class="text-xs">{{t .Lang "file.upload_hint"}}</span>
21 + <input id="files" name="files" type="file" multiple style="display:none;">
22 + </label>
23 + <div id="upload-list" class="text-xs font-mono text-muted"></div>
24 + </div>
25 +
26 + <div class="flex flex-col gap-1" style="max-width:300px;">
27 + <label class="text-xs font-semibold" for="path">{{t .Lang "file.upload_dest"}}</label>
28 + <input id="path" name="path" class="input font-mono" value="{{.SubPath}}" placeholder="{{t .Lang "file.upload_dest_ph"}}">
29 + </div>
30 +
31 + <div class="flex flex-col gap-1" style="max-width:240px;">
32 + <label class="text-xs font-semibold" for="ref">{{t .Lang "file.branch"}}</label>
33 + <select id="ref" name="ref" class="input font-mono">
34 + {{range .Branches}}<option value="{{.}}" {{if eq . $.Ref}}selected{{end}}>{{.}}</option>{{end}}
35 + </select>
36 + </div>
37 +
38 + <div class="flex flex-col gap-1">
39 + <label class="text-xs font-semibold" for="message">{{t .Lang "file.message"}}</label>
40 + <input id="message" name="message" class="input font-mono" placeholder="Add files">
41 + </div>
42 +
43 + <div class="flex items-center gap-2 pt-1 flex-wrap">
44 + <button type="submit" class="btn btn-primary btn-sm">
45 + {{icon "check" "size-3.5"}}
46 + <span>{{t .Lang "file.upload_submit"}}</span>
47 + </button>
48 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}{{if .SubPath}}/{{.SubPath}}{{end}}" class="btn btn-ghost btn-sm">
49 + <span>{{t .Lang "common.cancel"}}</span>
50 + </a>
51 + <span class="text-xs text-muted">{{t .Lang "file.upload_replace_hint"}}</span>
52 + </div>
53 + </form>
54 + </div>
55 +</div>
56 +<script>
57 +(function () {
58 + var input = document.getElementById('files');
59 + var drop = document.getElementById('upload-drop');
60 + var list = document.getElementById('upload-list');
61 + function render() {
62 + var names = [];
63 + for (var i = 0; i < input.files.length; i++) names.push(input.files[i].name);
64 + list.textContent = names.length ? names.join(' · ') : '';
65 + }
66 + input.addEventListener('change', render);
67 + ['dragover', 'dragenter'].forEach(function (ev) {
68 + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('drag'); });
69 + });
70 + ['dragleave', 'drop'].forEach(function (ev) {
71 + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('drag'); });
72 + });
73 + drop.addEventListener('drop', function (e) {
74 + if (e.dataTransfer && e.dataTransfer.files.length) {
75 + input.files = e.dataTransfer.files;
76 + render();
77 + }
78 + });
79 +})();
80 +</script>
81 +{{end}}
internal/web/views/pages/profile.html
+28 -32
... ... @@ -1,39 +1,32 @@
1 1 {{define "content"}}
2 2 <div class="container">
3 - <!-- Profile header -->
4 - <div class="card p-4 mb-4">
5 - <div class="flex items-start gap-4 flex-wrap">
6 - {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xl"}}
7 - <div class="min-w-0 flex-1">
3 + <!-- Profile hero -->
4 + <section class="card mb-4" style="padding:1.5rem;">
5 + <div class="flex items-start gap-5 flex-wrap">
6 + {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xxl"}}
7 + <div class="min-w-0 flex-1" style="min-width:15rem;">
8 8 <div class="flex items-center gap-2 flex-wrap">
9 - <h1 class="text-xl font-bold" style="margin:0;line-height:1.2;">{{if .ProfileUser.FullName}}{{.ProfileUser.FullName}}{{else}}{{.ProfileUser.Username}}{{end}}</h1>
9 + <h1 class="text-2xl font-bold" style="margin:0;line-height:1.15;">
10 + {{if .ProfileUser.FullName}}{{.ProfileUser.FullName}}{{else}}{{.ProfileUser.Username}}{{end}}
11 + </h1>
10 12 {{if .ProfileUser.IsAdmin}}
11 13 <span class="badge badge-primary">{{t .Lang "admin.role.admin"}}</span>
12 14 {{end}}
13 15 </div>
14 - <div class="text-sm text-muted font-mono">@{{.ProfileUser.Username}}</div>
16 + <div class="text-sm text-muted font-mono" style="margin-top:0.2rem;">@{{.ProfileUser.Username}}</div>
15 17
16 18 {{if .ProfileUser.Bio}}
17 - <p class="text-sm" style="margin:0.45rem 0 0;max-width:38rem;">{{.ProfileUser.Bio}}</p>
19 + <p class="text-sm" style="margin:0.65rem 0 0;max-width:44rem;">{{.ProfileUser.Bio}}</p>
18 20 {{end}}
19 21
20 - <div class="flex items-center flex-wrap gap-3 text-xs text-muted mt-2">
22 + <div class="flex items-center flex-wrap gap-2" style="margin-top:0.85rem;">
21 23 {{if .ProfileUser.Location}}
22 - <span class="flex items-center gap-1">
23 - {{icon "map-pin" "size-3.5"}}
24 - <span>{{.ProfileUser.Location}}</span>
25 - </span>
24 + <span class="profile-chip">{{icon "map-pin" "size-3.5"}}<span>{{.ProfileUser.Location}}</span></span>
26 25 {{end}}
27 26 {{if .ProfileUser.Website}}
28 - <a href="{{.ProfileUser.Website}}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1" style="color:hsl(var(--foreground));">
29 - {{icon "link" "size-3.5"}}
30 - <span>{{.ProfileUser.Website}}</span>
31 - </a>
27 + <span class="profile-chip">{{icon "link" "size-3.5"}}<a href="{{.ProfileUser.Website}}" target="_blank" rel="noopener noreferrer">{{.ProfileUser.Website}}</a></span>
32 28 {{end}}
33 - <span class="flex items-center gap-1">
34 - {{icon "calendar" "size-3.5"}}
35 - <span>{{t .Lang "profile.joined"}}: {{formatShortDate .ProfileUser.CreatedAt}}</span>
36 - </span>
29 + <span class="profile-chip">{{icon "calendar" "size-3.5"}}<span>{{t .Lang "profile.joined"}} · {{formatShortDate .ProfileUser.CreatedAt}}</span></span>
37 30 </div>
38 31 </div>
39 32
... ... @@ -48,13 +41,13 @@
48 41 {{end}}
49 42 </div>
50 43 </div>
51 - </div>
44 + </section>
52 45
53 46 <!-- Repositories -->
54 47 <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
55 48 <h2 class="text-base font-semibold" style="margin:0;">
56 49 {{t .Lang "nav.repositories"}}
57 - <span class="text-muted font-mono text-sm" style="font-weight:400;">{{len .Repos}}</span>
50 + <span class="badge badge-outline font-mono" style="margin-left:0.35rem;">{{len .Repos}}</span>
58 51 </h2>
59 52 <div class="flex items-center gap-2">
60 53 <div class="flex items-center" style="position:relative;">
... ... @@ -75,26 +68,29 @@
75 68 {{if .Repos}}
76 69 <div class="grid-2">
77 70 {{range .Repos}}
78 - <div class="card p-3 flex flex-col gap-2" data-tree-row data-tree-name="{{.Name}} {{.Description}}">
71 + <a href="/{{.OwnerUsername}}/{{.Name}}" class="card p-3 flex flex-col gap-2 repo-card" data-tree-row data-tree-name="{{.Name}} {{.Description}}" style="text-decoration:none;color:inherit;">
79 72 <div class="flex items-center justify-between gap-2">
80 - <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-semibold text-sm flex items-center gap-1.5 truncate">
73 + <span class="font-semibold text-sm flex items-center gap-2 min-w-0">
81 74 {{if .IsPrivate}}
82 75 {{icon "lock" "size-3.5"}}
83 76 {{else}}
84 - {{icon "git-branch" "size-3.5"}}
77 + {{icon "book-open" "size-3.5"}}
85 78 {{end}}
86 79 <span class="truncate">{{.Name}}</span>
87 - </a>
88 - <span class="text-xs text-muted flex-shrink-0">{{timeAgo .UpdatedAt $.Lang}}</span>
80 + </span>
81 + <span class="badge badge-outline flex-shrink-0">{{if .IsPrivate}}{{t $.Lang "repo.private"}}{{else}}{{t $.Lang "repo.public"}}{{end}}</span>
89 82 </div>
90 83 {{if .Description}}
91 84 <p class="text-xs text-muted" style="margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{{.Description}}</p>
92 85 {{end}}
93 - <div class="text-xs text-muted font-mono flex items-center gap-1.5">
94 - {{icon "git-branch" "size-3.5"}}
95 - <span>{{.DefaultBranch}}</span>
86 + <div class="flex items-center justify-between text-xs text-muted font-mono">
87 + <span class="flex items-center gap-1.5">
88 + {{icon "git-branch" "size-3.5"}}
89 + <span>{{.DefaultBranch}}</span>
90 + </span>
91 + <span>{{timeAgo .UpdatedAt $.Lang}}</span>
96 92 </div>
97 - </div>
93 + </a>
98 94 {{end}}
99 95 </div>
100 96 {{else}}
internal/web/views/pages/repo.html
+141 -146
... ... @@ -36,170 +36,165 @@
36 36 </div>
37 37 </div>
38 38 {{else}}
39 - <div class="repo-layout">
40 - <div class="repo-main">
41 - <!-- Controls Bar: Branch/Tag Switcher + Breadcrumb + File Filter -->
42 - <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
43 - <div class="flex items-center gap-2 flex-wrap">
44 - <div class="dropdown" data-dropdown>
45 - <button type="button" class="btn btn-outline btn-sm font-mono" data-dropdown-trigger aria-haspopup="true" aria-expanded="false">
46 - {{icon "git-branch" "size-3.5"}}
47 - <span>{{.Ref}}</span>
48 - {{icon "chevron-down" "size-3.5"}}
49 - </button>
50 - <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:13rem;">
51 - <div class="palette-group-title">{{t .Lang "repo.branches"}} ({{len .Branches}})</div>
52 - {{range .Branches}}
53 - <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.}}{{if $.SubPath}}/{{$.SubPath}}{{end}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
54 - <span class="flex items-center gap-2">{{icon "git-branch" "size-3.5"}} {{.}}</span>
55 - {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
56 - </a>
57 - {{end}}
58 - {{if .Tags}}
59 - <hr class="separator" style="margin:0.25rem 0;">
60 - <div class="palette-group-title">{{t .Lang "repo.tags"}} ({{len .Tags}})</div>
61 - {{range .Tags}}
62 - <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.}}{{if $.SubPath}}/{{$.SubPath}}{{end}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
63 - <span class="flex items-center gap-2">{{icon "tag" "size-3.5"}} {{.}}</span>
64 - {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
65 - </a>
66 - {{end}}
67 - {{end}}
68 - </div>
69 - </div>
70 -
71 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "repo.tab.branches"}}">
72 - {{icon "git-branch" "size-3.5"}}
73 - <span class="text-xs">{{len .Branches}}</span>
74 - </a>
75 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commits/{{.Ref}}" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "repo.tab.commits"}}">
76 - {{icon "history" "size-3.5"}}
77 - <span class="text-xs">{{t .Lang "repo.tab.commits"}}</span>
78 - </a>
79 -
80 - {{if .Breadcrumbs}}
81 - <nav class="breadcrumb font-mono text-xs" aria-label="Directory path">
82 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}" class="text-muted">{{.Repo.Name}}</a>
83 - {{range .Breadcrumbs}}
84 - <span class="breadcrumb-sep">/</span>
85 - {{if .IsLast}}
86 - <span class="font-semibold">{{.Name}}</span>
87 - {{else}}
88 - <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="text-muted">{{.Name}}</a>
89 - {{end}}
90 - {{end}}
91 - </nav>
92 - {{end}}
93 - </div>
94 -
95 - <div class="flex items-center gap-2 flex-wrap">
96 - <div class="flex items-center" style="position:relative;">
97 - <input type="text" class="input" style="height:1.75rem;width:13rem;padding-right:1.75rem;font-size:0.78rem;" placeholder="{{t .Lang "repo.find_file"}}" data-file-filter>
98 - <kbd class="kbd" style="position:absolute;right:0.35rem;pointer-events:none;">t</kbd>
99 - </div>
100 - {{if .CanWrite}}
101 - <a class="btn btn-outline btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-file?ref={{.Ref}}{{if .SubPath}}&path={{.SubPath}}{{end}}" title="{{t .Lang "file.new_file"}}">
102 - {{icon "plus" "size-3.5"}}
103 - <span class="hidden md-flex">{{t .Lang "file.new_file"}}</span>
104 - </a>
105 - <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-folder?ref={{.Ref}}{{if .SubPath}}&path={{.SubPath}}{{end}}" title="{{t .Lang "file.new_folder"}}">
106 - {{icon "folder" "size-3.5"}}
107 - <span class="hidden md-flex">{{t .Lang "file.new_folder"}}</span>
39 + <!-- Controls Bar: Branch/Tag Switcher + Breadcrumb + File Filter -->
40 + <div class="flex items-center justify-between flex-wrap gap-2 mb-3">
41 + <div class="flex items-center gap-2 flex-wrap">
42 + <div class="dropdown" data-dropdown>
43 + <button type="button" class="btn btn-outline btn-sm font-mono" data-dropdown-trigger aria-haspopup="true" aria-expanded="false">
44 + {{icon "git-branch" "size-3.5"}}
45 + <span>{{.Ref}}</span>
46 + {{icon "chevron-down" "size-3.5"}}
47 + </button>
48 + <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:13rem;">
49 + <div class="palette-group-title">{{t .Lang "repo.branches"}} ({{len .Branches}})</div>
50 + {{range .Branches}}
51 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.}}{{if $.SubPath}}/{{$.SubPath}}{{end}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
52 + <span class="flex items-center gap-2">{{icon "git-branch" "size-3.5"}} {{.}}</span>
53 + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
108 54 </a>
109 55 {{end}}
56 + {{if .Tags}}
57 + <hr class="separator" style="margin:0.25rem 0;">
58 + <div class="palette-group-title">{{t .Lang "repo.tags"}} ({{len .Tags}})</div>
59 + {{range .Tags}}
60 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{.}}{{if $.SubPath}}/{{$.SubPath}}{{end}}" class="dropdown-item font-mono {{if eq . $.Ref}}active{{end}}" role="menuitem">
61 + <span class="flex items-center gap-2">{{icon "tag" "size-3.5"}} {{.}}</span>
62 + {{if eq . $.Ref}}{{icon "check" "size-3.5"}}{{end}}
63 + </a>
64 + {{end}}
65 + {{end}}
110 66 </div>
111 67 </div>
112 68
113 - <!-- Unified Repository Card: Latest Commit Header + File List -->
114 - <div class="card mb-4">
115 - {{if .LatestCommit}}
116 - <div class="card-header text-xs" style="padding:0.5rem 0.8rem;background:hsl(var(--muted)/0.3);">
117 - <div class="flex items-center gap-2 min-w-0">
118 - <span class="avatar" style="width:1.25rem;height:1.25rem;font-size:0.62rem;">{{avatarInitial .LatestCommit.AuthorName}}</span>
119 - <span class="font-medium">{{.LatestCommit.AuthorName}}</span>
120 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commit/{{.LatestCommit.Hash}}" class="text-muted truncate">{{.LatestCommit.Subject}}</a>
121 - </div>
122 - <div class="flex items-center gap-2 text-muted font-mono">
123 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commit/{{.LatestCommit.Hash}}" class="badge badge-outline font-mono">{{.LatestCommit.ShortHash}}</a>
124 - <span>{{timeAgo .LatestCommit.AuthoredAt .Lang}}</span>
125 - </div>
126 - </div>
127 - {{end}}
69 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "repo.tab.branches"}}">
70 + {{icon "git-branch" "size-3.5"}}
71 + <span class="text-xs">{{len .Branches}}</span>
72 + </a>
73 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commits/{{.Ref}}" class="btn btn-ghost btn-sm font-mono" title="{{t .Lang "repo.tab.commits"}}">
74 + {{icon "history" "size-3.5"}}
75 + <span class="text-xs">{{t .Lang "repo.tab.commits"}}</span>
76 + </a>
128 77
129 - <div class="dense-list">
130 - {{if .SubPath}}
131 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}{{if ne .ParentPath "."}}/{{.ParentPath}}{{end}}" class="dense-row font-mono text-xs text-muted">
132 - <span class="flex items-center gap-2">{{icon "arrow-left" "size-3.5"}} ..</span>
133 - </a>
134 - {{end}}
135 - {{range .Entries}}
136 - <div class="dense-row" data-tree-row data-tree-name="{{.Name}}">
137 - <div class="flex items-center gap-2 min-w-0" style="flex:1 1 40%;">
138 - {{if eq .Type "tree"}}
139 - {{icon "folder" "size-4"}}
140 - <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="font-medium truncate">{{.Name}}</a>
141 - {{else}}
142 - {{icon "file" "size-4"}}
143 - <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}" class="truncate">{{.Name}}</a>
144 - {{end}}
145 - </div>
146 - <div class="hidden md-flex text-xs text-muted truncate" style="flex:1 1 45%;">
147 - {{if .CommitHash}}
148 - <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.CommitHash}}" class="truncate text-muted">{{.CommitMsg}}</a>
149 - {{end}}
150 - </div>
151 - <div class="text-xs text-muted font-mono" style="text-align:right;min-width:5rem;">
152 - {{if not .CommitTime.IsZero}}
153 - {{timeAgo .CommitTime $.Lang}}
154 - {{else if eq .Type "blob"}}
155 - {{formatSize .Size}}
156 - {{end}}
157 - </div>
158 - </div>
78 + {{if .Breadcrumbs}}
79 + <nav class="breadcrumb font-mono text-xs" aria-label="Directory path">
80 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}" class="text-muted">{{.Repo.Name}}</a>
81 + {{range .Breadcrumbs}}
82 + <span class="breadcrumb-sep">/</span>
83 + {{if .IsLast}}
84 + <span class="font-semibold">{{.Name}}</span>
85 + {{else}}
86 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="text-muted">{{.Name}}</a>
87 + {{end}}
159 88 {{end}}
160 - </div>
161 - </div>
162 -
163 - {{if .ReadmeHTML}}
164 - <section class="card">
165 - <div class="card-header" style="padding:0.55rem 0.85rem;">
166 - <span class="font-semibold text-xs flex items-center gap-2 font-mono">
167 - {{icon "book-open" "size-3.5"}}
168 - <span>{{.ReadmeName}}</span>
169 - </span>
170 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.ReadmeName}}" class="text-xs text-muted">{{t .Lang "repo.view_file"}}</a>
171 - </div>
172 - <div class="card-body markdown-body">
173 - {{.ReadmeHTML}}
174 - </div>
175 - </section>
89 + </nav>
176 90 {{end}}
177 91 </div>
178 92
179 - <aside class="repo-side">
180 - <div class="side-card">
181 - <div class="side-card-header">{{icon "terminal" "size-3.5"}} {{t .Lang "repo.clone_title"}}</div>
182 - <div class="side-card-body">
93 + <div class="flex items-center gap-2 flex-wrap">
94 + <div class="flex items-center" style="position:relative;">
95 + <input type="text" class="input" style="height:1.75rem;width:13rem;padding-right:1.75rem;font-size:0.78rem;" placeholder="{{t .Lang "repo.find_file"}}" data-file-filter>
96 + <kbd class="kbd" style="position:absolute;right:0.35rem;pointer-events:none;">t</kbd>
97 + </div>
98 +
99 + <div class="dropdown" data-dropdown>
100 + <button type="button" class="btn btn-outline btn-sm" data-dropdown-trigger aria-haspopup="true" aria-expanded="false" title="{{t .Lang "repo.clone_title"}}">
101 + {{icon "terminal" "size-3.5"}}
102 + <span class="hidden md-flex">{{t .Lang "repo.clone_title"}}</span>
103 + </button>
104 + <div class="dropdown-menu" data-dropdown-menu role="menu" style="min-width:22rem;padding:0.65rem;">
183 105 <div class="clone-box">
184 106 <code>{{.CloneURL}}</code>
185 - <button type="button" class="btn btn-ghost btn-icon" style="width:1.6rem;height:1.6rem;" data-copy="{{.CloneURL}}" title="{{t .Lang "common.copy"}}">
107 + <button type="button" class="btn btn-ghost btn-icon" style="width:1.6rem;height:1.6rem;flex:none;" data-copy="{{.CloneURL}}" title="{{t .Lang "common.copy"}}">
186 108 {{icon "copy" "size-3.5"}}
187 109 </button>
188 110 </div>
189 - <div class="flex items-center justify-between">
190 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/archive/{{.Ref}}.zip" class="text-xs text-muted flex items-center gap-1.5">
191 - {{icon "download" "size-3.5"}}
192 - <span>{{t .Lang "repo.download_zip"}}</span>
193 - </a>
194 - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commits/{{.Ref}}" class="text-xs text-muted flex items-center gap-1.5">
195 - {{icon "history" "size-3.5"}}
196 - <span>{{t .Lang "repo.tab.commits"}}</span>
197 - </a>
198 - </div>
111 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/archive/{{.Ref}}.zip" class="dropdown-item" role="menuitem" style="margin-top:0.35rem;">
112 + <span class="flex items-center gap-2">{{icon "download" "size-3.5"}} <span>{{t .Lang "repo.download_zip"}}</span></span>
113 + </a>
199 114 </div>
200 115 </div>
201 - </aside>
116 +
117 + {{if .CanWrite}}
118 + <a class="btn btn-outline btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-file?ref={{.Ref}}{{if .SubPath}}&path={{.SubPath}}{{end}}" title="{{t .Lang "file.new_file"}}">
119 + {{icon "plus" "size-3.5"}}
120 + <span class="hidden md-flex">{{t .Lang "file.new_file"}}</span>
121 + </a>
122 + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-folder?ref={{.Ref}}{{if .SubPath}}&path={{.SubPath}}{{end}}" title="{{t .Lang "file.new_folder"}}">
123 + {{icon "folder" "size-3.5"}}
124 + <span class="hidden md-flex">{{t .Lang "file.new_folder"}}</span>
125 + </a>
126 + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/upload?ref={{.Ref}}{{if .SubPath}}&path={{.SubPath}}{{end}}" title="{{t .Lang "file.upload_file"}}">
127 + {{icon "upload" "size-3.5"}}
128 + <span class="hidden md-flex">{{t .Lang "file.upload_file"}}</span>
129 + </a>
130 + {{end}}
131 + </div>
202 132 </div>
133 +
134 + <!-- Unified Repository Card: Latest Commit Header + File List -->
135 + <div class="card mb-4">
136 + {{if .LatestCommit}}
137 + <div class="card-header text-xs" style="padding:0.5rem 0.8rem;background:hsl(var(--muted)/0.3);">
138 + <div class="flex items-center gap-2 min-w-0">
139 + <span class="avatar" style="width:1.25rem;height:1.25rem;font-size:0.62rem;">{{avatarInitial .LatestCommit.AuthorName}}</span>
140 + <span class="font-medium">{{.LatestCommit.AuthorName}}</span>
141 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commit/{{.LatestCommit.Hash}}" class="text-muted truncate">{{.LatestCommit.Subject}}</a>
142 + </div>
143 + <div class="flex items-center gap-2 text-muted font-mono">
144 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/commit/{{.LatestCommit.Hash}}" class="badge badge-outline font-mono">{{.LatestCommit.ShortHash}}</a>
145 + <span>{{timeAgo .LatestCommit.AuthoredAt .Lang}}</span>
146 + </div>
147 + </div>
148 + {{end}}
149 +
150 + <div class="dense-list">
151 + {{if .SubPath}}
152 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}{{if ne .ParentPath "."}}/{{.ParentPath}}{{end}}" class="dense-row font-mono text-xs text-muted">
153 + <span class="flex items-center gap-2">{{icon "arrow-left" "size-3.5"}} ..</span>
154 + </a>
155 + {{end}}
156 + {{range .Entries}}
157 + <div class="dense-row" data-tree-row data-tree-name="{{.Name}}">
158 + <div class="flex items-center gap-2 min-w-0" style="flex:1 1 40%;">
159 + {{if eq .Type "tree"}}
160 + {{icon "folder" "size-4"}}
161 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}" class="font-medium truncate">{{.Name}}</a>
162 + {{else}}
163 + {{icon "file" "size-4"}}
164 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}" class="truncate">{{.Name}}</a>
165 + {{end}}
166 + </div>
167 + <div class="hidden md-flex text-xs text-muted truncate" style="flex:1 1 45%;">
168 + {{if .CommitHash}}
169 + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/commit/{{.CommitHash}}" class="truncate text-muted">{{.CommitMsg}}</a>
170 + {{end}}
171 + </div>
172 + <div class="text-xs text-muted font-mono" style="text-align:right;min-width:5rem;">
173 + {{if not .CommitTime.IsZero}}
174 + {{timeAgo .CommitTime $.Lang}}
175 + {{else if eq .Type "blob"}}
176 + {{formatSize .Size}}
177 + {{end}}
178 + </div>
179 + </div>
180 + {{end}}
181 + </div>
182 + </div>
183 +
184 + {{if .ReadmeHTML}}
185 + <section class="card">
186 + <div class="card-header" style="padding:0.55rem 0.85rem;">
187 + <span class="font-semibold text-xs flex items-center gap-2 font-mono">
188 + {{icon "book-open" "size-3.5"}}
189 + <span>{{.ReadmeName}}</span>
190 + </span>
191 + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.ReadmeName}}" class="text-xs text-muted">{{t .Lang "repo.view_file"}}</a>
192 + </div>
193 + <div class="card-body markdown-body">
194 + {{.ReadmeHTML}}
195 + </div>
196 + </section>
197 + {{end}}
203 198 {{end}}
204 199 </div>
205 200 {{end}}