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:
@@ -75,6 +75,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "update", extractName(path, "/update"))
|
||||
|
||||
// POST /api/stacks/{name}/optional-config
|
||||
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
|
||||
r.updateOptionalConfig(w, req, extractName(path, "/optional-config"))
|
||||
|
||||
// GET /api/stacks/{name}/logs
|
||||
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
|
||||
r.getStackLogs(w, req, extractName(path, "/logs"))
|
||||
@@ -210,6 +214,26 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
|
||||
}
|
||||
|
||||
func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) {
|
||||
r.logger.Printf("[API] Optional config update requested for stack: %s", name)
|
||||
|
||||
var body struct {
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
|
||||
r.logger.Printf("[API] Optional config update failed for %s: %v", name, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
|
||||
}
|
||||
|
||||
func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
|
||||
lines := 100
|
||||
if v := req.URL.Query().Get("lines"); v != "" {
|
||||
|
||||
@@ -282,6 +282,85 @@ func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
|
||||
return &meta, appCfg, nil
|
||||
}
|
||||
|
||||
// UpdateOptionalConfig updates optional env vars in app.yaml and restarts the stack if deployed.
|
||||
// Only updates env vars that are listed in the metadata's optional_config sections.
|
||||
func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]string) error {
|
||||
stack, ok := m.GetStack(stackName)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", stackName)
|
||||
}
|
||||
|
||||
// Build a set of allowed env vars from optional_config
|
||||
allowed := make(map[string]bool)
|
||||
for _, group := range stack.Meta.OptionalConfig {
|
||||
for _, field := range group.Fields {
|
||||
allowed[field.EnvVar] = true
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return fmt.Errorf("no optional config fields defined for %s", stackName)
|
||||
}
|
||||
|
||||
// Load existing app.yaml (or create empty one)
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
if appCfg == nil {
|
||||
appCfg = &AppConfig{
|
||||
Env: make(map[string]string),
|
||||
}
|
||||
}
|
||||
if appCfg.Env == nil {
|
||||
appCfg.Env = make(map[string]string)
|
||||
}
|
||||
|
||||
// Update only allowed env vars
|
||||
changed := false
|
||||
for key, val := range values {
|
||||
if !allowed[key] {
|
||||
m.logger.Printf("[WARN] Ignoring non-optional env var: %s", key)
|
||||
continue
|
||||
}
|
||||
if appCfg.Env[key] != val {
|
||||
appCfg.Env[key] = val
|
||||
changed = true
|
||||
m.logger.Printf("[INFO] Updated optional config %s for %s", key, stackName)
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save app.yaml
|
||||
if err := SaveAppConfig(stackDir, appCfg); err != nil {
|
||||
return fmt.Errorf("saving app config: %w", err)
|
||||
}
|
||||
m.logger.Printf("[INFO] Saved updated app.yaml for %s", stackName)
|
||||
|
||||
// If deployed, recreate containers to pick up new env vars
|
||||
// (docker compose restart does NOT pick up new env vars — must use up -d)
|
||||
if stack.Deployed {
|
||||
m.logger.Printf("[INFO] Restarting %s to apply new optional config", stackName)
|
||||
env := m.stackEnv(stackDir)
|
||||
if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil {
|
||||
return fmt.Errorf("restart after config update: %w", err)
|
||||
}
|
||||
m.logPostStartStatus(stackName, stackDir, env)
|
||||
}
|
||||
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
// LoadAppConfigByName reads app.yaml for a named stack. Returns nil if not found.
|
||||
func (m *Manager) LoadAppConfigByName(stackName string) *AppConfig {
|
||||
stack, ok := m.GetStack(stackName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
return LoadAppConfig(stackDir)
|
||||
}
|
||||
|
||||
// --- App config persistence ---
|
||||
|
||||
func LoadAppConfig(stackDir string) *AppConfig {
|
||||
|
||||
@@ -10,13 +10,41 @@ import (
|
||||
|
||||
// Metadata holds app information parsed from .felhom.yml.
|
||||
type Metadata struct {
|
||||
DisplayName string `yaml:"display_name" json:"display_name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||
Slug string `yaml:"slug" json:"slug"`
|
||||
Resources ResourceHints `yaml:"resources" json:"resources"`
|
||||
DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"`
|
||||
DisplayName string `yaml:"display_name" json:"display_name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||
Slug string `yaml:"slug" json:"slug"`
|
||||
Resources ResourceHints `yaml:"resources" json:"resources"`
|
||||
DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"`
|
||||
AppInfo AppInfo `yaml:"app_info" json:"app_info"`
|
||||
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
|
||||
}
|
||||
|
||||
// AppInfo holds detailed app information for the info page.
|
||||
type AppInfo struct {
|
||||
Tagline string `yaml:"tagline" json:"tagline"`
|
||||
UseCases []string `yaml:"use_cases" json:"use_cases"`
|
||||
FirstSteps []string `yaml:"first_steps" json:"first_steps"`
|
||||
Prerequisites []string `yaml:"prerequisites" json:"prerequisites"`
|
||||
DefaultCreds string `yaml:"default_creds" json:"default_creds"`
|
||||
DocsURL string `yaml:"docs_url" json:"docs_url"`
|
||||
}
|
||||
|
||||
// OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers").
|
||||
type OptionalConfigGroup struct {
|
||||
Group string `yaml:"group" json:"group"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Fields []OptionalConfigField `yaml:"fields" json:"fields"`
|
||||
}
|
||||
|
||||
// OptionalConfigField defines an individual optional config field.
|
||||
type OptionalConfigField struct {
|
||||
EnvVar string `yaml:"env_var" json:"env_var"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Type string `yaml:"type" json:"type"`
|
||||
HelpURL string `yaml:"help_url" json:"help_url"`
|
||||
HelpText string `yaml:"help_text" json:"help_text"`
|
||||
}
|
||||
|
||||
// ResourceHints describe what the app needs.
|
||||
@@ -131,3 +159,13 @@ func (m *Metadata) AutoGeneratedFields() []DeployField {
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// HasAppInfo returns true if the metadata has any app info content.
|
||||
func (m *Metadata) HasAppInfo() bool {
|
||||
return m.AppInfo.Tagline != "" || len(m.AppInfo.UseCases) > 0 || len(m.AppInfo.FirstSteps) > 0
|
||||
}
|
||||
|
||||
// HasOptionalConfig returns true if the metadata has any optional config groups.
|
||||
func (m *Metadata) HasOptionalConfig() bool {
|
||||
return len(m.OptionalConfig) > 0
|
||||
}
|
||||
|
||||
@@ -165,6 +165,16 @@ func (s *Server) loadTemplates() {
|
||||
}
|
||||
return r
|
||||
},
|
||||
"screenshotURL": func(slug string, index int) string {
|
||||
return s.cfg.AppScreenshotURL(slug, index)
|
||||
},
|
||||
"seq": func(n int) []int {
|
||||
result := make([]int, n)
|
||||
for i := range result {
|
||||
result[i] = i + 1
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
s.tmpl = template.Must(template.New("").Funcs(funcMap).Parse(allTemplates))
|
||||
@@ -475,16 +485,37 @@ func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename str
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
|
||||
func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug string) {
|
||||
var found *stacks.Stack
|
||||
for _, stack := range s.stackMgr.GetStacks() {
|
||||
if stack.Meta.Slug == slug {
|
||||
// Always redirect to deploy page — it has a read-only mode for
|
||||
// deployed apps showing current settings, plus the external link
|
||||
http.Redirect(w, r, "/stacks/"+stack.Name+"/deploy", http.StatusFound)
|
||||
return
|
||||
found = &stack
|
||||
break
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
if found == nil {
|
||||
http.NotFound(w, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Load current optional config values from app.yaml
|
||||
currentValues := make(map[string]string)
|
||||
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
||||
for k, v := range appCfg.Env {
|
||||
currentValues[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
data := s.baseData("stacks", found.Meta.DisplayName)
|
||||
data["Stack"] = found
|
||||
data["Meta"] = found.Meta
|
||||
data["AppInfo"] = found.Meta.AppInfo
|
||||
data["OptionalConfig"] = found.Meta.OptionalConfig
|
||||
data["CurrentValues"] = currentValues
|
||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
||||
|
||||
s.render(w, "app_info", data)
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user