feat: add miller document layout

This commit is contained in:
2026-04-29 09:26:37 -04:00
parent 6e244d55db
commit 8c454de809
5 changed files with 333 additions and 72 deletions

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -28,15 +29,16 @@ type layoutData struct {
}
type indexData struct {
Documents []docs.DocumentRecord
Browser browserData
}
type documentData struct {
Title string
Path string
Tags []string
Hash string
HTML template.HTML
Browser browserData
Title string
Path string
Tags []string
Hash string
HTML template.HTML
}
type errorData struct {
@@ -44,6 +46,23 @@ type errorData struct {
Message string
}
type browserData struct {
Columns []browserColumn
}
type browserColumn struct {
Title string
Items []browserItem
}
type browserItem struct {
Name string
Path string
URL string
IsFolder bool
Active bool
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
@@ -51,13 +70,14 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
return
}
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
Title: "MD Hub Secure",
WebEnabled: s.webEnabled,
BodyClass: "page-index",
BodyTemplate: "index_content",
Data: indexData{
Documents: items,
Browser: buildBrowser(items, activeFolder),
},
})
}
@@ -78,17 +98,24 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
WebEnabled: s.webEnabled,
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Browser: buildBrowser(items, page.Path),
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
HTML: template.HTML(page.HTML),
},
})
}
@@ -240,3 +267,108 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
paths := make([]string, 0, len(records))
titleByPath := make(map[string]string, len(records))
for _, record := range records {
paths = append(paths, record.Path)
titleByPath[record.Path] = record.Title
}
prefixes := []string{""}
if activePath != "" {
parts := strings.Split(activePath, "/")
limit := len(parts)
if strings.HasSuffix(activePath, ".md") {
limit = len(parts) - 1
}
for i := 1; i <= limit; i++ {
prefixes = append(prefixes, strings.Join(parts[:i], "/"))
}
}
columns := make([]browserColumn, 0, len(prefixes))
for _, prefix := range prefixes {
column := browserColumn{
Title: columnTitle(prefix),
Items: buildBrowserItems(paths, titleByPath, prefix, activePath),
}
if len(column.Items) > 0 {
columns = append(columns, column)
}
}
return browserData{Columns: columns}
}
func buildBrowserItems(paths []string, titleByPath map[string]string, prefix string, activePath string) []browserItem {
seenFolders := make(map[string]browserItem)
files := make([]browserItem, 0)
prefixWithSlash := ""
if prefix != "" {
prefixWithSlash = prefix + "/"
}
for _, path := range paths {
if prefix != "" && !strings.HasPrefix(path, prefixWithSlash) {
continue
}
remainder := strings.TrimPrefix(path, prefixWithSlash)
if remainder == "" {
continue
}
if slash := strings.Index(remainder, "/"); slash >= 0 {
name := remainder[:slash]
folderPath := name
if prefix != "" {
folderPath = prefix + "/" + name
}
seenFolders[folderPath] = browserItem{
Name: name,
Path: folderPath,
URL: "/?folder=" + folderPath,
IsFolder: true,
Active: activePath == folderPath || strings.HasPrefix(activePath, folderPath+"/"),
}
continue
}
files = append(files, browserItem{
Name: displayDocumentName(path, titleByPath[path]),
Path: path,
URL: "/docs/" + strings.TrimSuffix(path, ".md"),
Active: activePath == path,
})
}
folders := make([]browserItem, 0, len(seenFolders))
for _, item := range seenFolders {
folders = append(folders, item)
}
sort.Slice(folders, func(i, j int) bool {
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
})
sort.Slice(files, func(i, j int) bool {
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
items := append(folders, files...)
return items
}
func columnTitle(prefix string) string {
if prefix == "" {
return "Content"
}
return filepath.Base(prefix)
}
func displayDocumentName(path string, title string) string {
if title != "" && title != "Untitled" {
return title
}
return strings.TrimSuffix(filepath.Base(path), ".md")
}

View File

@@ -79,27 +79,99 @@ code {
padding: 2rem 0 4rem;
}
.hero-panel,
.doc-list,
.document-shell,
.error-panel {
.error-panel,
.empty-preview {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
}
.hero-panel,
.error-panel {
.error-panel,
.empty-preview {
padding: 2rem;
}
.doc-list,
.document-shell {
margin-top: 1rem;
padding: 1.5rem 2rem;
}
.workspace-shell {
display: grid;
grid-template-columns: minmax(20rem, 0.42fr) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.workspace-shell--empty {
grid-template-columns: minmax(22rem, 1fr) minmax(18rem, 0.45fr);
}
.miller-browser {
display: grid;
grid-auto-columns: minmax(12rem, 1fr);
grid-auto-flow: column;
overflow-x: auto;
min-height: 22rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
}
.miller-column {
min-width: 12rem;
border-left: 1px solid var(--border);
}
.miller-column:first-child {
border-left: 0;
}
.miller-column h2 {
position: sticky;
top: 0;
z-index: 1;
margin: 0;
padding: 0.75rem 0.85rem;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.72);
color: var(--muted);
font: 700 0.76rem/1.2 ui-monospace, SFMono-Regular, monospace;
text-transform: uppercase;
}
.miller-column ul {
margin: 0;
padding: 0.35rem;
list-style: none;
}
.miller-column a {
display: flex;
min-height: 2.25rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.45rem 0.55rem;
border-radius: var(--radius-sm);
color: var(--text);
text-decoration: none;
}
.miller-column a:hover,
.miller-column a.is-active {
background: var(--accent-soft);
color: var(--accent);
}
.miller-column a span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eyebrow {
margin: 0 0 0.8rem;
color: var(--accent);
@@ -114,26 +186,12 @@ code {
line-height: 1.7;
}
.doc-list ul,
.tag-list {
margin: 0;
padding: 0;
list-style: none;
}
.doc-list li {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 0;
border-top: 1px solid var(--border);
}
.doc-list li:first-child {
border-top: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
@@ -142,14 +200,46 @@ code {
.tag-list a {
display: inline-flex;
padding: 0.35rem 0.65rem;
padding: 0.15rem 0.4rem;
border-radius: 999px;
background: var(--accent-soft);
text-decoration: none;
}
.hash {
.document-meta {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.document-meta h1 {
margin: 0 0 0.75rem;
font-size: 1.85rem;
line-height: 1.1;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.meta-grid div {
min-width: 0;
}
.meta-grid dt {
margin: 0 0 0.2rem;
color: var(--muted);
font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace;
text-transform: uppercase;
}
.meta-grid dd {
margin: 0;
color: var(--muted);
font-size: 0.86rem;
overflow-wrap: anywhere;
}
@@ -221,8 +311,20 @@ code {
padding: 0.75rem 0;
}
.doc-list,
.workspace-shell,
.workspace-shell--empty {
grid-template-columns: 1fr;
}
.miller-browser {
min-height: 14rem;
}
.document-shell {
padding: 1.25rem;
}
.meta-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,22 +1,58 @@
{{ define "document.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "document_content" }}
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<div class="document-meta">
<p class="eyebrow">{{ .Path }}</p>
<h1>{{ .Title }}</h1>
{{ if .Tags }}
<ul class="tag-list">
{{ range .Tags }}
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
<section class="workspace-shell">
{{ template "browser" .Browser }}
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<div class="document-meta">
<h1>{{ .Title }}</h1>
<dl class="meta-grid">
<div>
<dt>File</dt>
<dd><code>{{ .Path }}</code></dd>
</div>
<div>
<dt>Hash</dt>
<dd><code>{{ .Hash }}</code></dd>
</div>
{{ if .Tags }}
<div>
<dt>Tags</dt>
<dd>
<ul class="tag-list">
{{ range .Tags }}
<li><a href="/?tag={{ . }}">#{{ . }}</a></li>
{{ end }}
</ul>
</dd>
</div>
{{ end }}
</dl>
</div>
<div class="markdown-body">
{{ .HTML }}
</div>
</article>
</section>
{{ end }}
{{ define "browser" }}
<nav class="miller-browser" aria-label="Documents">
{{ range .Columns }}
<section class="miller-column" aria-label="{{ .Title }}">
<h2>{{ .Title }}</h2>
<ul>
{{ range .Items }}
<li>
<a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}">
<span>{{ .Name }}</span>
{{ if .IsFolder }}<span aria-hidden="true"></span>{{ end }}
</a>
</li>
{{ end }}
</ul>
{{ end }}
<p class="hash">SHA-256: <code>{{ .Hash }}</code></p>
</div>
<div class="markdown-body">
{{ .HTML }}
</div>
</article>
</section>
{{ end }}
</nav>
{{ end }}

View File

@@ -1,23 +1,11 @@
{{ define "index.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "index_content" }}
<section class="hero-panel">
<p class="eyebrow">Foundation</p>
<h1>Server-rendered Markdown, secure attachments, and a clean base for sync.</h1>
<p class="lede">This milestone focuses on a fast read path. Documents are synchronized from the content directory into content-addressed storage and rendered to HTML on demand.</p>
</section>
<section class="doc-list">
<h2>Documents</h2>
<ul>
{{ range .Documents }}
<li>
<a href="/docs/{{ trimMd .Path }}">{{ .Title }}</a>
<code>{{ .Path }}</code>
</li>
{{ else }}
<li>No documents found.</li>
{{ end }}
</ul>
<section class="workspace-shell workspace-shell--empty">
{{ template "browser" .Browser }}
<section class="empty-preview">
<p class="eyebrow">Documents</p>
<h1>MD Hub Secure</h1>
</section>
</section>
{{ end }}