Git Repository
Stackyard
Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.
HTTPS
https://zanvex.de/git/stackyard.gitinternal/web/dashboard_template.go
Zum Verzeichnispackage web
import (
"html/template"
"time"
)
var dashboardTemplate = template.Must(template.New("dashboard").Funcs(template.FuncMap{
"since": func(ts time.Time) string {
if ts.IsZero() {
return "Noch kein Commit"
}
return ts.Format("02.01.2006 · 15:04")
},
}).Parse(`<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Stackyard · Projekte</title>
<style>
:root{color-scheme:dark;--bg:#080d18;--panel:#111827;--panel2:#0c1322;--line:#263349;--text:#e7eef9;--muted:#8fa0ba;--accent:#38bdf8;--accent2:#0e7490;--good:#4ade80;--warn:#fbbf24}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 12% -5%,#17294a 0,transparent 34rem),var(--bg);color:var(--text);font:14px/1.5 "Segoe UI",system-ui,sans-serif}a{color:inherit;text-decoration:none}button,input,select{font:inherit}.shell{max-width:1480px;margin:auto;padding:1.4rem 1.5rem 4rem}.top{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start;margin-bottom:1.15rem}.brand span{display:block;color:var(--accent);font-size:.75rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase}.brand h1{margin:.1rem 0;font-size:clamp(1.8rem,4vw,2.65rem);line-height:1}.brand p{margin:.45rem 0 0;color:var(--muted)}button,.button{border:1px solid #155e75;border-radius:.6rem;background:var(--accent2);color:#ecfeff;padding:.6rem .82rem;cursor:pointer}button:hover,.button:hover{background:#0891b2}.secondary{background:#111827;border-color:var(--line)}.flash{padding:.75rem 1rem;margin-bottom:1rem;border:1px solid;border-radius:.7rem}.flash-success{color:#bbf7d0;background:#052e16;border-color:#166534}.flash-error{color:#fecaca;background:#450a0a;border-color:#991b1b}
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem;margin-bottom:1rem}.stat{padding:.9rem 1rem;border:1px solid var(--line);border-radius:.8rem;background:rgba(17,24,39,.9)}.stat strong{display:block;font-size:1.55rem}.stat span{color:var(--muted);font-size:.76rem;text-transform:uppercase;letter-spacing:.08em}.controls{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;margin-bottom:1rem}.filter,.new-folder{display:flex;gap:.55rem;align-items:center;padding:.65rem;border:1px solid var(--line);border-radius:.8rem;background:var(--panel)}input,select{min-height:2.45rem;border:1px solid var(--line);border-radius:.55rem;background:#080d18;color:var(--text);padding:.55rem .7rem}.filter input{flex:1;min-width:11rem}.filter select{width:auto}.check{display:flex;gap:.35rem;align-items:center;color:var(--muted);white-space:nowrap}.check input{min-height:0}.new-folder input{width:13rem}
.attention{margin-bottom:1rem;padding:.9rem 1rem;border:1px solid #854d0e;border-radius:.8rem;background:rgba(69,26,3,.38)}.attention h2{margin:0 0 .55rem;font-size:1rem}.attention-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(16rem,1fr));gap:.4rem}.attention-item{padding:.5rem .65rem;border-left:3px solid #64748b;background:rgba(8,13,24,.55)}.attention-item.warning{border-color:#fbbf24}.attention-item.critical{border-color:#f87171}.attention-item span{display:block;color:var(--muted);font-size:.78rem}
.folder{margin-top:1rem;border:1px solid var(--line);border-top:3px solid var(--folder-color,var(--accent));border-radius:1rem;background:rgba(12,19,34,.72);overflow:hidden}.folder.collapsed .cards{display:none}.folder-head{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.85rem 1rem;border-bottom:1px solid var(--line)}.folder-title{display:flex;align-items:center;gap:.65rem}.folder-title h2{font-size:1rem;margin:0}.count{color:var(--muted);font-size:.78rem}.folder-tools{display:flex;align-items:center;gap:.25rem}.folder-tools button{padding:.35rem .5rem;background:transparent;border-color:transparent}.folder-tools .danger{color:#fca5a5}.folder-edit{display:none;gap:.35rem;padding:.7rem 1rem;border-bottom:1px solid var(--line)}.folder.editing .folder-edit{display:flex}.folder.drag-over{outline:2px solid var(--accent)}.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:.75rem;padding:.75rem}.cards.list{grid-template-columns:1fr}.cards.list .card{min-height:0;display:grid;grid-template-columns:minmax(12rem,1fr) minmax(12rem,1fr) auto;gap:.8rem;align-items:center}.cards.list .commit,.cards.list .card-actions,.cards.list .assign{margin:0;padding:0}.card{display:flex;flex-direction:column;min-height:13.5rem;padding:1rem;border:1px solid var(--line);border-radius:.8rem;background:var(--panel);transition:transform .12s,border-color .12s}.card:hover{transform:translateY(-2px);border-color:#3b82a0}.card.dragging{opacity:.45}.card-top{display:flex;justify-content:space-between;gap:.75rem}.type{color:var(--accent);font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em}.card h3{margin:.18rem 0 0;font-size:1.12rem}.health{width:.6rem;height:.6rem;border-radius:50%;margin-top:.35rem;background:#64748b}.health-good{background:var(--good)}.health-warning{background:var(--warn)}.path{color:var(--muted);font:.76rem ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:.45rem 0}.description{color:#cbd5e1;margin:.35rem 0}.badges{display:flex;flex-wrap:wrap;gap:.35rem;margin:.2rem 0}.state-paused{color:#fde68a;border-color:#854d0e!important}.state-archived{color:#94a3b8;border-color:#475569!important}.state-experimental{color:#d8b4fe;border-color:#7e22ce!important}.pill{padding:.16rem .46rem;border:1px solid var(--line);border-radius:999px;color:#c5d1e2;font-size:.72rem}.commit{margin-top:auto;padding-top:.8rem;color:#cdd7e5}.commit span{display:block;color:var(--muted);font-size:.75rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.card-actions{display:flex;gap:.4rem;margin-top:.7rem}.card-actions a{padding:.42rem .58rem;border-radius:.5rem;background:#0c4a6e}.assign{display:flex;gap:.35rem;margin-top:.65rem}.assign select{flex:1;min-width:0;min-height:2.1rem;padding:.35rem .45rem;font-size:.78rem}.assign button{min-height:2.1rem;padding:.35rem .55rem;font-size:.78rem}.empty{padding:1.2rem;color:var(--muted)}.pager{display:flex;justify-content:center;align-items:center;gap:.7rem;margin-top:1rem}.pager a{padding:.5rem .7rem;border:1px solid var(--line);border-radius:.5rem}
@media(max-width:900px){.shell{padding:1rem}.top,.controls{display:block}.top form{margin-top:1rem}.filter,.new-folder{flex-wrap:wrap;margin-bottom:.7rem}.stats{grid-template-columns:repeat(2,1fr)}.new-folder input{flex:1;width:auto}}@media(max-width:520px){.stats{grid-template-columns:1fr 1fr}.cards{grid-template-columns:1fr}.filter>*{width:100%!important}}
</style></head><body><main class="shell">
<header class="top"><div class="brand"><span>Local Dev Control Center</span><h1>Stackyard</h1><p>Projekte finden, ordnen und direkt weiterarbeiten.</p></div><div><a class="button secondary" href="/operations">Operations</a> <form style="display:inline" method="post" action="/projects/rescan"><button>↻ Projekte neu scannen</button></form></div></header>
{{if .Flash.Text}}<div class="flash flash-{{.Flash.Kind}}">{{.Flash.Text}}</div>{{end}}
{{if .SnapshotName}}<div class="flash flash-success">Workspace-Snapshot „{{.SnapshotName}}“ aktiv · <a href="/">Ansicht verlassen</a></div>{{end}}
<section class="stats"><div class="stat"><strong>{{.TotalProjects}}</strong><span>Projekte</span></div><div class="stat"><strong>{{.RepoCount}}</strong><span>Git-Repositories</span></div><div class="stat"><strong>{{.DirtyCount}}</strong><span>Dirty</span></div><div class="stat"><strong>{{.WarningCount}}</strong><span>Zu prüfen</span></div></section>
{{if .Warnings}}<section class="attention"><h2>Aufmerksamkeit nötig</h2><div class="attention-list">{{range .Warnings}}<a class="attention-item {{.Level}}" href="/projects/{{.ProjectID}}"><strong>{{.ProjectName}}</strong><span>{{.Message}}</span></a>{{end}}</div></section>{{end}}
<section class="controls"><form id="project-filters" class="filter" method="get" action="/"><input name="q" value="{{.Query}}" type="search" placeholder="Projekte, Labels, Branches, Ports oder Tooling durchsuchen …"><select name="state"><option value="">Ohne archivierte</option><option value="all" {{if eq .StateFilter "all"}}selected{{end}}>Alle Status</option><option value="active" {{if eq .StateFilter "active"}}selected{{end}}>Active</option><option value="paused" {{if eq .StateFilter "paused"}}selected{{end}}>Paused</option><option value="archived" {{if eq .StateFilter "archived"}}selected{{end}}>Archived</option><option value="experimental" {{if eq .StateFilter "experimental"}}selected{{end}}>Experimental</option></select><select name="sort"><option value="">Name</option><option value="commit" {{if eq .Sort "commit"}}selected{{end}}>Letzter Commit</option><option value="opened" {{if eq .Sort "opened"}}selected{{end}}>Zuletzt geöffnet</option><option value="state" {{if eq .Sort "state"}}selected{{end}}>Status</option></select><select name="view"><option value="cards">Karten</option><option value="list" {{if eq .View "list"}}selected{{end}}>Liste</option></select><label class="check"><input type="checkbox" name="favorites" value="1" {{if .FavoritesOnly}}checked{{end}}> Favoriten</label><button>Filtern</button><button id="reset-filters" class="secondary" type="button">Reset</button></form><form class="new-folder" method="post" action="/collections/create"><input name="name" maxlength="80" required placeholder="Neue Projektmappe"><button>Anlegen</button></form></section>
{{range .Collections}}<section class="folder" data-folder-id="{{.ID}}" data-color="{{.Color}}"><header class="folder-head"><div class="folder-title"><button type="button" data-collapse title="Ein-/ausklappen">▾</button><h2>{{.Name}}</h2><span class="count">{{len .Projects}} Projekte</span></div><div class="folder-tools"><form method="post" action="/collections/move"><input type="hidden" name="id" value="{{.ID}}"><button name="direction" value="-1" title="Nach oben">↑</button><button name="direction" value="1" title="Nach unten">↓</button></form><button type="button" data-edit>Bearbeiten</button><form method="post" action="/collections/delete" onsubmit="return confirm('Projektmappe entfernen? Die Projekte bleiben erhalten.')"><input type="hidden" name="id" value="{{.ID}}"><button class="danger">Entfernen</button></form></div></header><form class="folder-edit" method="post" action="/collections/update"><input type="hidden" name="id" value="{{.ID}}"><input name="name" maxlength="80" value="{{.Name}}" required><input type="color" name="color" value="{{.Color}}"><button>Speichern</button></form><div class="cards {{$.View}}">{{range .Projects}}{{template "project-card" .}}{{else}}<div class="empty">Projekte hierher ziehen oder über die Auswahl zuordnen.</div>{{end}}</div></section>{{end}}
<section class="folder" data-folder-id="0"><header class="folder-head"><div class="folder-title"><button type="button" data-collapse>▾</button><h2>Weitere Projekte</h2><span class="count">{{len .Unassigned}} Projekte</span></div></header><div class="cards {{.View}}">{{range .Unassigned}}{{template "project-card" .}}{{else}}<div class="empty">Alle sichtbaren Projekte sind einsortiert.</div>{{end}}</div></section>
<nav class="pager">{{if .HasPrev}}<a href="?page={{.PrevPage}}&q={{.Query}}&state={{.StateFilter}}&sort={{.Sort}}&view={{.View}}{{if .FavoritesOnly}}&favorites=1{{end}}">← Zurück</a>{{end}}<span>Seite {{.Page}}</span>{{if .HasNext}}<a href="?page={{.NextPage}}&q={{.Query}}&state={{.StateFilter}}&sort={{.Sort}}&view={{.View}}{{if .FavoritesOnly}}&favorites=1{{end}}">Weiter →</a>{{end}}</nav>
</main><script>const filterKey='stackyard-filters',filterForm=document.getElementById('project-filters'),savedFilters=localStorage.getItem(filterKey);if(!location.search&&savedFilters)location.replace('/?'+savedFilters);filterForm?.addEventListener('submit',()=>localStorage.setItem(filterKey,new URLSearchParams(new FormData(filterForm)).toString()));document.getElementById('reset-filters')?.addEventListener('click',()=>{localStorage.removeItem(filterKey);location.href='/'});const key='stackyard-collapsed';const collapsed=new Set(JSON.parse(localStorage.getItem(key)||'[]'));document.querySelectorAll('.folder').forEach(f=>{if(f.dataset.color)f.style.setProperty('--folder-color',f.dataset.color);if(collapsed.has(f.dataset.folderId))f.classList.add('collapsed');f.querySelector('[data-collapse]')?.addEventListener('click',()=>{f.classList.toggle('collapsed');const id=f.dataset.folderId;f.classList.contains('collapsed')?collapsed.add(id):collapsed.delete(id);localStorage.setItem(key,JSON.stringify([...collapsed]))});f.querySelector('[data-edit]')?.addEventListener('click',()=>f.classList.toggle('editing'));f.addEventListener('dragover',e=>{e.preventDefault();f.classList.add('drag-over')});f.addEventListener('dragleave',()=>f.classList.remove('drag-over'));f.addEventListener('drop',e=>{e.preventDefault();f.classList.remove('drag-over');const id=e.dataTransfer.getData('text/project-id');if(!id)return;const form=document.createElement('form');form.method='post';form.action='/collections/assign';for(const [name,value] of Object.entries({project_id:id,collection_id:f.dataset.folderId})){const input=document.createElement('input');input.type='hidden';input.name=name;input.value=value;form.append(input)}document.body.append(form);form.submit()})});document.querySelectorAll('.card').forEach(c=>{c.addEventListener('dragstart',e=>{c.classList.add('dragging');e.dataTransfer.setData('text/project-id',c.dataset.projectId)});c.addEventListener('dragend',()=>c.classList.remove('dragging'))});</script></body></html>
{{define "project-card"}}<article class="card" draggable="true" data-project-id="{{.ID}}"><div><div class="card-top"><div><div class="type">{{.RootName}} · {{.PrimaryType}}</div><h3><a href="/projects/{{.ID}}">{{.Name}}</a></h3></div><span class="health health-{{.Health}}" title="{{.HealthLabel}}"></span></div><div class="path" title="{{.Path}}">{{.Path}}</div>{{if .Description}}<div class="description">{{.Description}}</div>{{end}}<div class="badges"><span class="pill state-{{.State}}">{{.State}}</span>{{if .Git.IsRepo}}<span class="pill">{{.Git.Branch}}{{if .Git.Dirty}} · dirty{{end}}{{if .Git.Ahead}} · ↑{{.Git.Ahead}}{{end}}{{if .Git.Behind}} · ↓{{.Git.Behind}}{{end}}</span>{{end}}{{range .Labels}}<span class="pill">{{.}}</span>{{end}}{{if not .LastOpenedAt.IsZero}}<span class="pill">geöffnet {{since .LastOpenedAt}}</span>{{end}}{{if .Favorite}}<span class="pill">★ Favorit</span>{{end}}</div></div><div class="commit">{{if .Git.LastCommitSubject}}{{.Git.LastCommitSubject}}<span>{{since .Git.LastCommitAt}}</span>{{else}}<span>Keine Commit-Informationen</span>{{end}}</div><div><div class="card-actions"><a href="/projects/{{.ID}}">Übersicht</a><a href="/projects/{{.ID}}/editor">WebIDE</a><form method="post" action="/projects/favorite"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="favorite" value="{{if .Favorite}}0{{else}}1{{end}}"><input type="hidden" name="return_to" value="/"><button title="Favorit umschalten">{{if .Favorite}}★{{else}}☆{{end}}</button></form></div><form class="assign" method="post" action="/collections/assign"><input type="hidden" name="project_id" value="{{.ID}}"><select name="collection_id"><option value="0">Keine Projektmappe</option>{{range .CollectionOptions}}<option value="{{.ID}}" {{if eq .ID $.CollectionID}}selected{{end}}>{{.Name}}</option>{{end}}</select><button>Zuordnen</button></form></div></article>{{end}}`))