feat: in-browser file/folder creation, floating sidebar, minimal repo/profile UI
e277fd0
Browse files
- add New file / New folder pages committing directly to a branch (git plumbing, no checkout) - percent-encode flash cookie values so localized messages survive redirects - floating sidebar detached from viewport edges; compact clone card on repo page - drop repo stat chips and About card; hide fork/PR actions for anonymous visitors - redesign profile page; light/dark palettes fixed to #fff/#111 inverses
R
rin
·
2026-10-16 16:05 UTC
12 files changed
+525
-244
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. | |
| 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. | |
| 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
+19
-48
| ... | ... | @@ -188,34 +188,37 @@ a { |
| 188 | 188 | } |
| 189 | 189 | |
| 190 | 190 | .app-sidebar { |
| 191 | - width: 236px; | |
| 192 | - min-width: 236px; | |
| 193 | - max-width: 236px; | |
| 194 | - flex: 0 0 236px; | |
| 195 | - border-right: 1px solid hsl(var(--border)); | |
| 191 | + width: 232px; | |
| 192 | + min-width: 232px; | |
| 193 | + max-width: 232px; | |
| 194 | + flex: 0 0 232px; | |
| 195 | + border: 1px solid hsl(var(--border)); | |
| 196 | + border-radius: calc(var(--radius) * 1.5); | |
| 196 | 197 | background-color: hsl(var(--card)); |
| 197 | 198 | display: none; |
| 198 | 199 | flex-direction: column; |
| 199 | 200 | justify-content: space-between; |
| 200 | 201 | position: sticky; |
| 201 | - top: 0; | |
| 202 | - height: 100vh; | |
| 202 | + top: 0.75rem; | |
| 203 | + height: calc(100vh - 1.5rem); | |
| 204 | + margin: 0.75rem 0 0.75rem 0.75rem; | |
| 203 | 205 | overflow-y: auto; |
| 204 | 206 | overflow-x: hidden; |
| 205 | - padding: 0.85rem 0.75rem; | |
| 207 | + padding: 0.85rem 0.7rem; | |
| 206 | 208 | user-select: none; |
| 207 | 209 | transition: |
| 208 | 210 | width 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 209 | 211 | min-width 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 210 | 212 | max-width 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 211 | 213 | flex-basis 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 214 | + margin 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 212 | 215 | padding 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 213 | 216 | border-color 200ms cubic-bezier(0.16, 1, 0.3, 1); |
| 214 | 217 | } |
| 215 | 218 | |
| 216 | 219 | .sidebar-inner { |
| 217 | - width: 218px; | |
| 218 | - min-width: 218px; | |
| 220 | + width: 100%; | |
| 221 | + min-width: 0; | |
| 219 | 222 | height: 100%; |
| 220 | 223 | display: flex; |
| 221 | 224 | flex-direction: column; |
| ... | ... | @@ -230,9 +233,10 @@ html[data-sidebar="collapsed"] .app-sidebar { |
| 230 | 233 | min-width: 0; |
| 231 | 234 | max-width: 0; |
| 232 | 235 | flex-basis: 0; |
| 236 | + margin: 0.75rem 0; | |
| 233 | 237 | padding-left: 0; |
| 234 | 238 | padding-right: 0; |
| 235 | - border-right-color: transparent; | |
| 239 | + border-color: transparent; | |
| 236 | 240 | } |
| 237 | 241 | |
| 238 | 242 | html[data-sidebar="collapsed"] .sidebar-inner { |
| ... | ... | @@ -1225,13 +1229,7 @@ kbd, .kbd { |
| 1225 | 1229 | border-radius: calc(var(--radius) * 1.6); |
| 1226 | 1230 | overflow: hidden; |
| 1227 | 1231 | } |
| 1228 | -.repo-banner::before { | |
| 1229 | - content: ""; | |
| 1230 | - position: absolute; | |
| 1231 | - inset: 0 0 auto 0; | |
| 1232 | - height: 2px; | |
| 1233 | - background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0) 65%); | |
| 1234 | -} | |
| 1232 | + | |
| 1235 | 1233 | .repo-banner-main { |
| 1236 | 1234 | display: flex; |
| 1237 | 1235 | align-items: center; |
| ... | ... | @@ -1246,39 +1244,12 @@ kbd, .kbd { |
| 1246 | 1244 | flex-wrap: wrap; |
| 1247 | 1245 | } |
| 1248 | 1246 | |
| 1249 | -.repo-stats { | |
| 1250 | - display: flex; | |
| 1251 | - align-items: center; | |
| 1252 | - gap: 0.4rem; | |
| 1253 | - flex-wrap: wrap; | |
| 1254 | -} | |
| 1255 | -.stat-chip { | |
| 1256 | - display: inline-flex; | |
| 1257 | - align-items: center; | |
| 1258 | - gap: 0.4rem; | |
| 1259 | - height: 1.85rem; | |
| 1260 | - padding: 0 0.7rem; | |
| 1261 | - border: 1px solid hsl(var(--border)); | |
| 1262 | - border-radius: 999px; | |
| 1263 | - background: hsl(var(--card)); | |
| 1264 | - font-size: 0.78rem; | |
| 1265 | - color: hsl(var(--muted-foreground)); | |
| 1266 | - white-space: nowrap; | |
| 1267 | - transition: border-color 120ms ease, color 120ms ease; | |
| 1268 | -} | |
| 1269 | -.stat-chip b { | |
| 1270 | - color: hsl(var(--foreground)); | |
| 1271 | - font-weight: 600; | |
| 1272 | -} | |
| 1273 | -.stat-chip:hover { | |
| 1274 | - color: hsl(var(--foreground)); | |
| 1275 | - border-color: hsl(var(--primary) / 0.55); | |
| 1276 | -} | |
| 1247 | + | |
| 1277 | 1248 | |
| 1278 | 1249 | .repo-layout { |
| 1279 | 1250 | display: grid; |
| 1280 | - grid-template-columns: minmax(0, 1fr) 305px; | |
| 1281 | - gap: 1.1rem; | |
| 1251 | + grid-template-columns: minmax(0, 1fr) 280px; | |
| 1252 | + gap: 1rem; | |
| 1282 | 1253 | align-items: start; |
| 1283 | 1254 | } |
| 1284 | 1255 | .repo-main { |
internal/git/git.go
+74
-0
| ... | ... | @@ -117,6 +117,10 @@ func CleanSubPath(p string) (string, error) { |
| 117 | 117 | } |
| 118 | 118 | |
| 119 | 119 | func runGit(ctx context.Context, repoPath string, timeout time.Duration, args ...string) ([]byte, error) { |
| 120 | + return runGitFull(ctx, repoPath, timeout, nil, nil, args...) | |
| 121 | +} | |
| 122 | + | |
| 123 | +func runGitFull(ctx context.Context, repoPath string, timeout time.Duration, extraEnv []string, input []byte, args ...string) ([]byte, error) { | |
| 120 | 124 | cctx, cancel := context.WithTimeout(ctx, timeout) |
| 121 | 125 | defer cancel() |
| 122 | 126 | |
| ... | ... | @@ -131,6 +135,10 @@ func runGit(ctx context.Context, repoPath string, timeout time.Duration, args .. |
| 131 | 135 | "GIT_TERMINAL_PROMPT=0", |
| 132 | 136 | "LC_ALL=C", |
| 133 | 137 | ) |
| 138 | + cmd.Env = append(cmd.Env, extraEnv...) | |
| 139 | + if input != nil { | |
| 140 | + cmd.Stdin = bytes.NewReader(input) | |
| 141 | + } | |
| 134 | 142 | var stdout, stderr bytes.Buffer |
| 135 | 143 | cmd.Stdout = &stdout |
| 136 | 144 | cmd.Stderr = &stderr |
| ... | ... | @@ -1038,3 +1046,69 @@ func mergeRefs(ctx context.Context, repoPath, baseRef, headRef, commitMsg, autho |
| 1038 | 1046 | } |
| 1039 | 1047 | return mergeHash, nil |
| 1040 | 1048 | } |
| 1049 | + | |
| 1050 | +// WriteFileCommit creates or replaces filePath on branch with content and | |
| 1051 | +// commits the change. It works on a bare repository directly through git | |
| 1052 | +// plumbing and a temporary index file, so no working tree is required. | |
| 1053 | +func WriteFileCommit(ctx context.Context, repoPath, branch, filePath string, content []byte, commitMsg, authorName, authorEmail string) (string, error) { | |
| 1054 | + if err := ValidateRef(branch); err != nil { | |
| 1055 | + return "", err | |
| 1056 | + } | |
| 1057 | + clean, err := CleanSubPath(filePath) | |
| 1058 | + if err != nil || clean == "" { | |
| 1059 | + return "", ErrInvalidPath | |
| 1060 | + } | |
| 1061 | + for _, segment := range strings.Split(clean, "/") { | |
| 1062 | + if segment == ".git" { | |
| 1063 | + return "", ErrInvalidPath | |
| 1064 | + } | |
| 1065 | + } | |
| 1066 | + | |
| 1067 | + baseOut, err := runGit(ctx, repoPath, 5*time.Second, "rev-parse", "refs/heads/"+branch) | |
| 1068 | + 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) | |
| 1076 | + } | |
| 1077 | + blobHash := strings.TrimSpace(string(blobOut)) | |
| 1078 | + | |
| 1079 | + indexPath := filepath.Join(os.TempDir(), fmt.Sprintf("polar-index-%d-%d", time.Now().UnixNano(), os.Getpid())) | |
| 1080 | + defer os.Remove(indexPath) | |
| 1081 | + indexEnv := []string{"GIT_INDEX_FILE=" + indexPath} | |
| 1082 | + | |
| 1083 | + if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "read-tree", baseHash); err != nil { | |
| 1084 | + return "", fmt.Errorf("read-tree: %w", err) | |
| 1085 | + } | |
| 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) | |
| 1088 | + } | |
| 1089 | + treeOut, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "write-tree") | |
| 1090 | + if err != nil { | |
| 1091 | + return "", fmt.Errorf("write-tree: %w", err) | |
| 1092 | + } | |
| 1093 | + treeHash := strings.TrimSpace(string(treeOut)) | |
| 1094 | + | |
| 1095 | + if commitMsg == "" { | |
| 1096 | + commitMsg = "Add " + clean | |
| 1097 | + } | |
| 1098 | + authorEnv := []string{ | |
| 1099 | + "GIT_AUTHOR_NAME=" + authorName, | |
| 1100 | + "GIT_AUTHOR_EMAIL=" + authorEmail, | |
| 1101 | + "GIT_COMMITTER_NAME=" + authorName, | |
| 1102 | + "GIT_COMMITTER_EMAIL=" + authorEmail, | |
| 1103 | + } | |
| 1104 | + commitOut, err := runGitFull(ctx, repoPath, 10*time.Second, authorEnv, nil, "commit-tree", treeHash, "-p", baseHash, "-m", commitMsg) | |
| 1105 | + if err != nil { | |
| 1106 | + return "", fmt.Errorf("commit-tree: %w", err) | |
| 1107 | + } | |
| 1108 | + commitHash := strings.TrimSpace(string(commitOut)) | |
| 1109 | + | |
| 1110 | + if _, err := runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+branch, commitHash); err != nil { | |
| 1111 | + return "", fmt.Errorf("update-ref: %w", err) | |
| 1112 | + } | |
| 1113 | + return commitHash, nil | |
| 1114 | +} |
internal/git/write_file_test.go
+95
-0
| ... | ... | @@ -0,0 +1,95 @@ |
| 1 | +package git | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "os/exec" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// TestWriteFileCommit creates files (including nested paths) directly on a | |
| 13 | +// bare repository through git plumbing, without a working tree. | |
| 14 | +func TestWriteFileCommit(t *testing.T) { | |
| 15 | + ctx := context.Background() | |
| 16 | + tmp := t.TempDir() | |
| 17 | + | |
| 18 | + repoPath := filepath.Join(tmp, "repo.git") | |
| 19 | + if err := InitBare(ctx, repoPath, "main"); err != nil { | |
| 20 | + t.Fatalf("InitBare: %v", err) | |
| 21 | + } | |
| 22 | + | |
| 23 | + work := filepath.Join(tmp, "work") | |
| 24 | + if err := os.MkdirAll(work, 0o755); err != nil { | |
| 25 | + t.Fatalf("mkdir: %v", err) | |
| 26 | + } | |
| 27 | + run := func(dir string, args ...string) { | |
| 28 | + t.Helper() | |
| 29 | + cmd := exec.CommandContext(ctx, "git", args...) | |
| 30 | + cmd.Dir = dir | |
| 31 | + if out, err := cmd.CombinedOutput(); err != nil { | |
| 32 | + t.Fatalf("git %v: %v (%s)", args, err, string(out)) | |
| 33 | + } | |
| 34 | + } | |
| 35 | + run(work, "init", "-b", "main") | |
| 36 | + run(work, "config", "user.name", "Alice") | |
| 37 | + run(work, "config", "user.email", "alice@example.com") | |
| 38 | + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Repo\n"), 0o644); err != nil { | |
| 39 | + t.Fatalf("write: %v", err) | |
| 40 | + } | |
| 41 | + run(work, "add", ".") | |
| 42 | + run(work, "commit", "-m", "initial") | |
| 43 | + run(work, "push", repoPath, "main") | |
| 44 | + | |
| 45 | + hash, err := WriteFileCommit(ctx, repoPath, "main", "docs/guide.md", []byte("hello\n"), "Add docs/guide.md", "Alice", "alice@example.com") | |
| 46 | + if err != nil { | |
| 47 | + t.Fatalf("WriteFileCommit: %v", err) | |
| 48 | + } | |
| 49 | + if hash == "" { | |
| 50 | + t.Fatalf("expected commit hash") | |
| 51 | + } | |
| 52 | + | |
| 53 | + blob, err := ReadBlob(ctx, repoPath, "main", "docs/guide.md") | |
| 54 | + if err != nil { | |
| 55 | + t.Fatalf("ReadBlob: %v", err) | |
| 56 | + } | |
| 57 | + if string(blob.Data) != "hello\n" { | |
| 58 | + t.Fatalf("unexpected blob content: %q", string(blob.Data)) | |
| 59 | + } | |
| 60 | + | |
| 61 | + out, err := runGit(ctx, repoPath, 10_000_000_000, "log", "-1", "--format=%s|%an|%ae", "main") | |
| 62 | + if err != nil { | |
| 63 | + t.Fatalf("log: %v", err) | |
| 64 | + } | |
| 65 | + line := strings.TrimSpace(string(out)) | |
| 66 | + if line != "Add docs/guide.md|Alice|alice@example.com" { | |
| 67 | + t.Fatalf("unexpected commit metadata: %s", line) | |
| 68 | + } | |
| 69 | + | |
| 70 | + // A second write replaces the file and keeps history. | |
| 71 | + if _, err := WriteFileCommit(ctx, repoPath, "main", "docs/guide.md", []byte("hello v2\n"), "Update docs/guide.md", "Bob", "bob@example.com"); err != nil { | |
| 72 | + t.Fatalf("second WriteFileCommit: %v", err) | |
| 73 | + } | |
| 74 | + if n := CountCommits(ctx, repoPath, "main"); n != 3 { | |
| 75 | + t.Fatalf("expected 3 commits, got %d", n) | |
| 76 | + } | |
| 77 | + blob, err = ReadBlob(ctx, repoPath, "main", "docs/guide.md") | |
| 78 | + if err != nil { | |
| 79 | + t.Fatalf("ReadBlob v2: %v", err) | |
| 80 | + } | |
| 81 | + if string(blob.Data) != "hello v2\n" { | |
| 82 | + t.Fatalf("unexpected blob content: %q", string(blob.Data)) | |
| 83 | + } | |
| 84 | + | |
| 85 | + // Invalid paths and refs must be rejected. | |
| 86 | + if _, err := WriteFileCommit(ctx, repoPath, "main", "../evil.txt", []byte("x"), "", "Alice", "alice@example.com"); err == nil { | |
| 87 | + t.Fatalf("expected invalid path to be rejected") | |
| 88 | + } | |
| 89 | + if _, err := WriteFileCommit(ctx, repoPath, "main", ".git/config", []byte("x"), "", "Alice", "alice@example.com"); err == nil { | |
| 90 | + t.Fatalf("expected .git path to be rejected") | |
| 91 | + } | |
| 92 | + if _, err := WriteFileCommit(ctx, repoPath, "no-such-branch", "file.txt", []byte("x"), "", "Alice", "alice@example.com"); err == nil { | |
| 93 | + t.Fatalf("expected missing branch to be rejected") | |
| 94 | + } | |
| 95 | +} |
internal/i18n/i18n.go
+36
-0
| ... | ... | @@ -350,6 +350,24 @@ var translations = map[Lang]map[string]string{ |
| 350 | 350 | "flash.release_published": "Релиз %s опубликован.", |
| 351 | 351 | "flash.release_delete_failed": "Не удалось удалить релиз: %s", |
| 352 | 352 | "flash.release_deleted": "Релиз удалён.", |
| 353 | + "file.new_file": "Новый файл", | |
| 354 | + "file.new_folder": "Новая папка", | |
| 355 | + "file.path": "Путь", | |
| 356 | + "file.path_ph": "напр. src/main.go", | |
| 357 | + "file.folder_ph": "напр. docs", | |
| 358 | + "file.branch": "Ветка", | |
| 359 | + "file.content": "Содержимое", | |
| 360 | + "file.content_ph": "Текст файла…", | |
| 361 | + "file.message": "Сообщение коммита", | |
| 362 | + "file.create_file": "Создать файл", | |
| 363 | + "file.create_folder": "Создать папку", | |
| 364 | + "file.folder_hint": "Git не хранит пустые папки — Polar добавит туда .gitkeep.", | |
| 365 | + "flash.file_created": "Файл создан.", | |
| 366 | + "flash.folder_created": "Папка создана.", | |
| 367 | + "flash.file_exists": "Файл с таким путём уже существует.", | |
| 368 | + "flash.path_invalid": "Некорректный путь.", | |
| 369 | + "flash.file_create_failed": "Не удалось создать файл.", | |
| 370 | + "flash.folder_create_failed": "Не удалось создать папку.", | |
| 353 | 371 | "flash.repo_settings_saved": "Настройки репозитория сохранены.", |
| 354 | 372 | "flash.repo_rename_failed": "Не удалось переименовать: %s", |
| 355 | 373 | "flash.repo_renamed": "Репозиторий переименован в %s.", |
| ... | ... | @@ -815,6 +833,24 @@ var translations = map[Lang]map[string]string{ |
| 815 | 833 | "flash.release_published": "Release %s published.", |
| 816 | 834 | "flash.release_delete_failed": "Could not delete the release: %s", |
| 817 | 835 | "flash.release_deleted": "Release deleted.", |
| 836 | + "file.new_file": "New file", | |
| 837 | + "file.new_folder": "New folder", | |
| 838 | + "file.path": "Path", | |
| 839 | + "file.path_ph": "e.g. src/main.go", | |
| 840 | + "file.folder_ph": "e.g. docs", | |
| 841 | + "file.branch": "Branch", | |
| 842 | + "file.content": "Content", | |
| 843 | + "file.content_ph": "File contents…", | |
| 844 | + "file.message": "Commit message", | |
| 845 | + "file.create_file": "Create file", | |
| 846 | + "file.create_folder": "Create folder", | |
| 847 | + "file.folder_hint": "Git does not track empty folders — Polar adds a .gitkeep placeholder.", | |
| 848 | + "flash.file_created": "File created.", | |
| 849 | + "flash.folder_created": "Folder created.", | |
| 850 | + "flash.file_exists": "A file with that path already exists.", | |
| 851 | + "flash.path_invalid": "Invalid path.", | |
| 852 | + "flash.file_create_failed": "Could not create the file.", | |
| 853 | + "flash.folder_create_failed": "Could not create the folder.", | |
| 818 | 854 | "flash.repo_settings_saved": "Repository settings saved.", |
| 819 | 855 | "flash.repo_rename_failed": "Could not rename: %s", |
| 820 | 856 | "flash.repo_renamed": "Repository renamed to %s.", |
internal/web/handlers/handlers.go
+9
-1
| ... | ... | @@ -5,6 +5,7 @@ import ( |
| 5 | 5 | "fmt" |
| 6 | 6 | "log/slog" |
| 7 | 7 | "net/http" |
| 8 | + "net/url" | |
| 8 | 9 | "strings" |
| 9 | 10 | "time" |
| 10 | 11 | |
| ... | ... | @@ -118,6 +119,10 @@ func (h *Handler) RegisterRoutes(app *fiber.App) { |
| 118 | 119 | app.Post("/:owner/:repo/tags", middleware.RequireAuth(), h.RepoCreateTagSubmit) |
| 119 | 120 | app.Post("/:owner/:repo/tags/delete", middleware.RequireAuth(), h.RepoDeleteTagSubmit) |
| 120 | 121 | app.Get("/:owner/:repo/archive/*", h.RepoArchiveZip) |
| 122 | + app.Get("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoNewFilePage) | |
| 123 | + app.Post("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoCreateFileSubmit) | |
| 124 | + app.Get("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoNewFolderPage) | |
| 125 | + app.Post("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoCreateFolderSubmit) | |
| 121 | 126 | |
| 122 | 127 | app.Post("/:owner/:repo/fork", middleware.RequireAuth(), h.RepoForkSubmit) |
| 123 | 128 | app.Get("/:owner/:repo/forks", h.RepoForksPage) |
| ... | ... | @@ -184,6 +189,9 @@ func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any { |
| 184 | 189 | |
| 185 | 190 | flash := c.Cookies("polar_flash") |
| 186 | 191 | if flash != "" { |
| 192 | + if decoded, err := url.QueryUnescape(flash); err == nil { | |
| 193 | + flash = decoded | |
| 194 | + } | |
| 187 | 195 | c.ClearCookie("polar_flash") |
| 188 | 196 | } |
| 189 | 197 | |
| ... | ... | @@ -242,7 +250,7 @@ func (h *Handler) repoNavData(c *fiber.Ctx) map[string]any { |
| 242 | 250 | func (h *Handler) setFlash(c *fiber.Ctx, msg string) { |
| 243 | 251 | c.Cookie(&fiber.Cookie{ |
| 244 | 252 | Name: "polar_flash", |
| 245 | - Value: msg, | |
| 253 | + Value: url.QueryEscape(msg), | |
| 246 | 254 | Path: "/", |
| 247 | 255 | MaxAge: 30, |
| 248 | 256 | HTTPOnly: true, |
internal/web/handlers/repo_files.go
+131
-0
| ... | ... | @@ -0,0 +1,131 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "net/url" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "polar/internal/git" | |
| 9 | + "polar/internal/web/middleware" | |
| 10 | + | |
| 11 | + "github.com/gofiber/fiber/v2" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// RepoNewFilePage renders the web form for creating a file. | |
| 15 | +// GET /:owner/:repo/new-file | |
| 16 | +func (h *Handler) RepoNewFilePage(c *fiber.Ctx) error { | |
| 17 | + return h.renderFileForm(c, "file") | |
| 18 | +} | |
| 19 | + | |
| 20 | +// RepoNewFolderPage renders the form for creating a folder. Git cannot store | |
| 21 | +// empty directories, so the folder is tracked through a .gitkeep placeholder. | |
| 22 | +// GET /:owner/:repo/new-folder | |
| 23 | +func (h *Handler) RepoNewFolderPage(c *fiber.Ctx) error { | |
| 24 | + return h.renderFileForm(c, "folder") | |
| 25 | +} | |
| 26 | + | |
| 27 | +func (h *Handler) renderFileForm(c *fiber.Ctx, mode string) error { | |
| 28 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 29 | + if err != nil { | |
| 30 | + return nil | |
| 31 | + } | |
| 32 | + | |
| 33 | + titleKey := "file.new_file" | |
| 34 | + if mode == "folder" { | |
| 35 | + titleKey = "file.new_folder" | |
| 36 | + } | |
| 37 | + | |
| 38 | + ref := strings.TrimSpace(c.Query("ref")) | |
| 39 | + if ref == "" { | |
| 40 | + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch) | |
| 41 | + } | |
| 42 | + subPath := strings.Trim(strings.ReplaceAll(c.Query("path"), "\\", "/"), "/") | |
| 43 | + | |
| 44 | + data := h.baseData(c, h.tr(c, titleKey)) | |
| 45 | + data["Repo"] = r | |
| 46 | + data["Ref"] = ref | |
| 47 | + data["SubPath"] = subPath | |
| 48 | + data["Mode"] = mode | |
| 49 | + data["ActiveTab"] = "code" | |
| 50 | + data["CanWrite"] = true | |
| 51 | + data["Branches"], _ = git.ListBranches(c.UserContext(), diskPath) | |
| 52 | + return h.render(c, fiber.StatusOK, "file_new", data) | |
| 53 | +} | |
| 54 | + | |
| 55 | +// RepoCreateFileSubmit commits a new file to the selected branch. | |
| 56 | +// POST /:owner/:repo/new-file | |
| 57 | +func (h *Handler) RepoCreateFileSubmit(c *fiber.Ctx) error { | |
| 58 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 59 | + if err != nil { | |
| 60 | + return nil | |
| 61 | + } | |
| 62 | + u := middleware.CurrentUser(c) | |
| 63 | + | |
| 64 | + ref := strings.TrimSpace(c.FormValue("ref")) | |
| 65 | + if ref == "" || !git.BranchExists(c.UserContext(), diskPath, ref) { | |
| 66 | + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch) | |
| 67 | + } | |
| 68 | + | |
| 69 | + filePath := strings.TrimSpace(c.FormValue("path")) | |
| 70 | + message := strings.TrimSpace(c.FormValue("message")) | |
| 71 | + content := c.FormValue("content") | |
| 72 | + | |
| 73 | + backTo := fmt.Sprintf("/%s/%s/new-file?ref=%s", r.OwnerUsername, r.Name, url.QueryEscape(ref)) | |
| 74 | + | |
| 75 | + clean, cerr := git.CleanSubPath(filePath) | |
| 76 | + if cerr != nil || clean == "" { | |
| 77 | + h.setFlashT(c, "flash.path_invalid") | |
| 78 | + return c.Redirect(backTo, fiber.StatusSeeOther) | |
| 79 | + } | |
| 80 | + if blob, rerr := git.ReadBlob(c.UserContext(), diskPath, ref, clean); rerr == nil && blob != nil { | |
| 81 | + h.setFlashT(c, "flash.file_exists") | |
| 82 | + return c.Redirect(backTo+"&path="+url.QueryEscape(clean), fiber.StatusSeeOther) | |
| 83 | + } | |
| 84 | + if message == "" { | |
| 85 | + message = "Add " + clean | |
| 86 | + } | |
| 87 | + if _, werr := git.WriteFileCommit(c.UserContext(), diskPath, ref, clean, []byte(content), message, u.Username, u.Email); werr != nil { | |
| 88 | + h.setFlashT(c, "flash.file_create_failed") | |
| 89 | + return c.Redirect(backTo+"&path="+url.QueryEscape(clean), fiber.StatusSeeOther) | |
| 90 | + } | |
| 91 | + | |
| 92 | + h.setFlashT(c, "flash.file_created") | |
| 93 | + return c.Redirect(fmt.Sprintf("/%s/%s/blob/%s/%s", r.OwnerUsername, r.Name, ref, clean), fiber.StatusSeeOther) | |
| 94 | +} | |
| 95 | + | |
| 96 | +// RepoCreateFolderSubmit commits a .gitkeep placeholder so the new folder is | |
| 97 | +// tracked by git. | |
| 98 | +// POST /:owner/:repo/new-folder | |
| 99 | +func (h *Handler) RepoCreateFolderSubmit(c *fiber.Ctx) error { | |
| 100 | + r, diskPath, err := h.loadAuthorizedRepo(c, true) | |
| 101 | + if err != nil { | |
| 102 | + return nil | |
| 103 | + } | |
| 104 | + u := middleware.CurrentUser(c) | |
| 105 | + | |
| 106 | + ref := strings.TrimSpace(c.FormValue("ref")) | |
| 107 | + if ref == "" || !git.BranchExists(c.UserContext(), diskPath, ref) { | |
| 108 | + ref, _ = git.EnsureDefaultBranch(c.UserContext(), diskPath, r.DefaultBranch) | |
| 109 | + } | |
| 110 | + | |
| 111 | + folder := strings.TrimSpace(c.FormValue("path")) | |
| 112 | + message := strings.TrimSpace(c.FormValue("message")) | |
| 113 | + | |
| 114 | + backTo := fmt.Sprintf("/%s/%s/new-folder?ref=%s", r.OwnerUsername, r.Name, url.QueryEscape(ref)) | |
| 115 | + | |
| 116 | + clean, cerr := git.CleanSubPath(folder) | |
| 117 | + if cerr != nil || clean == "" { | |
| 118 | + h.setFlashT(c, "flash.path_invalid") | |
| 119 | + return c.Redirect(backTo, fiber.StatusSeeOther) | |
| 120 | + } | |
| 121 | + if message == "" { | |
| 122 | + message = "Create folder " + clean | |
| 123 | + } | |
| 124 | + if _, werr := git.WriteFileCommit(c.UserContext(), diskPath, ref, clean+"/.gitkeep", nil, message, u.Username, u.Email); werr != nil { | |
| 125 | + h.setFlashT(c, "flash.folder_create_failed") | |
| 126 | + return c.Redirect(backTo, fiber.StatusSeeOther) | |
| 127 | + } | |
| 128 | + | |
| 129 | + h.setFlashT(c, "flash.folder_created") | |
| 130 | + return c.Redirect(fmt.Sprintf("/%s/%s/tree/%s/%s", r.OwnerUsername, r.Name, ref, clean), fiber.StatusSeeOther) | |
| 131 | +} |
internal/web/static/css/app.css
+19
-48
| ... | ... | @@ -188,34 +188,37 @@ a { |
| 188 | 188 | } |
| 189 | 189 | |
| 190 | 190 | .app-sidebar { |
| 191 | - width: 236px; | |
| 192 | - min-width: 236px; | |
| 193 | - max-width: 236px; | |
| 194 | - flex: 0 0 236px; | |
| 195 | - border-right: 1px solid hsl(var(--border)); | |
| 191 | + width: 232px; | |
| 192 | + min-width: 232px; | |
| 193 | + max-width: 232px; | |
| 194 | + flex: 0 0 232px; | |
| 195 | + border: 1px solid hsl(var(--border)); | |
| 196 | + border-radius: calc(var(--radius) * 1.5); | |
| 196 | 197 | background-color: hsl(var(--card)); |
| 197 | 198 | display: none; |
| 198 | 199 | flex-direction: column; |
| 199 | 200 | justify-content: space-between; |
| 200 | 201 | position: sticky; |
| 201 | - top: 0; | |
| 202 | - height: 100vh; | |
| 202 | + top: 0.75rem; | |
| 203 | + height: calc(100vh - 1.5rem); | |
| 204 | + margin: 0.75rem 0 0.75rem 0.75rem; | |
| 203 | 205 | overflow-y: auto; |
| 204 | 206 | overflow-x: hidden; |
| 205 | - padding: 0.85rem 0.75rem; | |
| 207 | + padding: 0.85rem 0.7rem; | |
| 206 | 208 | user-select: none; |
| 207 | 209 | transition: |
| 208 | 210 | width 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 209 | 211 | min-width 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 210 | 212 | max-width 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 211 | 213 | flex-basis 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 214 | + margin 200ms cubic-bezier(0.16, 1, 0.3, 1), | |
| 212 | 215 | padding 200ms cubic-bezier(0.16, 1, 0.3, 1), |
| 213 | 216 | border-color 200ms cubic-bezier(0.16, 1, 0.3, 1); |
| 214 | 217 | } |
| 215 | 218 | |
| 216 | 219 | .sidebar-inner { |
| 217 | - width: 218px; | |
| 218 | - min-width: 218px; | |
| 220 | + width: 100%; | |
| 221 | + min-width: 0; | |
| 219 | 222 | height: 100%; |
| 220 | 223 | display: flex; |
| 221 | 224 | flex-direction: column; |
| ... | ... | @@ -230,9 +233,10 @@ html[data-sidebar="collapsed"] .app-sidebar { |
| 230 | 233 | min-width: 0; |
| 231 | 234 | max-width: 0; |
| 232 | 235 | flex-basis: 0; |
| 236 | + margin: 0.75rem 0; | |
| 233 | 237 | padding-left: 0; |
| 234 | 238 | padding-right: 0; |
| 235 | - border-right-color: transparent; | |
| 239 | + border-color: transparent; | |
| 236 | 240 | } |
| 237 | 241 | |
| 238 | 242 | html[data-sidebar="collapsed"] .sidebar-inner { |
| ... | ... | @@ -1238,13 +1242,7 @@ kbd, .kbd { |
| 1238 | 1242 | border-radius: calc(var(--radius) * 1.6); |
| 1239 | 1243 | overflow: hidden; |
| 1240 | 1244 | } |
| 1241 | -.repo-banner::before { | |
| 1242 | - content: ""; | |
| 1243 | - position: absolute; | |
| 1244 | - inset: 0 0 auto 0; | |
| 1245 | - height: 2px; | |
| 1246 | - background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0) 65%); | |
| 1247 | -} | |
| 1245 | + | |
| 1248 | 1246 | .repo-banner-main { |
| 1249 | 1247 | display: flex; |
| 1250 | 1248 | align-items: center; |
| ... | ... | @@ -1259,39 +1257,12 @@ kbd, .kbd { |
| 1259 | 1257 | flex-wrap: wrap; |
| 1260 | 1258 | } |
| 1261 | 1259 | |
| 1262 | -.repo-stats { | |
| 1263 | - display: flex; | |
| 1264 | - align-items: center; | |
| 1265 | - gap: 0.4rem; | |
| 1266 | - flex-wrap: wrap; | |
| 1267 | -} | |
| 1268 | -.stat-chip { | |
| 1269 | - display: inline-flex; | |
| 1270 | - align-items: center; | |
| 1271 | - gap: 0.4rem; | |
| 1272 | - height: 1.85rem; | |
| 1273 | - padding: 0 0.7rem; | |
| 1274 | - border: 1px solid hsl(var(--border)); | |
| 1275 | - border-radius: 999px; | |
| 1276 | - background: hsl(var(--card)); | |
| 1277 | - font-size: 0.78rem; | |
| 1278 | - color: hsl(var(--muted-foreground)); | |
| 1279 | - white-space: nowrap; | |
| 1280 | - transition: border-color 120ms ease, color 120ms ease; | |
| 1281 | -} | |
| 1282 | -.stat-chip b { | |
| 1283 | - color: hsl(var(--foreground)); | |
| 1284 | - font-weight: 600; | |
| 1285 | -} | |
| 1286 | -.stat-chip:hover { | |
| 1287 | - color: hsl(var(--foreground)); | |
| 1288 | - border-color: hsl(var(--primary) / 0.55); | |
| 1289 | -} | |
| 1260 | + | |
| 1290 | 1261 | |
| 1291 | 1262 | .repo-layout { |
| 1292 | 1263 | display: grid; |
| 1293 | - grid-template-columns: minmax(0, 1fr) 305px; | |
| 1294 | - gap: 1.1rem; | |
| 1264 | + grid-template-columns: minmax(0, 1fr) 280px; | |
| 1265 | + gap: 1rem; | |
| 1295 | 1266 | align-items: start; |
| 1296 | 1267 | } |
| 1297 | 1268 | .repo-main { |
internal/web/views/pages/file_new.html
+53
-0
| ... | ... | @@ -0,0 +1,53 @@ |
| 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 | + {{if eq .Mode "folder"}}{{icon "folder" "size-4"}}{{else}}{{icon "file" "size-4"}}{{end}} | |
| 9 | + {{if eq .Mode "folder"}}{{t .Lang "file.new_folder"}}{{else}}{{t .Lang "file.new_file"}}{{end}} | |
| 10 | + </span> | |
| 11 | + <span class="font-mono text-xs text-muted">{{.Repo.OwnerUsername}}/{{.Repo.Name}}</span> | |
| 12 | + </div> | |
| 13 | + <form method="post" action="{{if eq .Mode "folder"}}/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-folder{{else}}/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/new-file{{end}}" 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="text-xs font-semibold" for="path">{{t .Lang "file.path"}}</label> | |
| 18 | + <input id="path" name="path" class="input font-mono" required autofocus value="{{if .SubPath}}{{.SubPath}}/{{end}}" placeholder="{{if eq .Mode "folder"}}{{t .Lang "file.folder_ph"}}{{else}}{{t .Lang "file.path_ph"}}{{end}}"> | |
| 19 | + {{if eq .Mode "folder"}}<span class="text-xs text-muted">{{t .Lang "file.folder_hint"}}</span>{{end}} | |
| 20 | + </div> | |
| 21 | + | |
| 22 | + <div class="flex flex-col gap-1" style="max-width:240px;"> | |
| 23 | + <label class="text-xs font-semibold" for="ref">{{t .Lang "file.branch"}}</label> | |
| 24 | + <select id="ref" name="ref" class="input font-mono"> | |
| 25 | + {{range .Branches}}<option value="{{.}}" {{if eq . $.Ref}}selected{{end}}>{{.}}</option>{{end}} | |
| 26 | + </select> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + {{if eq .Mode "file"}} | |
| 30 | + <div class="flex flex-col gap-1"> | |
| 31 | + <label class="text-xs font-semibold" for="content">{{t .Lang "file.content"}}</label> | |
| 32 | + <textarea id="content" name="content" class="input font-mono" rows="12" placeholder="{{t .Lang "file.content_ph"}}" style="resize:vertical;min-height:12rem;line-height:1.5;"></textarea> | |
| 33 | + </div> | |
| 34 | + {{end}} | |
| 35 | + | |
| 36 | + <div class="flex flex-col gap-1"> | |
| 37 | + <label class="text-xs font-semibold" for="message">{{t .Lang "file.message"}}</label> | |
| 38 | + <input id="message" name="message" class="input font-mono" placeholder="{{if eq .Mode "folder"}}Create folder{{else}}Add file{{end}}"> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <div class="flex items-center gap-2 pt-1"> | |
| 42 | + <button type="submit" class="btn btn-primary btn-sm"> | |
| 43 | + {{icon "check" "size-3.5"}} | |
| 44 | + <span>{{if eq .Mode "folder"}}{{t .Lang "file.create_folder"}}{{else}}{{t .Lang "file.create_file"}}{{end}}</span> | |
| 45 | + </button> | |
| 46 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Ref}}{{if .SubPath}}/{{.SubPath}}{{end}}" class="btn btn-ghost btn-sm"> | |
| 47 | + <span>{{t .Lang "common.cancel"}}</span> | |
| 48 | + </a> | |
| 49 | + </div> | |
| 50 | + </form> | |
| 51 | + </div> | |
| 52 | +</div> | |
| 53 | +{{end}} |
internal/web/views/pages/profile.html
+50
-80
| ... | ... | @@ -1,45 +1,39 @@ |
| 1 | 1 | {{define "content"}} |
| 2 | 2 | <div class="container"> |
| 3 | - <!-- Profile Card --> | |
| 3 | + <!-- Profile header --> | |
| 4 | 4 | <div class="card p-4 mb-4"> |
| 5 | - <div class="flex items-start justify-between flex-wrap gap-4"> | |
| 6 | - <div class="flex items-start gap-4 min-w-0"> | |
| 7 | - {{avatar .ProfileUser.Username .ProfileUser.AvatarURL "avatar-xl"}} | |
| 8 | - <div class="flex flex-col gap-1 min-w-0"> | |
| 9 | - <div class="flex items-center gap-2 flex-wrap"> | |
| 10 | - {{if .ProfileUser.FullName}} | |
| 11 | - <h1 class="text-xl font-bold" style="margin:0;line-height:1.2;">{{.ProfileUser.FullName}}</h1> | |
| 12 | - <span class="font-mono text-sm text-muted">@{{.ProfileUser.Username}}</span> | |
| 13 | - {{else}} | |
| 14 | - <h1 class="text-xl font-bold" style="margin:0;line-height:1.2;">{{.ProfileUser.Username}}</h1> | |
| 15 | - {{end}} | |
| 16 | - {{if .ProfileUser.IsAdmin}} | |
| 17 | - <span class="badge badge-primary">{{t .Lang "admin.role.admin"}}</span> | |
| 18 | - {{end}} | |
| 19 | - </div> | |
| 20 | - | |
| 21 | - {{if .ProfileUser.Bio}} | |
| 22 | - <p class="text-sm" style="margin:0.15rem 0 0.25rem;max-width:38rem;">{{.ProfileUser.Bio}}</p> | |
| 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"> | |
| 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> | |
| 10 | + {{if .ProfileUser.IsAdmin}} | |
| 11 | + <span class="badge badge-primary">{{t .Lang "admin.role.admin"}}</span> | |
| 23 | 12 | {{end}} |
| 13 | + </div> | |
| 14 | + <div class="text-sm text-muted font-mono">@{{.ProfileUser.Username}}</div> | |
| 15 | + | |
| 16 | + {{if .ProfileUser.Bio}} | |
| 17 | + <p class="text-sm" style="margin:0.45rem 0 0;max-width:38rem;">{{.ProfileUser.Bio}}</p> | |
| 18 | + {{end}} | |
| 24 | 19 | |
| 25 | - <div class="flex items-center flex-wrap gap-3 text-xs text-muted mt-1"> | |
| 26 | - {{if .ProfileUser.Location}} | |
| 27 | - <span class="flex items-center gap-1"> | |
| 28 | - {{icon "map-pin" "size-3.5"}} | |
| 29 | - <span>{{.ProfileUser.Location}}</span> | |
| 30 | - </span> | |
| 31 | - {{end}} | |
| 32 | - {{if .ProfileUser.Website}} | |
| 33 | - <a href="{{.ProfileUser.Website}}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1" style="color:hsl(var(--foreground));text-decoration:underline;text-underline-offset:3px;"> | |
| 34 | - {{icon "link" "size-3.5"}} | |
| 35 | - <span>{{.ProfileUser.Website}}</span> | |
| 36 | - </a> | |
| 37 | - {{end}} | |
| 20 | + <div class="flex items-center flex-wrap gap-3 text-xs text-muted mt-2"> | |
| 21 | + {{if .ProfileUser.Location}} | |
| 38 | 22 | <span class="flex items-center gap-1"> |
| 39 | - {{icon "calendar" "size-3.5"}} | |
| 40 | - <span>{{t .Lang "profile.joined"}}: {{formatShortDate .ProfileUser.CreatedAt}}</span> | |
| 23 | + {{icon "map-pin" "size-3.5"}} | |
| 24 | + <span>{{.ProfileUser.Location}}</span> | |
| 41 | 25 | </span> |
| 42 | - </div> | |
| 26 | + {{end}} | |
| 27 | + {{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> | |
| 32 | + {{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> | |
| 43 | 37 | </div> |
| 44 | 38 | </div> |
| 45 | 39 | |
| ... | ... | @@ -54,32 +48,14 @@ |
| 54 | 48 | {{end}} |
| 55 | 49 | </div> |
| 56 | 50 | </div> |
| 57 | - | |
| 58 | - <!-- Stats Row --> | |
| 59 | - <div class="flex items-center gap-4 flex-wrap mt-4 pt-3" style="border-top:1px solid hsl(var(--border));"> | |
| 60 | - <div class="flex items-center gap-1.5 text-xs"> | |
| 61 | - {{icon "git-branch" "size-3.5"}} | |
| 62 | - <span class="text-muted">{{t .Lang "profile.total_repos"}}:</span> | |
| 63 | - <strong class="font-mono">{{len .Repos}}</strong> | |
| 64 | - </div> | |
| 65 | - <div class="flex items-center gap-1.5 text-xs"> | |
| 66 | - {{icon "globe" "size-3.5"}} | |
| 67 | - <span class="text-muted">{{t .Lang "profile.public_repos"}}:</span> | |
| 68 | - <strong class="font-mono">{{.PublicRepoCount}}</strong> | |
| 69 | - </div> | |
| 70 | - {{if .ShowPrivateCount}} | |
| 71 | - <div class="flex items-center gap-1.5 text-xs"> | |
| 72 | - {{icon "lock" "size-3.5"}} | |
| 73 | - <span class="text-muted">{{t .Lang "profile.private_repos"}}:</span> | |
| 74 | - <strong class="font-mono">{{.PrivateRepoCount}}</strong> | |
| 75 | - </div> | |
| 76 | - {{end}} | |
| 77 | - </div> | |
| 78 | 51 | </div> |
| 79 | 52 | |
| 80 | - <!-- Repositories Section --> | |
| 81 | - <div class="flex items-center justify-between flex-wrap gap-3 mb-3"> | |
| 82 | - <h2 class="text-base font-semibold" style="margin:0;">{{t .Lang "nav.repositories"}} ({{len .Repos}})</h2> | |
| 53 | + <!-- Repositories --> | |
| 54 | + <div class="flex items-center justify-between flex-wrap gap-2 mb-3"> | |
| 55 | + <h2 class="text-base font-semibold" style="margin:0;"> | |
| 56 | + {{t .Lang "nav.repositories"}} | |
| 57 | + <span class="text-muted font-mono text-sm" style="font-weight:400;">{{len .Repos}}</span> | |
| 58 | + </h2> | |
| 83 | 59 | <div class="flex items-center gap-2"> |
| 84 | 60 | <div class="flex items-center" style="position:relative;"> |
| 85 | 61 | <input type="text" class="input" style="height:1.85rem;width:14rem;padding-right:1.8rem;" placeholder="{{t .Lang "repo.filter_ph"}}" data-search-input data-file-filter> |
| ... | ... | @@ -99,30 +75,24 @@ |
| 99 | 75 | {{if .Repos}} |
| 100 | 76 | <div class="grid-2"> |
| 101 | 77 | {{range .Repos}} |
| 102 | - <div class="card p-3 flex flex-col justify-between gap-2" data-tree-row data-tree-name="{{.Name}} {{.Description}}"> | |
| 103 | - <div> | |
| 104 | - <div class="flex items-center justify-between gap-2 mb-1"> | |
| 105 | - <a href="/{{.OwnerUsername}}/{{.Name}}" class="font-semibold text-sm flex items-center gap-1.5 truncate"> | |
| 106 | - {{if .IsPrivate}} | |
| 107 | - {{icon "lock" "size-3.5"}} | |
| 108 | - {{else}} | |
| 109 | - {{icon "git-branch" "size-3.5"}} | |
| 110 | - {{end}} | |
| 111 | - <span class="truncate">{{.Name}}</span> | |
| 112 | - </a> | |
| 78 | + <div class="card p-3 flex flex-col gap-2" data-tree-row data-tree-name="{{.Name}} {{.Description}}"> | |
| 79 | + <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"> | |
| 113 | 81 | {{if .IsPrivate}} |
| 114 | - <span class="badge badge-outline">{{t $.Lang "repo.private"}}</span> | |
| 82 | + {{icon "lock" "size-3.5"}} | |
| 115 | 83 | {{else}} |
| 116 | - <span class="badge badge-outline">{{t $.Lang "repo.public"}}</span> | |
| 84 | + {{icon "git-branch" "size-3.5"}} | |
| 117 | 85 | {{end}} |
| 118 | - </div> | |
| 119 | - {{if .Description}} | |
| 120 | - <p class="text-xs text-muted" style="margin:0.2rem 0 0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{{.Description}}</p> | |
| 121 | - {{end}} | |
| 86 | + <span class="truncate">{{.Name}}</span> | |
| 87 | + </a> | |
| 88 | + <span class="text-xs text-muted flex-shrink-0">{{timeAgo .UpdatedAt $.Lang}}</span> | |
| 122 | 89 | </div> |
| 123 | - <div class="flex items-center justify-between text-xs text-muted pt-2" style="border-top:1px solid hsl(var(--border)/0.6);"> | |
| 124 | - <span class="font-mono flex items-center gap-1">{{icon "git-branch" "size-3.5"}} {{.DefaultBranch}}</span> | |
| 125 | - <span>{{timeAgo .UpdatedAt $.Lang}}</span> | |
| 90 | + {{if .Description}} | |
| 91 | + <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 | + {{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> | |
| 126 | 96 | </div> |
| 127 | 97 | </div> |
| 128 | 98 | {{end}} |
| ... | ... | @@ -135,4 +105,4 @@ |
| 135 | 105 | {{end}} |
| 136 | 106 | {{end}} |
| 137 | 107 | </div> |
| 138 | -{{end}} | |
| 108 | +{{end}} |
internal/web/views/pages/repo.html
+25
-38
| ... | ... | @@ -92,9 +92,21 @@ |
| 92 | 92 | {{end}} |
| 93 | 93 | </div> |
| 94 | 94 | |
| 95 | - <div class="flex items-center" style="position:relative;"> | |
| 96 | - <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> | |
| 97 | - <kbd class="kbd" style="position:absolute;right:0.35rem;pointer-events:none;">t</kbd> | |
| 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> | |
| 108 | + </a> | |
| 109 | + {{end}} | |
| 98 | 110 | </div> |
| 99 | 111 | </div> |
| 100 | 112 | |
| ... | ... | @@ -165,37 +177,6 @@ |
| 165 | 177 | </div> |
| 166 | 178 | |
| 167 | 179 | <aside class="repo-side"> |
| 168 | - <div class="side-card"> | |
| 169 | - <div class="side-card-header">{{icon "info" "size-3.5"}} {{t .Lang "repo.about"}}</div> | |
| 170 | - <div class="side-card-body"> | |
| 171 | - {{if .Repo.Description}} | |
| 172 | - <p style="margin:0;">{{.Repo.Description}}</p> | |
| 173 | - {{end}} | |
| 174 | - {{if .Repo.ForkedFromRepoID}} | |
| 175 | - <div class="meta-row"> | |
| 176 | - <span>{{t .Lang "fork.forked_from"}}</span> | |
| 177 | - <a href="/{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}" class="font-medium font-mono">{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}</a> | |
| 178 | - </div> | |
| 179 | - {{end}} | |
| 180 | - <div class="meta-row"> | |
| 181 | - <span>{{t .Lang "repo.default_branch"}}</span> | |
| 182 | - <span class="badge badge-outline font-mono">{{.Repo.DefaultBranch}}</span> | |
| 183 | - </div> | |
| 184 | - <div class="meta-row"> | |
| 185 | - <span>{{t .Lang "fork.forks"}}</span> | |
| 186 | - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks" class="font-mono">{{.Repo.ForkCount}}</a> | |
| 187 | - </div> | |
| 188 | - <div class="meta-row"> | |
| 189 | - <span>{{t .Lang "repo.created"}}</span> | |
| 190 | - <span class="font-mono">{{formatDate .Repo.CreatedAt}}</span> | |
| 191 | - </div> | |
| 192 | - <div class="meta-row"> | |
| 193 | - <span>{{t .Lang "repo.updated"}}</span> | |
| 194 | - <span class="font-mono">{{formatDate .Repo.UpdatedAt}}</span> | |
| 195 | - </div> | |
| 196 | - </div> | |
| 197 | - </div> | |
| 198 | - | |
| 199 | 180 | <div class="side-card"> |
| 200 | 181 | <div class="side-card-header">{{icon "terminal" "size-3.5"}} {{t .Lang "repo.clone_title"}}</div> |
| 201 | 182 | <div class="side-card-body"> |
| ... | ... | @@ -205,10 +186,16 @@ |
| 205 | 186 | {{icon "copy" "size-3.5"}} |
| 206 | 187 | </button> |
| 207 | 188 | </div> |
| 208 | - <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/archive/{{.Ref}}.zip" class="btn btn-outline btn-sm w-full justify-center"> | |
| 209 | - {{icon "download" "size-3.5"}} | |
| 210 | - <span>{{t .Lang "repo.download_zip"}}</span> | |
| 211 | - </a> | |
| 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> | |
| 212 | 199 | </div> |
| 213 | 200 | </div> |
| 214 | 201 | </aside> |
internal/web/views/partials/components.html
+13
-28
| ... | ... | @@ -73,44 +73,29 @@ |
| 73 | 73 | </div> |
| 74 | 74 | </div> |
| 75 | 75 | |
| 76 | - <div class="repo-banner-actions"> | |
| 77 | - {{if .CurrentUser}} | |
| 76 | + {{if .CurrentUser}} | |
| 77 | + <div class="repo-banner-actions"> | |
| 78 | 78 | {{if .IsRepoOwner}} |
| 79 | - <a class="btn btn-outline btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks">{{icon "git-branch" "size-3.5"}}<span>{{if gt .Repo.ForkCount 0}}{{.Repo.ForkCount}} {{end}}{{t .Lang "fork.forks_btn"}}</span></a> | |
| 79 | + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks" title="{{t .Lang "fork.forks_btn"}}">{{icon "git-branch" "size-3.5"}}<span class="hidden md-flex">{{if gt .Repo.ForkCount 0}}{{.Repo.ForkCount}} {{end}}{{t .Lang "fork.forks_btn"}}</span></a> | |
| 80 | 80 | {{else if .ViewerFork}} |
| 81 | - <a class="btn btn-outline btn-sm" href="/{{.ViewerFork.OwnerUsername}}/{{.ViewerFork.Name}}" title="{{t .Lang "fork.your_fork_desc"}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.your_fork"}}</span></a> | |
| 81 | + <a class="btn btn-ghost btn-sm" href="/{{.ViewerFork.OwnerUsername}}/{{.ViewerFork.Name}}" title="{{t .Lang "fork.your_fork_desc"}}">{{icon "git-branch" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "fork.your_fork"}}</span></a> | |
| 82 | 82 | {{else}} |
| 83 | 83 | <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/fork" style="margin:0;"> |
| 84 | 84 | <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> |
| 85 | - <button class="btn btn-outline btn-sm" type="submit" title="{{t .Lang "fork.fork_btn_desc"}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.fork_btn"}}</span></button> | |
| 85 | + <button class="btn btn-ghost btn-sm" type="submit" title="{{t .Lang "fork.fork_btn_desc"}}">{{icon "git-branch" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "fork.fork_btn"}}</span></button> | |
| 86 | 86 | </form> |
| 87 | 87 | {{end}} |
| 88 | - {{else}} | |
| 89 | - <a class="btn btn-outline btn-sm" href="/login?next=/{{.Repo.OwnerUsername}}/{{.Repo.Name}}">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.fork_btn"}}</span></a> | |
| 90 | - {{end}} | |
| 91 | - {{if .Repo.EnableIssues}} | |
| 92 | - <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new">{{icon "issue" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "issues.new"}}</span></a> | |
| 93 | - {{end}} | |
| 94 | - {{if .Repo.EnablePulls}} | |
| 95 | - <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new">{{icon "git-pull-request" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "pulls.new"}}</span></a> | |
| 96 | - {{end}} | |
| 97 | - </div> | |
| 98 | - </div> | |
| 99 | - | |
| 100 | - <div class="repo-stats mb-3"> | |
| 101 | - {{if .Repo.EnableIssues}} | |
| 102 | - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues">{{icon "issue" "size-3.5"}}<span>{{t .Lang "repo.tab.issues"}}</span> <b>{{.Repo.OpenIssueCount}}</b></a> | |
| 103 | - {{end}} | |
| 104 | - {{if .Repo.EnablePulls}} | |
| 105 | - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls">{{icon "git-pull-request" "size-3.5"}}<span>{{t .Lang "repo.tab.pulls"}}</span> <b>{{.Repo.OpenPRCount}}</b></a> | |
| 106 | - {{end}} | |
| 107 | - {{if .Repo.EnableReleases}} | |
| 108 | - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases">{{icon "tag" "size-3.5"}}<span>{{t .Lang "repo.tab.releases"}}</span> <b>{{.Repo.ReleaseCount}}</b></a> | |
| 88 | + {{if .Repo.EnableIssues}} | |
| 89 | + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/issues/new" title="{{t .Lang "issues.new"}}">{{icon "issue" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "issues.new"}}</span></a> | |
| 90 | + {{end}} | |
| 91 | + {{if .Repo.EnablePulls}} | |
| 92 | + <a class="btn btn-ghost btn-sm" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls/new" title="{{t .Lang "pulls.new"}}">{{icon "git-pull-request" "size-3.5"}}<span class="hidden md-flex">{{t .Lang "pulls.new"}}</span></a> | |
| 93 | + {{end}} | |
| 94 | + </div> | |
| 109 | 95 | {{end}} |
| 110 | - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/forks">{{icon "git-branch" "size-3.5"}}<span>{{t .Lang "fork.forks"}}</span> <b>{{.Repo.ForkCount}}</b></a> | |
| 111 | - <a class="stat-chip" href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/branches">{{icon "history" "size-3.5"}}<span>{{t .Lang "repo.tab.branches"}}</span></a> | |
| 112 | 96 | </div> |
| 113 | 97 | |
| 98 | + | |
| 114 | 99 | <div class="tabs-nav" role="tablist"> |
| 115 | 100 | <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}" class="tab-link {{if eq .ActiveTab "code"}}active{{end}}" role="tab"> |
| 116 | 101 | {{icon "code" "size-4"}} |