feat(repo): inline description that expands on click with a growing avatar
bd46702
Файлы на момент коммита
- repo header: description sits on the title line, truncates with an ellipsis and expands onto its own line on click; the avatar animates to match the height of the name + description block - release form: styled drag-and-drop chooser for release assets - /agents: English guide for AI agents (device flow, git ops, scripted UI, releases with assets) with sidebar and command-palette links - keep assets/input.css in sync with the served app.css
R
rin
·
2026-10-16 18:11 UTC
11 файлов изменено
+355
-17
assets/input.css
+48
-1
| ... | ... | @@ -373,6 +373,13 @@ html[data-sidebar="collapsed"] .sidebar-inner { |
| 373 | 373 | .nav-item:active { |
| 374 | 374 | transform: scale(0.985); |
| 375 | 375 | } |
| 376 | +.muted-link { | |
| 377 | + color: hsl(var(--muted-foreground)); | |
| 378 | + transition: color 140ms ease; | |
| 379 | +} | |
| 380 | +.muted-link:hover { | |
| 381 | + color: hsl(var(--foreground)); | |
| 382 | +} | |
| 376 | 383 | .nav-section-label { |
| 377 | 384 | display: flex; |
| 378 | 385 | align-items: center; |
| ... | ... | @@ -734,6 +741,7 @@ kbd, .kbd { |
| 734 | 741 | } |
| 735 | 742 | } |
| 736 | 743 | |
| 744 | + | |
| 737 | 745 | /* Separator */ |
| 738 | 746 | .separator { |
| 739 | 747 | height: 1px; |
| ... | ... | @@ -1273,7 +1281,7 @@ kbd, .kbd { |
| 1273 | 1281 | justify-content: space-between; |
| 1274 | 1282 | gap: 1rem; |
| 1275 | 1283 | flex-wrap: wrap; |
| 1276 | - padding: 0.9rem 1rem 0.95rem; | |
| 1284 | + padding: 0.65rem 1rem; | |
| 1277 | 1285 | background: hsl(var(--card)); |
| 1278 | 1286 | border: 1px solid hsl(var(--border)); |
| 1279 | 1287 | border-radius: calc(var(--radius) * 1.6); |
| ... | ... | @@ -1293,6 +1301,45 @@ kbd, .kbd { |
| 1293 | 1301 | gap: 0.4rem; |
| 1294 | 1302 | flex-wrap: wrap; |
| 1295 | 1303 | } |
| 1304 | +.repo-head-block { | |
| 1305 | + min-width: 0; | |
| 1306 | + flex: 1 1 auto; | |
| 1307 | +} | |
| 1308 | +.repo-head-row { | |
| 1309 | + display: flex; | |
| 1310 | + align-items: center; | |
| 1311 | + gap: 0.55rem; | |
| 1312 | + flex-wrap: wrap; | |
| 1313 | + min-width: 0; | |
| 1314 | +} | |
| 1315 | +.repo-desc { | |
| 1316 | + flex: 1 1 6rem; | |
| 1317 | + min-width: 0; | |
| 1318 | + max-width: 36rem; | |
| 1319 | + overflow: hidden; | |
| 1320 | + text-overflow: ellipsis; | |
| 1321 | + white-space: nowrap; | |
| 1322 | + font-size: 0.8rem; | |
| 1323 | + line-height: 1.35; | |
| 1324 | + color: hsl(var(--muted-foreground)); | |
| 1325 | + transition: color 0.15s ease; | |
| 1326 | +} | |
| 1327 | +.repo-desc.truncated { | |
| 1328 | + cursor: pointer; | |
| 1329 | +} | |
| 1330 | +.repo-desc.truncated:hover { | |
| 1331 | + color: hsl(var(--foreground)); | |
| 1332 | +} | |
| 1333 | +.repo-banner.desc-open .repo-desc { | |
| 1334 | + flex-basis: 100%; | |
| 1335 | + max-width: 100%; | |
| 1336 | + white-space: normal; | |
| 1337 | + overflow: visible; | |
| 1338 | + overflow-wrap: anywhere; | |
| 1339 | +} | |
| 1340 | +.repo-avatar { | |
| 1341 | + transition: width 0.2s ease, height 0.2s ease, font-size 0.2s ease; | |
| 1342 | +} | |
| 1296 | 1343 | |
| 1297 | 1344 | |
| 1298 | 1345 |
internal/i18n/i18n.go
+2
-0
| ... | ... | @@ -25,6 +25,7 @@ var translations = map[Lang]map[string]string{ |
| 25 | 25 | "nav.explore": "Обзор", |
| 26 | 26 | "nav.settings": "Настройки", |
| 27 | 27 | "nav.admin": "Админка", |
| 28 | + "nav.agents": "Для AI-агентов", | |
| 28 | 29 | "nav.login": "Войти", |
| 29 | 30 | "nav.register": "Регистрация", |
| 30 | 31 | "nav.logout": "Выйти", |
| ... | ... | @@ -531,6 +532,7 @@ var translations = map[Lang]map[string]string{ |
| 531 | 532 | "nav.explore": "Explore", |
| 532 | 533 | "nav.settings": "Settings", |
| 533 | 534 | "nav.admin": "Admin", |
| 535 | + "nav.agents": "For AI agents", | |
| 534 | 536 | "nav.login": "Sign in", |
| 535 | 537 | "nav.register": "Sign up", |
| 536 | 538 | "nav.logout": "Sign out", |
internal/web/handlers/agents.go
+13
-0
| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "github.com/gofiber/fiber/v2" | |
| 5 | +) | |
| 6 | + | |
| 7 | +// AgentsPage renders the public guide for AI agents working with this instance. | |
| 8 | +// GET /agents | |
| 9 | +func (h *Handler) AgentsPage(c *fiber.Ctx) error { | |
| 10 | + data := h.baseData(c, "AI agents · Polar") | |
| 11 | + data["SiteURL"] = h.siteURL(c) | |
| 12 | + return h.render(c, fiber.StatusOK, "agents", data) | |
| 13 | +} |
internal/web/handlers/agents_test.go
+30
-0
| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | +package handlers | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// TestAgentsPage checks that the public agent guide renders for anonymous visitors. | |
| 10 | +func TestAgentsPage(t *testing.T) { | |
| 11 | + app, _, _, _ := setupTestApp(t) | |
| 12 | + | |
| 13 | + status, body := getPage(t, app, "/agents") | |
| 14 | + if status != http.StatusOK { | |
| 15 | + t.Fatalf("GET /agents status %d", status) | |
| 16 | + } | |
| 17 | + for _, want := range []string{ | |
| 18 | + "Polar for AI agents", | |
| 19 | + "RFC 8628", | |
| 20 | + "/api/v1/device/code", | |
| 21 | + "/api/v1/device/token", | |
| 22 | + "polar_session", | |
| 23 | + "releases/new", | |
| 24 | + "git-credential-polar", | |
| 25 | + } { | |
| 26 | + if !strings.Contains(body, want) { | |
| 27 | + t.Errorf("/agents page body missing %q", want) | |
| 28 | + } | |
| 29 | + } | |
| 30 | +} |
internal/web/handlers/handlers.go
+1
-0
| ... | ... | @@ -74,6 +74,7 @@ func (h *Handler) RegisterRoutes(app *fiber.App) { |
| 74 | 74 | // Public & Root routes |
| 75 | 75 | app.Get("/", h.Home) |
| 76 | 76 | app.Get("/explore", h.Explore) |
| 77 | + app.Get("/agents", h.AgentsPage) | |
| 77 | 78 | |
| 78 | 79 | // Auth routes |
| 79 | 80 | app.Get("/login", h.LoginPage) |
internal/web/static/css/app.css
+47
-1
| ... | ... | @@ -373,6 +373,13 @@ html[data-sidebar="collapsed"] .sidebar-inner { |
| 373 | 373 | .nav-item:active { |
| 374 | 374 | transform: scale(0.985); |
| 375 | 375 | } |
| 376 | +.muted-link { | |
| 377 | + color: hsl(var(--muted-foreground)); | |
| 378 | + transition: color 140ms ease; | |
| 379 | +} | |
| 380 | +.muted-link:hover { | |
| 381 | + color: hsl(var(--foreground)); | |
| 382 | +} | |
| 376 | 383 | .nav-section-label { |
| 377 | 384 | display: flex; |
| 378 | 385 | align-items: center; |
| ... | ... | @@ -1274,7 +1281,7 @@ kbd, .kbd { |
| 1274 | 1281 | justify-content: space-between; |
| 1275 | 1282 | gap: 1rem; |
| 1276 | 1283 | flex-wrap: wrap; |
| 1277 | - padding: 0.9rem 1rem 0.95rem; | |
| 1284 | + padding: 0.65rem 1rem; | |
| 1278 | 1285 | background: hsl(var(--card)); |
| 1279 | 1286 | border: 1px solid hsl(var(--border)); |
| 1280 | 1287 | border-radius: calc(var(--radius) * 1.6); |
| ... | ... | @@ -1294,6 +1301,45 @@ kbd, .kbd { |
| 1294 | 1301 | gap: 0.4rem; |
| 1295 | 1302 | flex-wrap: wrap; |
| 1296 | 1303 | } |
| 1304 | +.repo-head-block { | |
| 1305 | + min-width: 0; | |
| 1306 | + flex: 1 1 auto; | |
| 1307 | +} | |
| 1308 | +.repo-head-row { | |
| 1309 | + display: flex; | |
| 1310 | + align-items: center; | |
| 1311 | + gap: 0.55rem; | |
| 1312 | + flex-wrap: wrap; | |
| 1313 | + min-width: 0; | |
| 1314 | +} | |
| 1315 | +.repo-desc { | |
| 1316 | + flex: 1 1 6rem; | |
| 1317 | + min-width: 0; | |
| 1318 | + max-width: 36rem; | |
| 1319 | + overflow: hidden; | |
| 1320 | + text-overflow: ellipsis; | |
| 1321 | + white-space: nowrap; | |
| 1322 | + font-size: 0.8rem; | |
| 1323 | + line-height: 1.35; | |
| 1324 | + color: hsl(var(--muted-foreground)); | |
| 1325 | + transition: color 0.15s ease; | |
| 1326 | +} | |
| 1327 | +.repo-desc.truncated { | |
| 1328 | + cursor: pointer; | |
| 1329 | +} | |
| 1330 | +.repo-desc.truncated:hover { | |
| 1331 | + color: hsl(var(--foreground)); | |
| 1332 | +} | |
| 1333 | +.repo-banner.desc-open .repo-desc { | |
| 1334 | + flex-basis: 100%; | |
| 1335 | + max-width: 100%; | |
| 1336 | + white-space: normal; | |
| 1337 | + overflow: visible; | |
| 1338 | + overflow-wrap: anywhere; | |
| 1339 | +} | |
| 1340 | +.repo-avatar { | |
| 1341 | + transition: width 0.2s ease, height 0.2s ease, font-size 0.2s ease; | |
| 1342 | +} | |
| 1297 | 1343 | |
| 1298 | 1344 | |
| 1299 | 1345 |
internal/web/static/js/app.js
+53
-1
| ... | ... | @@ -463,5 +463,57 @@ |
| 463 | 463 | rmBtn.style.display = 'none'; |
| 464 | 464 | } |
| 465 | 465 | }); |
| 466 | -})(); | |
| 467 | 466 | |
| 467 | + // Repo header: the description stays on the title line and expands onto its | |
| 468 | + // own line when clicked. The avatar grows to match the name + description | |
| 469 | + // block instead of staying centered. | |
| 470 | + var repoAvatarTimer = null; | |
| 471 | + | |
| 472 | + function sizeRepoAvatars() { | |
| 473 | + var changed = false; | |
| 474 | + document.querySelectorAll('.repo-banner').forEach(function (banner) { | |
| 475 | + var avatar = banner.querySelector('.repo-avatar'); | |
| 476 | + var block = banner.querySelector('.repo-head-block'); | |
| 477 | + if (!avatar || !block) return; | |
| 478 | + var h = Math.round(block.getBoundingClientRect().height); | |
| 479 | + if (h <= 0) return; | |
| 480 | + var size = h + 'px'; | |
| 481 | + if (avatar.style.width === size) return; | |
| 482 | + avatar.style.width = size; | |
| 483 | + avatar.style.height = size; | |
| 484 | + avatar.style.fontSize = Math.min(22, Math.max(9, Math.round(h * 0.4))) + 'px'; | |
| 485 | + changed = true; | |
| 486 | + }); | |
| 487 | + return changed; | |
| 488 | + } | |
| 489 | + | |
| 490 | + function updateRepoDescs() { | |
| 491 | + document.querySelectorAll('[data-repo-desc]').forEach(function (el) { | |
| 492 | + var banner = el.closest('.repo-banner'); | |
| 493 | + if (banner && banner.classList.contains('desc-open')) { | |
| 494 | + el.classList.add('truncated'); | |
| 495 | + return; | |
| 496 | + } | |
| 497 | + el.classList.toggle('truncated', el.scrollWidth > el.clientWidth + 1); | |
| 498 | + }); | |
| 499 | + if (sizeRepoAvatars()) { | |
| 500 | + // Re-measure once the width transition settles: the final avatar width | |
| 501 | + // changes the room left for the description. | |
| 502 | + window.clearTimeout(repoAvatarTimer); | |
| 503 | + repoAvatarTimer = window.setTimeout(sizeRepoAvatars, 300); | |
| 504 | + } | |
| 505 | + } | |
| 506 | + | |
| 507 | + document.addEventListener('click', function (e) { | |
| 508 | + var desc = e.target.closest('[data-repo-desc]'); | |
| 509 | + if (!desc || !desc.classList.contains('truncated')) return; | |
| 510 | + var banner = desc.closest('.repo-banner'); | |
| 511 | + if (!banner) return; | |
| 512 | + banner.classList.toggle('desc-open'); | |
| 513 | + updateRepoDescs(); | |
| 514 | + }); | |
| 515 | + | |
| 516 | + window.addEventListener('resize', updateRepoDescs); | |
| 517 | + document.addEventListener('htmx:afterSwap', updateRepoDescs); | |
| 518 | + updateRepoDescs(); | |
| 519 | +})(); |
internal/web/views/layouts/base.html
+9
-0
| ... | ... | @@ -124,6 +124,12 @@ |
| 124 | 124 | {{end}} |
| 125 | 125 | </div> |
| 126 | 126 | {{end}} |
| 127 | + <div style="padding:0 0.35rem;"> | |
| 128 | + <a href="/agents" class="muted-link flex items-center gap-1.5 text-xs" style="padding:0.1rem 0;"> | |
| 129 | + {{icon "book-open" "size-3.5"}} | |
| 130 | + <span>{{t .Lang "nav.agents"}}</span> | |
| 131 | + </a> | |
| 132 | + </div> | |
| 127 | 133 | </div> |
| 128 | 134 | </div> |
| 129 | 135 | </aside> |
| ... | ... | @@ -224,6 +230,9 @@ |
| 224 | 230 | <span class="flex items-center gap-1"><kbd class="kbd">g</kbd><kbd class="kbd">r</kbd></span> |
| 225 | 231 | </button> |
| 226 | 232 | {{end}} |
| 233 | + <button type="button" class="palette-item" data-palette-item data-palette-href="/agents" data-palette-keywords="agents ai api guide агенты документация"> | |
| 234 | + <span class="flex items-center gap-2">{{icon "book-open" "size-4"}} {{t .Lang "nav.agents"}}</span> | |
| 235 | + </button> | |
| 227 | 236 | {{if .CurrentUser}} |
| 228 | 237 | <button type="button" class="palette-item" data-palette-item data-palette-href="/new" data-palette-keywords="new create создать репозиторий"> |
| 229 | 238 | <span class="flex items-center gap-2">{{icon "plus" "size-4"}} {{t .Lang "repo.new.title"}}</span> |
internal/web/views/pages/agents.html
+106
-0
| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container" style="max-width:880px;"> | |
| 3 | + <div class="flex items-start justify-between flex-wrap gap-2 mb-4"> | |
| 4 | + <div> | |
| 5 | + <h1 class="text-xl font-bold tracking-tight" style="margin:0;">Polar for AI agents</h1> | |
| 6 | + <div class="text-xs text-muted mt-1">A practical guide to cloning, pushing, publishing and automating this Git host.</div> | |
| 7 | + </div> | |
| 8 | + <span class="badge badge-outline font-mono text-xs">GET /agents</span> | |
| 9 | + </div> | |
| 10 | + | |
| 11 | + <article class="card"> | |
| 12 | + <div class="card-body markdown-body"> | |
| 13 | + <h2>Overview</h2> | |
| 14 | + <p>Polar is a self-hosted Git forge at <code>{{.SiteURL}}</code>. It serves standard <strong>Git over HTTPS</strong> (smart HTTP) plus a web UI for issues, pull requests, releases and repository settings. Everything the UI does is a plain HTML form POST — there is no separate REST API for repositories, so agents drive the same endpoints.</p> | |
| 15 | + | |
| 16 | + <h2>Authentication: device flow</h2> | |
| 17 | + <p>Git over HTTPS uses the <strong>OAuth 2.0 Device Authorization Grant (RFC 8628)</strong>: the client asks for a short user code, the human approves it in the browser once, and the client receives a personal access token.</p> | |
| 18 | + <ol> | |
| 19 | + <li>Request a device code from <code>POST /api/v1/device/code</code> with a <code>client_id</code>.</li> | |
| 20 | + <li>Send the user to <code>verification_uri_complete</code> (they must be signed in to Polar).</li> | |
| 21 | + <li>Poll <code>POST /api/v1/device/token</code> with <code>device_code</code> every <code>interval</code> seconds (default 5). While the grant is pending the endpoint answers <code>429 {"error":"authorization_pending"}</code>.</li> | |
| 22 | + <li>On approval you receive <code>{"access_token":"...","token_type":"bearer","username":"...","scope":"git"}</code>.</li> | |
| 23 | + </ol> | |
| 24 | + <pre><code># 1. Start a device grant (no browser session required for this step) | |
| 25 | +curl -sX POST {{.SiteURL}}/api/v1/device/code -d 'client_id=my-agent' | |
| 26 | +# {"device_code":"...","user_code":"WDJB-MJHT","verification_uri":"{{.SiteURL}}/login/device", | |
| 27 | +# "verification_uri_complete":"{{.SiteURL}}/login/device?user_code=WDJB-MJHT","expires_in":600,"interval":5} | |
| 28 | + | |
| 29 | +# 2. Ask the user to open verification_uri_complete and approve. | |
| 30 | + | |
| 31 | +# 3. Exchange the approved device code for a token | |
| 32 | +curl -sX POST {{.SiteURL}}/api/v1/device/token -d 'device_code=DEVICE_CODE' | |
| 33 | +# {"access_token":"...","token_type":"bearer","username":"rin","scope":"git"}</code></pre> | |
| 34 | + <p>Use the token as the password for HTTPS Git with any username:</p> | |
| 35 | + <pre><code>git clone https://USERNAME:TOKEN@HOST/OWNER/REPO.git</code></pre> | |
| 36 | + <p>This is the same handoff a fresh <code>git push</code> triggers: Polar opens the approval page and the credential helper waits for it. For scripts, <code>scripts/git-credential-polar.sh</code> automates the flow and caches tokens in <code>~/.polar/credentials.json</code>:</p> | |
| 37 | + <pre><code>git config --global credential.helper '!sh /path/to/polar/scripts/git-credential-polar.sh'</code></pre> | |
| 38 | + <p>Tokens are listed and revocable under <em>Settings → Access tokens</em>. Device codes are single-use and expire after <code>expires_in</code> seconds.</p> | |
| 39 | + | |
| 40 | + <h2>Git operations</h2> | |
| 41 | + <ul> | |
| 42 | + <li><strong>Clone / fetch / push:</strong> <code>https://HOST/OWNER/REPO.git</code> — standard smart HTTP.</li> | |
| 43 | + <li><strong>Branches and tags:</strong> push them normally; Polar stores refs and shows them in the UI.</li> | |
| 44 | + <li><strong>Public repositories:</strong> readable anonymously when anonymous read is enabled on the instance.</li> | |
| 45 | + <li><strong>Private repositories:</strong> every Git request needs credentials — unauthenticated requests get <code>401</code>, while the web UI answers <code>404</code> so it does not leak existence.</li> | |
| 46 | + <li><strong>Empty repositories:</strong> the first <code>git push -u origin main</code> creates the default branch.</li> | |
| 47 | + </ul> | |
| 48 | + | |
| 49 | + <h2>Driving the web UI from a script</h2> | |
| 50 | + <p>The UI is server-rendered HTML with plain forms, which makes it fully scriptable:</p> | |
| 51 | + <ul> | |
| 52 | + <li>Sign in by POSTing <code>/login</code> (<code>username</code>, <code>password</code>) and keeping the <code>polar_session</code> cookie.</li> | |
| 53 | + <li>Every non-GET request needs the session CSRF token: read the hidden <code>_csrf</code> input from any page and send it as the <code>_csrf</code> form field or the <code>X-CSRF-Token</code> header.</li> | |
| 54 | + <li>File uploads are <code>multipart/form-data</code>.</li> | |
| 55 | + </ul> | |
| 56 | + <pre><code># Close an issue (state: open|closed) | |
| 57 | +curl -b cookies.txt -X POST https://HOST/OWNER/REPO/issues/1/state \ | |
| 58 | + -H "X-CSRF-Token: $CSRF" -d 'state=closed' | |
| 59 | + | |
| 60 | +# Comment on an issue | |
| 61 | +curl -b cookies.txt -X POST https://HOST/OWNER/REPO/issues/1/comments \ | |
| 62 | + -H "X-CSRF-Token: $CSRF" -d 'body=Fixed in the latest build.' | |
| 63 | + | |
| 64 | +# Open a pull request (head_branch must already be pushed) | |
| 65 | +curl -b cookies.txt -X POST https://HOST/OWNER/REPO/pulls/new \ | |
| 66 | + -H "X-CSRF-Token: $CSRF" \ | |
| 67 | + -d 'title=Rework uploads' -d 'body=Release notes...' \ | |
| 68 | + -d 'base_branch=main' -d 'head_branch=feature/uploads' | |
| 69 | + | |
| 70 | +# Review and merge a pull request | |
| 71 | +curl -b cookies.txt -X POST https://HOST/OWNER/REPO/pulls/4/review \ | |
| 72 | + -H "X-CSRF-Token: $CSRF" -d 'state=approved' -d 'body=LGTM' | |
| 73 | +curl -b cookies.txt -X POST https://HOST/OWNER/REPO/pulls/4/merge \ | |
| 74 | + -H "X-CSRF-Token: $CSRF"</code></pre> | |
| 75 | + <p>Other form endpoints: <code>POST /OWNER/REPO/issues/new</code> (<code>title</code>, <code>body</code>), <code>POST /OWNER/REPO/upload</code> (<code>files</code>, <code>ref</code>, <code>path</code>, <code>message</code>), <code>POST /OWNER/REPO/settings/collaborators/add</code>, <code>POST /OWNER/REPO/fork</code>.</p> | |
| 76 | + | |
| 77 | + <h2>Releases and artifacts</h2> | |
| 78 | + <p>Releases group a git tag, notes and uploaded files. Publishing one is a single multipart POST:</p> | |
| 79 | + <pre><code>curl -b cookies.txt -X POST https://HOST/OWNER/REPO/releases/new \ | |
| 80 | + -H "X-CSRF-Token: $CSRF" \ | |
| 81 | + -F 'tag_name=v1.2.0' -F 'target_ref=main' \ | |
| 82 | + -F 'title=v1.2.0' -F 'body=Release notes...' -F 'is_prerelease=true' \ | |
| 83 | + -F 'assets=@dist/app-linux-amd64' -F 'assets=@dist/app-windows-amd64.exe'</code></pre> | |
| 84 | + <ul> | |
| 85 | + <li>The tag is created automatically if missing, pointing at <code>target_ref</code>.</li> | |
| 86 | + <li>Up to 20 assets per release, 100 MB per file. Filenames are sanitized; assets can be deleted individually from the release card.</li> | |
| 87 | + <li>Asset downloads support HTTP <code>Range</code>: <code>GET /OWNER/REPO/releases/<id>/assets/<assetID>/download</code>.</li> | |
| 88 | + <li>Tag archives are always available: <code>GET /OWNER/REPO/archive/v1.2.0.zip</code> or <code>.tar.gz</code>.</li> | |
| 89 | + <li>Raw file contents: <code>GET /OWNER/REPO/raw/<ref>/<path></code>.</li> | |
| 90 | + </ul> | |
| 91 | + | |
| 92 | + <h2>Working agreements</h2> | |
| 93 | + <ul> | |
| 94 | + <li>Prefer branches and pull requests over pushing straight to the default branch in shared repositories.</li> | |
| 95 | + <li>Match the repository's commit message style; explain the change, not the process.</li> | |
| 96 | + <li>Use tags for release points and attach artifacts with stable names (for example <code>polar-linux-amd64</code>).</li> | |
| 97 | + <li>Treat tokens as secrets: never commit them, and revoke what you no longer need.</li> | |
| 98 | + <li>Poll the device token endpoint no faster than the advertised <code>interval</code>; back off on <code>429</code>.</li> | |
| 99 | + </ul> | |
| 100 | + | |
| 101 | + <hr> | |
| 102 | + <p class="text-xs text-muted">Static documentation. Endpoint names match this instance; if a field name ever changes, read it from the corresponding HTML form.</p> | |
| 103 | + </div> | |
| 104 | + </article> | |
| 105 | +</div> | |
| 106 | +{{end}} |
internal/web/views/pages/release_new.html
+35
-4
| ... | ... | @@ -45,10 +45,15 @@ |
| 45 | 45 | <textarea id="body" name="body" class="textarea" rows="8" placeholder="{{t .Lang "releases.body_ph"}}" maxlength="50000"></textarea> |
| 46 | 46 | </div> |
| 47 | 47 | |
| 48 | - <div> | |
| 49 | - <label class="label" for="assets">{{t .Lang "releases.assets_upload"}}</label> | |
| 50 | - <input id="assets" name="assets" type="file" multiple class="input" style="padding:0.4rem 0.5rem;height:auto;"> | |
| 51 | - <div class="text-xs text-muted mt-1">{{t .Lang "releases.assets_upload_hint"}}</div> | |
| 48 | + <div class="flex flex-col gap-1"> | |
| 49 | + <label class="label" for="assets" style="margin-bottom:0;">{{t .Lang "releases.assets_upload"}}</label> | |
| 50 | + <label class="upload-drop" id="assets-drop" for="assets"> | |
| 51 | + {{icon "upload" "size-5"}} | |
| 52 | + <span class="text-sm font-medium">{{t .Lang "file.upload_pick"}}</span> | |
| 53 | + <span class="text-xs">{{t .Lang "releases.assets_upload_hint"}}</span> | |
| 54 | + <input id="assets" name="assets" type="file" multiple style="display:none;"> | |
| 55 | + </label> | |
| 56 | + <div id="assets-list" class="text-xs font-mono text-muted"></div> | |
| 52 | 57 | </div> |
| 53 | 58 | |
| 54 | 59 | <div> |
| ... | ... | @@ -67,4 +72,30 @@ |
| 67 | 72 | </form> |
| 68 | 73 | </section> |
| 69 | 74 | </div> |
| 75 | +<script> | |
| 76 | +(function () { | |
| 77 | + var input = document.getElementById('assets'); | |
| 78 | + var drop = document.getElementById('assets-drop'); | |
| 79 | + var list = document.getElementById('assets-list'); | |
| 80 | + if (!input || !drop || !list) return; | |
| 81 | + function render() { | |
| 82 | + var names = []; | |
| 83 | + for (var i = 0; i < input.files.length; i++) names.push(input.files[i].name); | |
| 84 | + list.textContent = names.length ? names.join(' · ') : ''; | |
| 85 | + } | |
| 86 | + input.addEventListener('change', render); | |
| 87 | + ['dragover', 'dragenter'].forEach(function (ev) { | |
| 88 | + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('drag'); }); | |
| 89 | + }); | |
| 90 | + ['dragleave', 'drop'].forEach(function (ev) { | |
| 91 | + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('drag'); }); | |
| 92 | + }); | |
| 93 | + drop.addEventListener('drop', function (e) { | |
| 94 | + if (e.dataTransfer && e.dataTransfer.files.length) { | |
| 95 | + input.files = e.dataTransfer.files; | |
| 96 | + render(); | |
| 97 | + } | |
| 98 | + }); | |
| 99 | +})(); | |
| 100 | +</script> | |
| 70 | 101 | {{end}} |
internal/web/views/partials/components.html
+11
-10
| ... | ... | @@ -43,11 +43,11 @@ |
| 43 | 43 | {{end}} |
| 44 | 44 | |
| 45 | 45 | {{define "repo_header"}} |
| 46 | - <div class="repo-banner mb-3"> | |
| 46 | + <div class="repo-banner mb-3{{if .Repo.ForkedFromRepoID}} has-subrow{{end}}"> | |
| 47 | 47 | <div class="repo-banner-main"> |
| 48 | - <a href="/{{.Repo.OwnerUsername}}" class="flex-shrink-0">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL}}</a> | |
| 49 | - <div class="min-w-0"> | |
| 50 | - <div class="flex items-center gap-2 flex-wrap"> | |
| 48 | + <a href="/{{.Repo.OwnerUsername}}" class="flex-shrink-0">{{avatar .Repo.OwnerUsername .Repo.OwnerAvatarURL "repo-avatar"}}</a> | |
| 49 | + <div class="min-w-0 repo-head-block"> | |
| 50 | + <div class="repo-head-row"> | |
| 51 | 51 | <nav class="breadcrumb" aria-label="Repository breadcrumb"> |
| 52 | 52 | <a href="/{{.Repo.OwnerUsername}}" class="text-sm font-medium text-muted">{{.Repo.OwnerUsername}}</a> |
| 53 | 53 | <span class="breadcrumb-sep">/</span> |
| ... | ... | @@ -58,18 +58,19 @@ |
| 58 | 58 | {{else}} |
| 59 | 59 | <span class="badge badge-outline">{{icon "globe" "size-3.5"}} {{t .Lang "repo.public"}}</span> |
| 60 | 60 | {{end}} |
| 61 | + {{if .Repo.Description}} | |
| 62 | + <span class="repo-desc" data-repo-desc>{{.Repo.Description}}</span> | |
| 63 | + {{end}} | |
| 61 | 64 | </div> |
| 62 | - <div class="text-xs text-muted mt-1 flex items-center gap-2 flex-wrap"> | |
| 63 | - {{if .Repo.ForkedFromRepoID}} | |
| 65 | + {{if .Repo.ForkedFromRepoID}} | |
| 66 | + <div class="text-xs text-muted mt-1 flex items-center gap-2 flex-wrap"> | |
| 64 | 67 | <span class="flex items-center gap-1.5"> |
| 65 | 68 | {{icon "git-branch" "size-3.5"}} |
| 66 | 69 | {{t .Lang "fork.forked_from"}} |
| 67 | 70 | <a class="font-medium" style="color:hsl(var(--foreground));" href="/{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}">{{.Repo.ForkedFromOwner}}/{{.Repo.ForkedFromName}}</a> |
| 68 | 71 | </span> |
| 69 | - {{else if .Repo.Description}} | |
| 70 | - <span class="truncate" style="max-width:36rem;">{{.Repo.Description}}</span> | |
| 71 | - {{end}} | |
| 72 | - </div> | |
| 72 | + </div> | |
| 73 | + {{end}} | |
| 73 | 74 | </div> |
| 74 | 75 | </div> |
| 75 | 76 |