Part A: shared app_list_row partial — ONE row grammar on four surfaces (v0.126.0)

- templates/app_row.html: app_list_row/app_list_row_end (layout_start/_end idiom) — icon+name
  (+optional secondary) left, caller action block right; compact 44px row; logoURL→PNG→
  optional FallbackIcon→hidden onerror chain
- applied: Távoli mentés toggle list, Visszaállítás restore-to-verify + .fab lists, dashboard
  Telepített alkalmazások (state edge + data-href preserved); Alkalmazások collapsed headers
  ALIGNED (icon+name left, status dot moved right before chevron; expander untouched)
- funcmap: dict + appHref; OffboxAppRow/AppBackupRow gain Slug
- style.css: .app-row family; stack-card/stack-info/stack-logo/stack-name/stack-desc/
  stack-actions rules retired (dashboard rows now shared); .app-backup-row-header matched
  to the shared row height
- scripts/app_row_dedup_gate.py: row markup single-sourced (red-proven: pasted old
  storage-path-item block → exit 1); render tests per surface (app_row_test.go)
- NO behavior change: toggle/download/expander actions byte-identical
This commit is contained in:
2026-07-13 11:26:48 +02:00
parent 7b3e7f2e71
commit 3eacb6c326
11 changed files with 380 additions and 87 deletions
@@ -0,0 +1,30 @@
{{/* app_list_row / app_list_row_end — THE canonical app-list row (v0.126.0).
One grammar on every list surface: icon + name (+ optional one-line secondary) left,
caller-provided action block right. A row without a secondary line stays compact.
Usage (the layout_start/layout_end idiom):
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
...caller action buttons / status...
{{template "app_list_row_end"}}
dict keys: Slug (icon lookup), Name, Secondary (optional one-liner),
RowClass (optional extra row class, e.g. stack-state-run), Href (optional data-href),
FallbackIcon (optional last-resort icon URL — infra stacks pass the generic infra SVG).
Do NOT hand-roll app rows — scripts/app_row_dedup_gate.py asserts this markup exists
here ONCE (the backups_apps expander header is the single allowlisted aligned copy). */}}
{{define "app_list_row"}}
<div class="app-row{{with .RowClass}} {{.}}{{end}}"{{with .Href}} data-href="{{.}}"{{end}}>
<img class="app-row-icon" src="{{logoURL .Slug}}" alt=""{{with .FallbackIcon}} data-fallback="{{.}}"{{end}}
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div class="app-row-text">
<span class="app-row-name">{{.Name}}</span>
{{with .Secondary}}<span class="app-row-secondary">{{.}}</span>{{end}}
</div>
<div class="app-row-actions">
{{end}}
{{define "app_list_row_end"}}
</div>
</div>
{{end}}
@@ -137,8 +137,12 @@
{{range .AppBackupRows}}
<div class="app-backup-row" data-status="{{.Status}}">
<!-- Aligned to the shared app-row grammar (icon + name left, status + chevron right);
the expander behavior is untouched — this header is the one allowlisted aligned
copy in scripts/app_row_dedup_gate.py, NOT a partial render (it owns the toggle). -->
<div class="app-backup-row-header" onclick="toggleBackupDetail(this)">
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
<img class="app-row-icon" src="{{logoURL .Slug}}" alt=""
onerror="this.onerror=function(){this.style.visibility='hidden'};this.src='{{logoPNGURL .Slug}}'">
<span class="app-backup-row-name">{{.DisplayName}}</span>
<div class="app-backup-row-meta">
{{if .DriveDisconnected}}
@@ -151,6 +155,7 @@
{{else}}
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}}</span>
{{end}}
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
</div>
<span class="expand-icon"></span>
</div>
@@ -70,20 +70,15 @@
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.</p>
{{end}}
{{if .OffboxApps}}
<div class="storage-paths-list">
<div class="app-row-list">
{{range .OffboxApps}}
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
<div class="storage-path-actions">
<form method="POST" action="/backup/offbox/toggle" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{end}}">
<button type="submit" class="btn btn-xs {{if .Enabled}}btn-outline{{else}}btn-primary{{end}}">{{if .Enabled}}Távoli mentés kikapcsolása{{else}}Távoli mentés bekapcsolása{{end}}</button>
</form>
</div>
</div>
</div>
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<form method="POST" action="/backup/offbox/toggle" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{end}}">
<button type="submit" class="btn btn-xs {{if .Enabled}}btn-outline{{else}}btn-primary{{end}}">{{if .Enabled}}Távoli mentés kikapcsolása{{else}}Távoli mentés bekapcsolása{{end}}</button>
</form>
{{template "app_list_row_end"}}
{{end}}
</div>
{{else}}<p class="form-hint">Nincs telepített alkalmazás.</p>{{end}}
@@ -66,20 +66,15 @@
<h3>Ellenőrző visszaállítás a távoli tárolóból</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">A távoli mentésre kijelölt alkalmazások legutóbbi pillanatképe egy külön ellenőrző mappába állítható vissza — a meglévő adatok nem változnak.</p>
{{if .OffboxToggledCount}}
<div class="storage-paths-list">
<div class="app-row-list">
{{range .OffboxApps}}
{{if .Enabled}}
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
<div class="storage-path-actions">
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a(z) {{.DisplayName}} adatait a távoli tárolóról egy ellenőrző mappába? A meglévő adatok NEM íródnak felül.">Visszaállítás (ellenőrzéshez)</button>
</form>
</div>
</div>
</div>
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a(z) {{.DisplayName}} adatait a távoli tárolóról egy ellenőrző mappába? A meglévő adatok NEM íródnak felül.">Visszaállítás (ellenőrzéshez)</button>
</form>
{{template "app_list_row_end"}}
{{end}}
{{end}}
</div>
@@ -101,16 +96,11 @@
<div class="form-row" style="max-width:420px"><label>Jelszavas titkosítás (opcionális)</label>
<input type="password" id="fab-dl-password" class="form-input" autocomplete="new-password" placeholder="Üresen hagyva a csomag titkosítatlan">
</div>
<div class="storage-paths-list">
<div class="app-row-list">
{{range .OffboxApps}}
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
<div class="storage-path-actions">
<button type="button" class="btn btn-xs btn-outline fab-dl-btn" data-stack="{{.Name}}" onclick="fabDownload(this)">Letöltés (.fab)</button>
</div>
</div>
</div>
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<button type="button" class="btn btn-xs btn-outline fab-dl-btn" data-stack="{{.Name}}" onclick="fabDownload(this)">Letöltés (.fab)</button>
{{template "app_list_row_end"}}
{{end}}
</div>
<div class="schedule-actions" style="margin-top:.75rem">
@@ -146,16 +146,7 @@
<div class="stack-list">
{{range .Stacks}}
<div class="stack-card stack-state-{{stateColor .State}}"{{if .Meta.Slug}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
<div class="stack-info">
<img class="stack-logo" src="{{logoURL .Meta.Slug}}"
alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
<div>
<strong class="stack-name">{{.Meta.DisplayName}}</strong>
{{if .Meta.Description}}<span class="stack-desc">{{.Meta.Description}}</span>{{end}}
</div>
</div>
<div class="stack-actions">
{{template "app_list_row" dict "Slug" .Meta.Slug "Name" .Meta.DisplayName "Secondary" .Meta.Description "RowClass" (printf "stack-state-%s" (stateColor .State)) "Href" (appHref .Meta.Slug)}}
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
{{if .Orphaned}}<span class="tag tag-warn">Elavult</span>{{end}}
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hiányzó tárhely: {{$ms}}</span>{{end}}
@@ -183,8 +174,7 @@
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
{{end}}
</div>
</div>
{{template "app_list_row_end"}}
{{else}}
<div class="empty-state">
<p>Nincs elérhető alkalmazás.</p>
@@ -108,7 +108,7 @@
</main>
<script>
document.addEventListener('click', function(e) {
if (e.target.closest('a, button, .btn, input, select, textarea, .stack-actions, .stack-detail-actions')) return;
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
var card = e.target.closest('[data-href]');
if (card) window.location.href = card.dataset.href;
});
+74 -38
View File
@@ -287,47 +287,89 @@ h3 {
.meter.crit .meter-flag { color: var(--crit); background: var(--crit-dim); }
.meter-flag .ico { width: 13px; height: 13px; }
/* Stack list (dashboard) — one panel, hairline-separated rows, 2px state edge. */
.stack-list {
background: var(--bg-1);
/* Shared app-list row (v0.126.0)
THE canonical list grammar (app_row.html partial): icon + name left, caller action
block right. Compact single-row height unless a secondary line is present matches
the Alkalmazások backup-status collapsed-header density. */
.app-row-list {
display: flex;
flex-direction: column;
gap: .5rem;
}
.app-row {
position: relative;
display: flex;
align-items: center;
gap: .75rem;
padding: .5rem 1rem;
min-height: 44px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.stack-card {
position: relative;
padding: .85rem 1.25rem;
border-top: 1px solid var(--line-soft);
.app-row-icon {
width: 24px;
height: 24px;
border-radius: var(--radius);
object-fit: contain;
background: var(--bg-2);
flex-shrink: 0;
}
.app-row-text {
display: flex;
flex-direction: column;
gap: .1rem;
min-width: 0;
flex: 1;
}
.app-row-name {
font-weight: 500;
font-size: .9rem;
}
.app-row-secondary {
font-size: .8rem;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-row-actions {
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.15s ease;
justify-content: flex-end;
gap: .5rem;
flex-shrink: 0;
flex-wrap: wrap;
}
.stack-card:first-child { border-top: none; }
.stack-card:hover {
background: rgba(0,131,216,.04);
}
.stack-card::before {
/* 2px state edge (dashboard rows carry stack-state-<color> as RowClass). */
.app-row::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 2px;
background: transparent;
}
.stack-card.stack-state-run::before,
.stack-card.stack-state-progress::before { background: var(--blue); }
.stack-card.stack-state-warn::before { background: var(--warn); }
.stack-info {
display: flex;
align-items: center;
gap: .75rem;
}
.stack-logo {
width: 32px;
height: 32px;
.app-row.stack-state-run::before,
.app-row.stack-state-progress::before { background: var(--blue); }
.app-row.stack-state-warn::before { background: var(--warn); }
/* Stack list (dashboard) — one panel, hairline-separated shared rows. */
.stack-list {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
object-fit: contain;
background: var(--bg-2);
padding: 4px;
}
.stack-list .app-row {
background: transparent;
border: none;
border-top: 1px solid var(--line-soft);
border-radius: 0;
padding: .6rem 1.25rem;
transition: background 0.15s ease;
}
.stack-list .app-row:first-child { border-top: none; }
.stack-list .app-row:hover {
background: rgba(0,131,216,.04);
}
.stack-logo-lg {
width: 48px;
@@ -337,13 +379,6 @@ h3 {
background: var(--bg-2);
padding: 6px;
}
.stack-name { font-size: 1rem; font-weight: 500; }
.stack-desc { display: block; font-size: .8rem; color: var(--text-2); }
.stack-actions {
display: flex;
align-items: center;
gap: .5rem;
}
.stack-state-label {
font-size: .8rem;
color: var(--text-2);
@@ -2392,8 +2427,8 @@ a.stat-card:hover {
.nav-links a.active { border-left: none; border-bottom: 2px solid var(--blue); }
.content { margin-left: 0; padding: 1rem; }
body { flex-direction: column; }
.stack-card { flex-direction: column; align-items: flex-start; gap: .75rem; }
.stack-actions { width: 100%; justify-content: flex-end; }
.app-row { flex-wrap: wrap; }
.app-row-actions { width: 100%; justify-content: flex-end; }
.stack-grid { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(3, 1fr); }
.deploy-info { flex-direction: column; }
@@ -2694,7 +2729,8 @@ a.stat-card:hover {
display: flex;
align-items: center;
gap: .75rem;
padding: .65rem 1rem;
padding: .5rem 1rem;
min-height: 44px; /* same compact height as the shared .app-row */
cursor: pointer;
user-select: none;
}