This commit is contained in:
delete 2026-06-10 20:51:17 +03:00
commit b15b95781c
108 changed files with 14802 additions and 0 deletions

584
web/static/app.css Normal file
View file

@ -0,0 +1,584 @@
:root {
color-scheme: light;
--bg: #f5f1e8;
--card: rgba(255, 255, 255, 0.88);
--card-border: rgba(55, 46, 36, 0.16);
--text: #2a241d;
--muted: #64584b;
--accent: #0f766e;
--accent-soft: #d8f3ef;
--danger: #9a3412;
--danger-soft: #ffefe6;
--success-soft: #edf7f2;
--shadow: 0 18px 45px rgba(49, 41, 31, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
color: var(--text);
background:
radial-gradient(circle at top right, rgba(15, 118, 110, 0.18), transparent 25rem),
linear-gradient(180deg, #efe7d5 0%, var(--bg) 100%);
}
a {
color: inherit;
}
.page-shell {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 1.25rem 0 3rem;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 0 1.5rem;
}
.brand {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.08em;
text-decoration: none;
text-transform: uppercase;
}
.site-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.9rem;
}
.site-nav a {
text-decoration: none;
color: var(--muted);
}
.nav-user {
color: var(--text);
font-size: 0.95rem;
}
.nav-form {
margin: 0;
}
.nav-button {
border: 0;
background: transparent;
color: var(--accent);
font: inherit;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.content-card {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 1.5rem;
padding: 2rem;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.hero {
max-width: 46rem;
}
.hero-split {
max-width: none;
display: flex;
justify-content: space-between;
gap: 1.25rem;
align-items: flex-start;
}
.hero-meta {
display: grid;
justify-items: end;
gap: 0.75rem;
}
.eyebrow {
margin: 0 0 0.75rem;
color: var(--accent);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2.2rem, 4vw, 4.2rem);
line-height: 0.96;
}
.lede {
margin: 1rem 0 0;
max-width: 44rem;
font-size: 1.1rem;
line-height: 1.7;
color: var(--muted);
}
.link-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.link-card,
.callout {
border-radius: 1rem;
border: 1px solid rgba(15, 118, 110, 0.16);
background: linear-gradient(180deg, rgba(216, 243, 239, 0.8), rgba(255, 255, 255, 0.92));
padding: 1rem 1.1rem;
}
.link-card h2 {
margin: 0 0 0.6rem;
font-size: 1.15rem;
}
.link-card p,
.callout p {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.callout h2 {
margin: 0 0 0.7rem;
font-size: 1.05rem;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.metric-grid,
.resource-grid,
.section-grid {
display: grid;
gap: 1rem;
margin-top: 2rem;
}
.metric-grid {
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.resource-grid,
.section-grid {
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
.compact-grid {
margin-top: 1rem;
}
.metric-card,
.resource-card,
.section-card {
border-radius: 1.1rem;
border: 1px solid rgba(55, 46, 36, 0.14);
background: rgba(255, 255, 255, 0.96);
padding: 1.15rem;
}
.section-card-wide {
margin-top: 2rem;
}
.metric-value {
margin: 0;
color: var(--accent);
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1;
font-weight: 700;
}
.metric-card h2,
.resource-card h2,
.section-card h2,
.release-card h3 {
margin: 0.35rem 0 0.45rem;
}
.metric-card p:last-child,
.resource-card p:last-child,
.section-card p:last-child {
margin-bottom: 0;
}
.toolbar {
margin-top: 1.5rem;
}
.resource-header,
.release-head,
.section-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.section-heading {
margin-bottom: 1rem;
}
.section-heading h2,
.resource-header h2,
.release-head h3 {
margin-top: 0;
}
.resource-slug {
margin: 0;
color: var(--accent);
font-size: 0.92rem;
font-weight: 700;
}
.resource-description,
.empty-copy,
.helper-copy,
.release-notes {
color: var(--muted);
line-height: 1.6;
}
.stat-row,
.button-row,
.chip-list,
.release-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.stat-row {
margin: 1rem 0;
color: var(--muted);
font-size: 0.95rem;
}
.button-row {
margin-top: 0.35rem;
}
.endpoint-list {
display: grid;
gap: 0.75rem;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2rem;
padding: 0.2rem 0.75rem;
border-radius: 999px;
background: rgba(55, 46, 36, 0.08);
color: var(--text);
font-size: 0.85rem;
font-weight: 700;
}
.pill-success {
background: var(--success-soft);
color: #0b5a54;
}
.pill-soft {
background: var(--accent-soft);
color: #0b5a54;
}
.pill-muted {
background: rgba(55, 46, 36, 0.08);
color: var(--muted);
}
.meta-line + .meta-line {
margin-top: 0.45rem;
}
.action-row {
margin-top: 1.5rem;
}
.form-shell {
max-width: 28rem;
margin-top: 2rem;
}
.form-shell-wide {
max-width: 40rem;
}
.stack-form {
display: grid;
gap: 1rem;
}
.inline-form {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
.field {
display: grid;
gap: 0.45rem;
color: var(--text);
font-weight: 700;
}
.field span {
font-size: 0.95rem;
}
.field input {
width: 100%;
border: 1px solid rgba(55, 46, 36, 0.18);
border-radius: 0.95rem;
padding: 0.9rem 1rem;
font: inherit;
color: var(--text);
background: rgba(255, 255, 255, 0.96);
}
.field select,
.field textarea {
width: 100%;
border: 1px solid rgba(55, 46, 36, 0.18);
border-radius: 0.95rem;
padding: 0.9rem 1rem;
font: inherit;
color: var(--text);
background: rgba(255, 255, 255, 0.96);
resize: vertical;
}
.field input:focus {
outline: 2px solid rgba(15, 118, 110, 0.22);
outline-offset: 2px;
border-color: rgba(15, 118, 110, 0.45);
}
.field textarea:focus,
.field select:focus {
outline: 2px solid rgba(15, 118, 110, 0.22);
outline-offset: 2px;
border-color: rgba(15, 118, 110, 0.45);
}
.choice-group {
margin: 0;
padding: 1rem;
border: 1px solid rgba(55, 46, 36, 0.12);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.78);
}
.choice-group legend {
padding: 0 0.35rem;
font-weight: 700;
}
.choice-list {
display: grid;
gap: 0.75rem;
}
.choice-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
color: var(--muted);
line-height: 1.5;
}
.choice-row input {
margin-top: 0.2rem;
}
.secret-code {
display: inline-block;
margin: 0.35rem 0;
padding: 0.55rem 0.7rem;
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.96);
word-break: break-all;
}
.code-block {
margin: 1rem 0 0;
padding: 1rem 1.1rem;
border-radius: 1rem;
border: 1px solid rgba(55, 46, 36, 0.12);
background: rgba(42, 36, 29, 0.96);
color: #f7f2ea;
overflow-x: auto;
}
.code-block code {
color: inherit;
white-space: pre;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 3rem;
padding: 0.8rem 1.2rem;
border: 0;
border-radius: 999px;
font: inherit;
font-weight: 700;
color: white;
background: linear-gradient(135deg, #0f766e, #0b5a54);
cursor: pointer;
text-decoration: none;
}
.button:hover {
filter: brightness(1.03);
}
.button-secondary {
color: var(--text);
background: rgba(55, 46, 36, 0.08);
}
.button-danger {
background: linear-gradient(135deg, var(--danger), #7c2d12);
}
.alert-card {
margin-top: 1.5rem;
border-radius: 1rem;
border: 1px solid rgba(15, 118, 110, 0.18);
background: rgba(255, 255, 255, 0.9);
padding: 1rem 1.1rem;
}
.alert-card p {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.alert-error {
border-color: rgba(162, 50, 28, 0.28);
background: rgba(255, 243, 239, 0.95);
}
.alert-success {
background: rgba(237, 247, 242, 0.95);
}
.compact-alert {
margin-top: 0;
}
.text-link {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.chip-form {
display: inline-flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0.55rem 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid rgba(15, 118, 110, 0.16);
background: rgba(216, 243, 239, 0.7);
}
.chip-label {
font-weight: 700;
}
.chip-remove {
border: 0;
background: transparent;
color: var(--danger);
font: inherit;
cursor: pointer;
}
.release-card {
flex: 1 1 20rem;
border-radius: 1rem;
border: 1px solid rgba(55, 46, 36, 0.14);
background: rgba(255, 255, 255, 0.96);
padding: 1rem;
}
.release-build {
color: var(--muted);
font-size: 0.95rem;
}
.empty-state {
margin-top: 2rem;
border-radius: 1rem;
border: 1px dashed rgba(55, 46, 36, 0.2);
padding: 1.5rem;
background: rgba(255, 255, 255, 0.72);
}
code {
font-family: "SFMono-Regular", "SF Mono", "Menlo", monospace;
font-size: 0.92em;
}
@media (max-width: 640px) {
.page-shell {
width: min(100% - 1rem, 72rem);
}
.content-card {
padding: 1.35rem;
}
.site-header {
align-items: flex-start;
flex-direction: column;
}
.hero-split,
.hero-meta,
.resource-header,
.release-head,
.section-heading {
display: grid;
}
.hero-meta {
justify-items: start;
}
}

View file

@ -0,0 +1,46 @@
{{define "base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} | {{end}}Update Server</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="page-shell">
<header class="site-header">
<a class="brand" href="/">Update Server</a>
<nav class="site-nav">
<a href="/">Home</a>
<a href="/admin">Dashboard</a>
{{if .CurrentUser}}
<a href="/admin/projects">Projects</a>
<a href="/admin/tags">Tags</a>
<a href="/admin/api-keys">API Keys</a>
{{else}}
<a href="/admin/login">Login</a>
{{end}}
<a href="/api/v1">API</a>
<a href="/healthz">Health</a>
{{if .CurrentUser}}
<span class="nav-user">{{.CurrentUser.Email}}</span>
<form class="nav-form" method="post" action="/admin/logout">
{{template "csrf_field" .}}
<button class="nav-button" type="submit">Log out</button>
</form>
{{end}}
</nav>
</header>
<main class="content-card">
{{if .Flash}}
<section class="alert-card {{if eq .Flash.Kind "error"}}alert-error{{else}}alert-success{{end}}">
<p>{{.Flash.Message}}</p>
</section>
{{end}}
{{template "content" .}}
</main>
</div>
</body>
</html>
{{end}}

View file

@ -0,0 +1,39 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
<section class="metric-grid">
{{range .Metrics}}
<article class="metric-card">
<p class="metric-value">{{.Value}}</p>
<h2>{{.Label}}</h2>
<p>{{.Description}}</p>
</article>
{{end}}
</section>
<section class="admin-grid">
<article class="callout">
<h2>Authenticated User</h2>
<p class="meta-line"><strong>Email:</strong> {{if .CurrentUser}}{{.CurrentUser.Email}}{{else}}Unavailable{{end}}</p>
<p class="meta-line"><strong>Role:</strong> {{if .CurrentUser}}{{.CurrentUser.Role}}{{else}}Unknown{{end}}</p>
</article>
<article class="callout">
<h2>Session And Storage</h2>
<p>Protected admin routes continue to use the existing session context. Release uploads now persist files under the private artifact directory rather than the public static web root.</p>
</article>
</section>
<section class="link-grid">
{{range .Links}}
<article class="link-card">
<h2><a href="{{.Href}}">{{.Label}}</a></h2>
<p>{{.Description}}</p>
</article>
{{end}}
</section>
{{end}}

View file

@ -0,0 +1,200 @@
{{define "content"}}
<section class="hero hero-split">
<div>
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</div>
{{if .APIKey}}
<div class="hero-meta">
<span class="pill {{if .APIKey.IsActive}}pill-success{{else}}pill-muted{{end}}">
{{if .APIKey.IsActive}}Active{{else}}Revoked{{end}}
</span>
<p class="resource-slug">{{.APIKey.KeyPrefix}}</p>
</div>
{{end}}
</section>
{{if .Form.RevealKey}}
<section class="alert-card alert-success">
<p><strong>Copy this API key now:</strong> <code class="secret-code">{{.Form.RevealKey}}</code></p>
<p>This is the only time the full raw key will be shown. Only the hash and short prefix are stored.</p>
</section>
{{end}}
{{if .Form.Error}}
<section class="alert-card alert-error">
<p>{{.Form.Error}}</p>
</section>
{{end}}
<section class="section-grid">
<article class="section-card">
<div class="section-heading">
<h2>Key Details</h2>
<p>Choose a stable label, minimum permissions, and one scope mode for this key.</p>
</div>
<form class="stack-form" method="post" action="{{.Form.Action}}">
{{template "csrf_field" .}}
<label class="field">
<span>Name</span>
<input type="text" name="name" value="{{.Form.Name}}" maxlength="120" required>
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="4">{{.Form.Description}}</textarea>
</label>
<label class="field">
<span>Scope mode</span>
<select name="scope_mode" required>
<option value="all_projects" {{if eq .Form.ScopeMode "all_projects"}}selected{{end}}>all_projects</option>
<option value="project_allow_list" {{if eq .Form.ScopeMode "project_allow_list"}}selected{{end}}>project_allow_list</option>
<option value="project_deny_list" {{if eq .Form.ScopeMode "project_deny_list"}}selected{{end}}>project_deny_list</option>
<option value="tag_allow_list" {{if eq .Form.ScopeMode "tag_allow_list"}}selected{{end}}>tag_allow_list</option>
<option value="tag_deny_list" {{if eq .Form.ScopeMode "tag_deny_list"}}selected{{end}}>tag_deny_list</option>
</select>
</label>
<label class="field">
<span>Expires at</span>
<input type="text" name="expires_at" value="{{.Form.ExpiresAt}}" placeholder="2026-12-31T23:59:59Z or 2026-12-31">
</label>
<fieldset class="choice-group">
<legend>Permissions</legend>
<label class="choice-row">
<input type="checkbox" name="can_download" value="1" {{if .Form.CanDownload}}checked{{end}}>
<span><strong>updates.read</strong> style access for listing or downloading releases.</span>
</label>
<label class="choice-row">
<input type="checkbox" name="can_upload" value="1" {{if .Form.CanUpload}}checked{{end}}>
<span>Upload release artifacts.</span>
</label>
<label class="choice-row">
<input type="checkbox" name="can_delete" value="1" {{if .Form.CanDelete}}checked{{end}}>
<span>Delete or disable release records later.</span>
</label>
<label class="choice-row">
<input type="checkbox" name="can_manage_projects" value="1" {{if .Form.CanManageProjects}}checked{{end}}>
<span>Create or edit projects.</span>
</label>
</fieldset>
<fieldset class="choice-group">
<legend>Project rules</legend>
<p class="helper-copy">Used only for <code>project_allow_list</code> and <code>project_deny_list</code>.</p>
{{if .ProjectChoices}}
<div class="choice-list">
{{range .ProjectChoices}}
<label class="choice-row">
<input type="checkbox" name="project_id" value="{{.Project.ID}}" {{if .Selected}}checked{{end}}>
<span>{{.Project.Name}} <code>{{.Project.Slug}}</code> {{if not .Project.IsActive}}(archived){{end}}</span>
</label>
{{end}}
</div>
{{else}}
<p class="empty-copy">No projects exist yet.</p>
{{end}}
</fieldset>
<fieldset class="choice-group">
<legend>Tag rules</legend>
<p class="helper-copy">Used only for <code>tag_allow_list</code> and <code>tag_deny_list</code>.</p>
{{if .TagChoices}}
<div class="choice-list">
{{range .TagChoices}}
<label class="choice-row">
<input type="checkbox" name="tag_id" value="{{.Tag.ID}}" {{if .Selected}}checked{{end}}>
<span>{{.Tag.Name}} <code>{{.Tag.Slug}}</code></span>
</label>
{{end}}
</div>
{{else}}
<p class="empty-copy">No tags exist yet.</p>
{{end}}
</fieldset>
<div class="button-row">
<button class="button" type="submit">{{.Form.SubmitLabel}}</button>
<a class="button button-secondary" href="/admin/api-keys">Back to API keys</a>
</div>
</form>
</article>
{{if .APIKey}}
<article class="section-card">
<div class="section-heading">
<h2>Lifecycle</h2>
<p>Disabled or expired keys are rejected before any project scope checks happen.</p>
</div>
<p class="meta-line"><strong>Created:</strong> {{.APIKey.CreatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<p class="meta-line"><strong>Updated:</strong> {{.APIKey.UpdatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<p class="meta-line"><strong>Last used:</strong> {{if .APIKey.LastUsedAt}}{{.APIKey.LastUsedAt.Format "2006-01-02 15:04 UTC"}}{{else}}Never{{end}}</p>
<p class="meta-line"><strong>Expires:</strong> {{if .APIKey.ExpiresAt}}{{.APIKey.ExpiresAt.Format "2006-01-02 15:04 UTC"}}{{else}}No expiry{{end}}</p>
<form class="stack-form" method="post" action="{{.ToggleAction}}">
{{template "csrf_field" .}}
<input type="hidden" name="state" value="{{.ToggleState}}">
<button class="button button-secondary" type="submit">{{.ToggleLabel}}</button>
</form>
</article>
{{end}}
</section>
{{if .APIKey}}
<section class="section-card section-card-wide">
<div class="section-heading">
<h2>Effective Access Preview</h2>
<p>This preview shows the active projects currently reachable after scope mode evaluation.</p>
</div>
{{if .AccessibleProjects}}
<div class="resource-grid compact-grid">
{{range .AccessibleProjects}}
<article class="resource-card">
<div class="resource-header">
<div>
<h3>{{.Name}}</h3>
<p class="resource-slug">{{.Slug}}</p>
</div>
<span class="pill pill-success">Active</span>
</div>
<p class="resource-description">{{if .Description}}{{.Description}}{{else}}No description yet.{{end}}</p>
</article>
{{end}}
</div>
{{else}}
<p class="empty-copy">No active projects currently match this key.</p>
{{end}}
</section>
<section class="section-card section-card-wide">
<div class="section-heading">
<h2>Client API Quick Start</h2>
<p>Clients should authenticate with a bearer API key and then discover projects before requesting metadata or downloading an artifact.</p>
</div>
<div class="endpoint-list">
<p class="meta-line"><strong>Bearer header:</strong> <code>Authorization: Bearer {{if .Form.RevealKey}}{{.Form.RevealKey}}{{else}}&lt;paste-api-key&gt;{{end}}</code></p>
<p class="meta-line"><strong>Accessible projects:</strong> <code>{{.BaseURL}}/api/v1/projects</code></p>
</div>
<pre class="code-block"><code>curl -H "Authorization: Bearer {{if .Form.RevealKey}}{{.Form.RevealKey}}{{else}}&lt;paste-api-key&gt;{{end}}" \
{{.BaseURL}}/api/v1/projects</code></pre>
{{if .AccessibleProjects}}
<div class="endpoint-list">
{{range .AccessibleProjects}}
<p class="meta-line"><strong>{{.Name}} latest metadata:</strong> <code>{{printf "%s/api/v1/projects/%s/releases/latest" $.BaseURL .Slug}}</code></p>
{{end}}
</div>
{{else}}
<p class="empty-copy">This key does not currently expose any active projects, so client metadata and download lookups will return no accessible resources.</p>
{{end}}
</section>
{{end}}
{{end}}

View file

@ -0,0 +1,49 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
<section class="toolbar">
<a class="button" href="/admin/api-keys/new">Create API key</a>
</section>
{{if .APIKeys}}
<section class="resource-grid">
{{range .APIKeys}}
<article class="resource-card">
<div class="resource-header">
<div>
<h2><a href="/admin/api-keys/{{.APIKey.ID}}">{{.APIKey.Name}}</a></h2>
<p class="resource-slug">{{.APIKey.KeyPrefix}}</p>
</div>
<span class="pill {{if .APIKey.IsActive}}pill-success{{else}}pill-muted{{end}}">
{{if .APIKey.IsActive}}Active{{else}}Revoked{{end}}
</span>
</div>
<p class="resource-description">{{if .APIKey.Description}}{{.APIKey.Description}}{{else}}No description yet.{{end}}</p>
<p class="meta-line"><strong>Scope:</strong> <code>{{.APIKey.ScopeMode}}</code></p>
<p class="meta-line"><strong>Rule rows:</strong> {{.ProjectRuleCount}} project, {{.TagRuleCount}} tag</p>
<p class="meta-line"><strong>Permissions:</strong>
{{if .APIKey.CanDownload}}download{{else}}no-download{{end}},
{{if .APIKey.CanUpload}}upload{{else}}no-upload{{end}},
{{if .APIKey.CanDelete}}delete{{else}}no-delete{{end}},
{{if .APIKey.CanManageProjects}}manage-projects{{else}}no-manage-projects{{end}}
</p>
<p class="meta-line"><strong>Updated:</strong> {{.APIKey.UpdatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<p class="meta-line"><strong>Last used:</strong> {{if .APIKey.LastUsedAt}}{{.APIKey.LastUsedAt.Format "2006-01-02 15:04 UTC"}}{{else}}Never{{end}}</p>
<p class="meta-line"><strong>Expires:</strong> {{if .APIKey.ExpiresAt}}{{.APIKey.ExpiresAt.Format "2006-01-02 15:04 UTC"}}{{else}}No expiry{{end}}</p>
<a class="text-link" href="/admin/api-keys/{{.APIKey.ID}}">Open API key</a>
</article>
{{end}}
</section>
{{else}}
<section class="empty-state">
<h2>No API keys yet</h2>
<p>Create the first key to let client applications authenticate against the project, metadata, and download endpoints.</p>
</section>
{{end}}
{{end}}

View file

@ -0,0 +1,16 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
<section class="link-grid">
{{range .Links}}
<article class="link-card">
<h2><a href="{{.Href}}">{{.Label}}</a></h2>
<p>{{.Description}}</p>
</article>
{{end}}
</section>
{{end}}

View file

@ -0,0 +1,38 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
{{if .Login.Error}}
<section class="alert-card alert-error">
<p>{{.Login.Error}}</p>
</section>
{{end}}
{{if .Login.SetupHint}}
<section class="alert-card">
<p>{{.Login.SetupHint}}</p>
</section>
{{end}}
<section class="form-shell">
<form class="stack-form" method="post" action="{{.Login.Action}}">
{{template "csrf_field" .}}
<input type="hidden" name="next" value="{{.Login.Next}}">
<label class="field">
<span>Email</span>
<input type="email" name="email" value="{{.Login.Email}}" autocomplete="username" required>
</label>
<label class="field">
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button class="button" type="submit">Log in</button>
</form>
</section>
{{end}}

View file

@ -0,0 +1,200 @@
{{define "content"}}
<section class="hero hero-split">
<div>
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</div>
<div class="hero-meta">
<span class="pill {{if .Project.IsActive}}pill-success{{else}}pill-muted{{end}}">
{{if .Project.IsActive}}Active{{else}}Archived{{end}}
</span>
<p class="resource-slug">{{.Project.Slug}}</p>
</div>
</section>
{{if .Form.Error}}
<section class="alert-card alert-error">
<p>{{.Form.Error}}</p>
</section>
{{end}}
<section class="section-grid">
<article class="section-card">
<div class="section-heading">
<h2>Project Details</h2>
<p>Edit the stable metadata used by the admin UI and future client endpoints.</p>
</div>
<form class="stack-form" method="post" action="{{.Form.Action}}">
{{template "csrf_field" .}}
<label class="field">
<span>Name</span>
<input type="text" name="name" value="{{.Form.Name}}" maxlength="120" required>
</label>
<label class="field">
<span>Slug</span>
<input type="text" name="slug" value="{{.Form.Slug}}" maxlength="120" required>
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="4">{{.Form.Description}}</textarea>
</label>
<button class="button" type="submit">{{.Form.SubmitLabel}}</button>
</form>
</article>
<article class="section-card">
<div class="section-heading">
<h2>Lifecycle</h2>
<p>Archive projects to hide them operationally without deleting their data.</p>
</div>
<p class="meta-line"><strong>Created:</strong> {{.Project.CreatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<p class="meta-line"><strong>Updated:</strong> {{.Project.UpdatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<form class="stack-form" method="post" action="{{.ArchiveAction}}">
{{template "csrf_field" .}}
<input type="hidden" name="state" value="{{.ArchiveState}}">
<button class="button button-secondary" type="submit">{{.ArchiveLabel}}</button>
</form>
</article>
</section>
<section class="section-grid">
<article class="section-card">
<div class="section-heading">
<h2>Project Tags</h2>
<p>Tags are available from day one so projects can be grouped immediately.</p>
</div>
{{if .Tags}}
<div class="chip-list">
{{range .Tags}}
<form class="chip-form" method="post" action="/admin/projects/{{$.Project.ID}}/tags/{{.ID}}/detach">
{{template "csrf_field" $}}
<span class="chip-label">{{.Name}}</span>
<button class="chip-remove" type="submit">Detach</button>
</form>
{{end}}
</div>
{{else}}
<p class="empty-copy">No tags attached yet.</p>
{{end}}
{{if .AvailableTags}}
<form class="inline-form" method="post" action="{{.AttachTagAction}}">
{{template "csrf_field" .}}
<label class="field">
<span>Attach tag</span>
<select name="tag_id" required>
<option value="">Choose a tag</option>
{{range .AvailableTags}}
<option value="{{.ID}}">{{.Name}} ({{.Slug}})</option>
{{end}}
</select>
</label>
<button class="button" type="submit">Attach tag</button>
</form>
{{else}}
<p class="empty-copy">All existing tags are already attached. <a href="/admin/tags/new">Create another tag</a> if you need a new grouping.</p>
{{end}}
</article>
<article class="section-card">
<div class="section-heading">
<h2>Release Upload</h2>
<p>Uploads are stored outside the public web root and each release records its checksum, size, and storage path.</p>
</div>
{{if .Upload.Error}}
<section class="alert-card alert-error compact-alert">
<p>{{.Upload.Error}}</p>
</section>
{{end}}
<form class="stack-form" method="post" action="{{.Upload.Action}}" enctype="multipart/form-data">
{{template "csrf_field" .}}
<label class="field">
<span>Version</span>
<input type="text" name="version" value="{{.Upload.Version}}" maxlength="120" placeholder="1.0.0" required>
</label>
<label class="field">
<span>Build</span>
<input type="text" name="build" value="{{.Upload.Build}}" maxlength="120" placeholder="optional build label">
</label>
<label class="field">
<span>Release notes</span>
<textarea name="release_notes" rows="4">{{.Upload.ReleaseNotes}}</textarea>
</label>
<label class="field">
<span>Artifact file</span>
<input type="file" name="artifact" required>
</label>
<p class="helper-copy">Maximum upload size: {{.Upload.MaxUploadMB}} MB.</p>
<button class="button" type="submit">Upload release</button>
</form>
</article>
</section>
<section class="section-card section-card-wide">
<div class="section-heading">
<h2>Release History</h2>
<p>Each release row keeps the sanitized filename, checksum, file size, content type, and private storage path for later download endpoints.</p>
</div>
{{if .Releases}}
<div class="release-list">
{{range .Releases}}
<article class="release-card">
<div class="release-head">
<h3>{{.Release.Version}}{{if .Release.Build}} <span class="release-build">{{.Release.Build}}</span>{{end}}</h3>
<span class="pill pill-soft">{{.Release.ContentType}}</span>
</div>
<p class="meta-line"><strong>File:</strong> {{.Release.Filename}}</p>
<p class="meta-line"><strong>Checksum:</strong> <code>{{.Release.ChecksumSHA256}}</code></p>
<p class="meta-line"><strong>Storage path:</strong> <code>{{.Release.StoragePath}}</code></p>
<p class="meta-line"><strong>Size:</strong> {{.Release.SizeBytes}} bytes</p>
<p class="meta-line"><strong>Uploaded by:</strong> {{if .UploadedByEmail}}{{.UploadedByEmail}}{{else}}Unknown{{end}}</p>
<p class="meta-line"><strong>Created:</strong> {{.Release.CreatedAt.Format "2006-01-02 15:04 UTC"}}</p>
{{if .Release.ReleaseNotes}}
<p class="release-notes">{{.Release.ReleaseNotes}}</p>
{{end}}
</article>
{{end}}
</div>
{{else}}
<p class="empty-copy">No releases uploaded for this project yet.</p>
{{end}}
</section>
<section class="section-card section-card-wide">
<div class="section-heading">
<h2>Client API</h2>
<p>Use these bearer-authenticated endpoints from client applications after you create an API key with download access.</p>
</div>
{{if .Project.IsActive}}
<div class="endpoint-list">
<p class="meta-line"><strong>Project listing:</strong> <code>{{.BaseURL}}/api/v1/projects</code></p>
<p class="meta-line"><strong>Latest release metadata:</strong> <code>{{printf "%s/api/v1/projects/%s/releases/latest" .BaseURL .Project.Slug}}</code></p>
{{if .LatestRelease}}
<p class="meta-line"><strong>Release metadata:</strong> <code>{{printf "%s/api/v1/releases/%d" .BaseURL .LatestRelease.ID}}</code></p>
<p class="meta-line"><strong>Artifact download:</strong> <code>{{printf "%s/api/v1/releases/%d/download" .BaseURL .LatestRelease.ID}}</code></p>
<p class="meta-line"><strong>Current latest:</strong> Version {{.LatestRelease.Version}}{{if .LatestRelease.Build}} (build {{.LatestRelease.Build}}){{end}}, release ID {{.LatestRelease.ID}}.</p>
{{else}}
<p class="empty-copy">Upload the first release to activate the metadata and download endpoints for this project.</p>
{{end}}
</div>
{{else}}
<p class="empty-copy">This project is archived, so client API lookups and downloads will hide it until you restore the project.</p>
{{end}}
</section>
{{end}}

View file

@ -0,0 +1,38 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
{{if .Form.Error}}
<section class="alert-card alert-error">
<p>{{.Form.Error}}</p>
</section>
{{end}}
<section class="form-shell form-shell-wide">
<form class="stack-form" method="post" action="{{.Form.Action}}">
{{template "csrf_field" .}}
<label class="field">
<span>Name</span>
<input type="text" name="name" value="{{.Form.Name}}" maxlength="120" required>
</label>
<label class="field">
<span>Slug</span>
<input type="text" name="slug" value="{{.Form.Slug}}" maxlength="120" placeholder="auto-generated from the name">
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="5">{{.Form.Description}}</textarea>
</label>
<div class="button-row">
<button class="button" type="submit">{{.Form.SubmitLabel}}</button>
<a class="button button-secondary" href="/admin/projects">Back to projects</a>
</div>
</form>
</section>
{{end}}

View file

@ -0,0 +1,44 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
<section class="toolbar">
<a class="button" href="/admin/projects/new">Create project</a>
</section>
{{if .Projects}}
<section class="resource-grid">
{{range .Projects}}
<article class="resource-card">
<div class="resource-header">
<div>
<h2><a href="/admin/projects/{{.Project.ID}}">{{.Project.Name}}</a></h2>
<p class="resource-slug">{{.Project.Slug}}</p>
</div>
<span class="pill {{if .Project.IsActive}}pill-success{{else}}pill-muted{{end}}">
{{if .Project.IsActive}}Active{{else}}Archived{{end}}
</span>
</div>
<p class="resource-description">{{if .Project.Description}}{{.Project.Description}}{{else}}No description yet.{{end}}</p>
<div class="stat-row">
<span>{{.TagCount}} tags</span>
<span>{{.ReleaseCount}} releases</span>
<span>Updated {{.Project.UpdatedAt.Format "2006-01-02 15:04 UTC"}}</span>
</div>
<a class="text-link" href="/admin/projects/{{.Project.ID}}">Open project</a>
</article>
{{end}}
</section>
{{else}}
<section class="empty-state">
<h2>No projects yet</h2>
<p>Create the first project to start grouping releases and tags.</p>
</section>
{{end}}
{{end}}

View file

@ -0,0 +1,93 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
{{if .Form.Error}}
<section class="alert-card alert-error">
<p>{{.Form.Error}}</p>
</section>
{{end}}
<section class="section-grid">
<article class="section-card">
<div class="section-heading">
<h2>Tag Details</h2>
<p>Use stable tag slugs so projects and future access rules can reference them predictably.</p>
</div>
<form class="stack-form" method="post" action="{{.Form.Action}}">
{{template "csrf_field" .}}
<label class="field">
<span>Name</span>
<input type="text" name="name" value="{{.Form.Name}}" maxlength="120" required>
</label>
<label class="field">
<span>Slug</span>
<input type="text" name="slug" value="{{.Form.Slug}}" maxlength="120" placeholder="auto-generated from the name">
</label>
<label class="field">
<span>Description</span>
<textarea name="description" rows="4">{{.Form.Description}}</textarea>
</label>
<div class="button-row">
<button class="button" type="submit">{{.Form.SubmitLabel}}</button>
<a class="button button-secondary" href="/admin/tags">Back to tags</a>
</div>
</form>
</article>
{{if .Tag}}
<article class="section-card">
<div class="section-heading">
<h2>Delete Tag</h2>
<p>Tags can only be deleted once they are no longer attached to any project.</p>
</div>
{{if .Form.CanDelete}}
<form class="stack-form" method="post" action="{{.Form.DeleteAction}}">
{{template "csrf_field" .}}
<button class="button button-danger" type="submit">Delete tag</button>
</form>
{{else}}
<p class="empty-copy">Detach this tag from all projects before deleting it.</p>
{{end}}
</article>
{{end}}
</section>
{{if .Tag}}
<section class="section-card section-card-wide">
<div class="section-heading">
<h2>Assigned Projects</h2>
<p>These projects currently use this tag.</p>
</div>
{{if .Projects}}
<div class="resource-grid compact-grid">
{{range .Projects}}
<article class="resource-card">
<div class="resource-header">
<div>
<h3><a href="/admin/projects/{{.ID}}">{{.Name}}</a></h3>
<p class="resource-slug">{{.Slug}}</p>
</div>
<span class="pill {{if .IsActive}}pill-success{{else}}pill-muted{{end}}">
{{if .IsActive}}Active{{else}}Archived{{end}}
</span>
</div>
<p class="resource-description">{{if .Description}}{{.Description}}{{else}}No description yet.{{end}}</p>
</article>
{{end}}
</div>
{{else}}
<p class="empty-copy">This tag is not attached to any project yet.</p>
{{end}}
</section>
{{end}}
{{end}}

View file

@ -0,0 +1,36 @@
{{define "content"}}
<section class="hero">
<p class="eyebrow">{{.Eyebrow}}</p>
<h1>{{.Heading}}</h1>
<p class="lede">{{.Description}}</p>
</section>
<section class="toolbar">
<a class="button" href="/admin/tags/new">Create tag</a>
</section>
{{if .Tags}}
<section class="resource-grid">
{{range .Tags}}
<article class="resource-card">
<div class="resource-header">
<div>
<h2><a href="/admin/tags/{{.Tag.ID}}">{{.Tag.Name}}</a></h2>
<p class="resource-slug">{{.Tag.Slug}}</p>
</div>
<span class="pill pill-soft">{{.ProjectCount}} projects</span>
</div>
<p class="resource-description">{{if .Tag.Description}}{{.Tag.Description}}{{else}}No description yet.{{end}}</p>
<p class="meta-line"><strong>Updated:</strong> {{.Tag.UpdatedAt.Format "2006-01-02 15:04 UTC"}}</p>
<a class="text-link" href="/admin/tags/{{.Tag.ID}}">Open tag</a>
</article>
{{end}}
</section>
{{else}}
<section class="empty-state">
<h2>No tags yet</h2>
<p>Create the first tag so projects can be grouped from day one.</p>
</section>
{{end}}
{{end}}

View file

@ -0,0 +1,3 @@
{{define "csrf_field"}}
{{if .CSRFToken}}<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">{{end}}
{{end}}