feat(profile): hero layout with large avatar and polished repo cards
c5ae13e
Файлы на момент коммита
- 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 файлов изменено
+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}} |