Add app detail/info pages with optional config support

- New route: GET /apps/{slug} renders info page with use cases, setup guide, prerequisites
- New API: POST /api/stacks/{name}/optional-config for updating optional env vars
- New structs: AppInfo, OptionalConfigGroup, OptionalConfigField in metadata.go
- UpdateOptionalConfig saves to app.yaml and restarts deployed stacks with new env vars
- Info page template with hero section, screenshots, info cards, optional config form
- Navigation: stack cards now link to /apps/{slug}, deploy page has "Részletek" link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 20:12:18 +01:00
parent 528f64cab7
commit 6080170367
5 changed files with 544 additions and 19 deletions
+359 -6
View File
@@ -4,7 +4,7 @@ package web
// Compiled into the binary — zero external file dependencies at runtime.
// As the UI grows, switch to go:embed for easier editing.
const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl
const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl + appInfoTmpl
const layoutTmpl = `
{{define "layout_start"}}
@@ -187,7 +187,7 @@ const dashboardTmpl = `
<div class="stack-list">
{{range .Stacks}}
<div class="stack-card stack-state-{{stateColor .State}}"{{if not .Protected}} data-href="/stacks/{{.Name}}/deploy"{{end}}>
<div class="stack-card stack-state-{{stateColor .State}}"{{if not .Protected}} 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}}'">
@@ -238,7 +238,7 @@ const stacksTmpl = `
<div class="stack-grid">
{{range .Stacks}}
<div class="stack-detail-card stack-state-{{stateColor .State}}"{{if not .Protected}} data-href="/stacks/{{.Name}}/deploy"{{end}}>
<div class="stack-detail-card stack-state-{{stateColor .State}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
<div class="stack-detail-header">
<div class="stack-title-row">
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
@@ -307,8 +307,11 @@ const deployTmpl = `
{{template "layout_start" .}}
<div class="page-header">
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
<h2>{{.Meta.DisplayName}} — Telepítés</h2>
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
<h2>{{.Meta.DisplayName}} — Telepítés</h2>
</div>
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline">️ Részletek</a>
</div>
<div class="deploy-container">
@@ -322,7 +325,7 @@ const deployTmpl = `
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{end}}
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">HDD szükséges</span>{{end}}
</div>
<a href="{{.AppPageURL}}" target="_blank" class="btn btn-sm btn-outline" style="margin-top:0.5rem">
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline" style="margin-top:0.5rem">
Részletes leírás, képernyőképek
</a>
</div>
@@ -738,6 +741,186 @@ const logsTmpl = `
{{end}}
`
const appInfoTmpl = `
{{define "app_info"}}
{{template "layout_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
<h2>{{.Meta.DisplayName}}</h2>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
{{if .Stack.Deployed}}
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
<a href="https://{{.Meta.Subdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
{{else}}
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">Telepítés</a>
{{end}}
</div>
</div>
<!-- Hero section -->
<div class="app-info-hero">
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
alt="{{.Meta.DisplayName}}"
onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
<div class="app-info-hero-text">
{{if .AppInfo.Tagline}}
<p class="app-info-tagline">{{.AppInfo.Tagline}}</p>
{{else}}
<p class="app-info-tagline">{{.Meta.Description}}</p>
{{end}}
<div class="stack-meta-badges">
<span class="meta-badge">~{{.Meta.Resources.MemRequest}} RAM</span>
<span class="meta-badge">{{.Meta.Category}}</span>
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge meta-badge-warn">HDD szükséges</span>{{end}}
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{else}}<span class="meta-badge meta-badge-warn">Csak x86</span>{{end}}
</div>
</div>
</div>
<!-- Screenshots (graceful — hidden if assets don't exist) -->
<div class="app-screenshots" id="screenshots">
<img src="{{screenshotURL .Meta.Slug 1}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="{{screenshotURL .Meta.Slug 2}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="{{screenshotURL .Meta.Slug 3}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
</div>
{{if .HasAppInfo}}
<div class="app-info-grid">
{{if .AppInfo.UseCases}}
<div class="app-info-card">
<h3>Mire használható?</h3>
<ul class="app-info-list">
{{range .AppInfo.UseCases}}<li>{{.}}</li>{{end}}
</ul>
</div>
{{end}}
{{if .AppInfo.FirstSteps}}
<div class="app-info-card">
<h3>Első lépések</h3>
<ol class="app-info-list">
{{range .AppInfo.FirstSteps}}<li>{{.}}</li>{{end}}
</ol>
</div>
{{end}}
{{if .AppInfo.Prerequisites}}
<div class="app-info-card">
<h3>Előfeltételek</h3>
<ul class="app-info-list">
{{range .AppInfo.Prerequisites}}<li>{{.}}</li>{{end}}
</ul>
</div>
{{end}}
{{if .AppInfo.DefaultCreds}}
<div class="app-info-card">
<h3>Alapértelmezett belépés</h3>
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
<p class="app-info-creds-warn">Az első bejelentkezés után azonnal változtasd meg!</p>
</div>
{{end}}
{{if .AppInfo.DocsURL}}
<div class="app-info-card">
<h3>Dokumentáció</h3>
<p><a href="{{.AppInfo.DocsURL}}" target="_blank" class="app-info-link">Hivatalos dokumentáció ↗</a></p>
</div>
{{end}}
</div>
{{end}}
{{if .HasOptionalConfig}}
<div class="app-optional-config">
<h3>Opcionális beállítások</h3>
{{range .OptionalConfig}}
<div class="config-group">
<h4>{{.Group}}</h4>
{{if .Description}}<p class="config-group-desc">{{.Description}}</p>{{end}}
<div class="config-fields">
{{range .Fields}}
<div class="config-field">
<label for="opt-{{.EnvVar}}">{{.Label}}</label>
{{if .HelpText}}<p class="config-field-help">{{.HelpText}}</p>{{end}}
{{if .HelpURL}}<p class="config-field-help"><a href="{{.HelpURL}}" target="_blank">Regisztrációs útmutató ↗</a></p>{{end}}
<input type="{{if eq .Type "secret_input"}}password{{else}}text{{end}}"
id="opt-{{.EnvVar}}"
name="{{.EnvVar}}"
class="config-input"
value="{{index $.CurrentValues .EnvVar}}"
placeholder="{{.Label}}"
autocomplete="off">
</div>
{{end}}
</div>
</div>
{{end}}
<div class="config-actions">
<button class="btn btn-primary" id="save-optional-config" onclick="saveOptionalConfig('{{.Stack.Name}}')">
Mentés
</button>
<span id="config-save-status" class="config-save-status"></span>
</div>
</div>
<script>
async function saveOptionalConfig(stackName) {
const btn = document.getElementById('save-optional-config');
const status = document.getElementById('config-save-status');
const inputs = document.querySelectorAll('.config-input');
const values = {};
inputs.forEach(function(input) {
values[input.name] = input.value;
});
btn.disabled = true;
btn.textContent = 'Mentés...';
status.textContent = '';
status.className = 'config-save-status';
try {
const resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({values: values})
});
const data = await resp.json();
if (data.ok) {
status.textContent = (data.message || 'Mentve');
status.className = 'config-save-status config-save-ok';
} else {
status.textContent = (data.error || 'Hiba');
status.className = 'config-save-status config-save-err';
}
} catch(err) {
status.textContent = 'Hálózati hiba';
status.className = 'config-save-status config-save-err';
}
btn.disabled = false;
btn.textContent = 'Mentés';
setTimeout(function() { status.textContent = ''; }, 5000);
}
</script>
{{end}}
{{template "layout_end" .}}
{{end}}
`
// CSS is defined in a separate const for readability.
// Served at /static/style.css
const cssContent = `
@@ -1622,6 +1805,176 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
}
.login-footer a:hover { color: var(--accent-blue); }
/* --- App Info Page --- */
.app-info-hero {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.5rem;
background: var(--bg-card);
border-radius: var(--radius);
border: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.app-info-logo {
width: 80px;
height: 80px;
border-radius: 12px;
object-fit: contain;
background: var(--bg-secondary);
padding: 10px;
flex-shrink: 0;
}
.app-info-tagline {
font-size: 1.1rem;
color: var(--text-primary);
margin: 0 0 .75rem 0;
}
.app-screenshots {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
overflow-x: auto;
padding-bottom: .5rem;
}
.app-screenshot {
max-height: 220px;
border-radius: var(--radius);
border: 1px solid var(--border-color);
object-fit: cover;
}
.app-info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.app-info-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.25rem;
border: 1px solid var(--border-color);
}
.app-info-card h3 {
margin: 0 0 .75rem 0;
font-size: .95rem;
color: var(--text-primary);
}
.app-info-list {
margin: 0;
padding-left: 1.25rem;
color: var(--text-secondary);
font-size: .9rem;
line-height: 1.6;
}
.app-info-creds {
font-family: monospace;
font-size: 1rem;
color: var(--accent-light);
background: var(--bg-secondary);
padding: .5rem .75rem;
border-radius: 4px;
display: inline-block;
margin: 0 0 .5rem 0;
}
.app-info-creds-warn {
color: var(--orange);
font-size: .85rem;
margin: 0;
}
.app-info-link {
color: var(--accent-light);
text-decoration: none;
}
.app-info-link:hover { text-decoration: underline; }
/* Optional config section */
.app-optional-config {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.5rem;
border: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.app-optional-config h3 {
margin: 0 0 1rem 0;
font-size: 1.05rem;
}
.config-group {
margin-bottom: 1.5rem;
}
.config-group h4 {
margin: 0 0 .5rem 0;
font-size: .95rem;
color: var(--text-primary);
}
.config-group-desc {
color: var(--text-secondary);
font-size: .85rem;
margin: 0 0 1rem 0;
}
.config-fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}
.config-field {
display: flex;
flex-direction: column;
gap: .25rem;
}
.config-field label {
font-size: .85rem;
font-weight: 500;
color: var(--text-primary);
}
.config-field-help {
font-size: .8rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.4;
}
.config-field-help a {
color: var(--accent-light);
text-decoration: none;
}
.config-field-help a:hover { text-decoration: underline; }
.config-input {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: .5rem .75rem;
color: var(--text-primary);
font-size: .9rem;
font-family: monospace;
width: 100%;
box-sizing: border-box;
}
.config-input:focus {
outline: none;
border-color: var(--accent-blue);
}
.config-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1rem;
}
.config-save-status {
font-size: .9rem;
transition: opacity .3s;
}
.config-save-ok { color: var(--green); }
.config-save-err { color: var(--red); }
.meta-badge-warn {
background: rgba(255, 152, 0, 0.1) !important;
color: var(--orange) !important;
}
.meta-badge-ok {
background: rgba(76, 175, 80, 0.1) !important;
color: var(--green) !important;
}
/* Responsive */
@media(max-width: 768px) {
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }