feat: add miller document layout
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user