diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index f476c14..d70833c 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -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") +} diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index eb1dd51..1173862 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -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; + } } diff --git a/apps/server/internal/httpserver/templates/document.gohtml b/apps/server/internal/httpserver/templates/document.gohtml index bf31883..858778f 100644 --- a/apps/server/internal/httpserver/templates/document.gohtml +++ b/apps/server/internal/httpserver/templates/document.gohtml @@ -1,22 +1,58 @@ {{ define "document.gohtml" }}{{ template "base" . }}{{ end }} {{ define "document_content" }} -
-
-

{{ .Path }}

-

{{ .Title }}

- {{ if .Tags }} -
- -
- {{ .HTML }} -
-
+ + {{ end }} + {{ end }} diff --git a/apps/server/internal/httpserver/templates/index.gohtml b/apps/server/internal/httpserver/templates/index.gohtml index 127cea2..2263174 100644 --- a/apps/server/internal/httpserver/templates/index.gohtml +++ b/apps/server/internal/httpserver/templates/index.gohtml @@ -1,23 +1,11 @@ {{ define "index.gohtml" }}{{ template "base" . }}{{ end }} {{ define "index_content" }} -
-

Foundation

-

Server-rendered Markdown, secure attachments, and a clean base for sync.

-

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.

-
- -
-

Documents

- +
+ {{ template "browser" .Browser }} +
+

Documents

+

MD Hub Secure

+
{{ end }} diff --git a/apps/server/internal/markdown/renderer.go b/apps/server/internal/markdown/renderer.go index a43ab31..f7dc98d 100644 --- a/apps/server/internal/markdown/renderer.go +++ b/apps/server/internal/markdown/renderer.go @@ -15,7 +15,8 @@ import ( ) var ( - tagPattern = regexp.MustCompile(`(^|[\s(])#([a-zA-Z0-9_-]+)\b`) + tagPattern = regexp.MustCompile(`(^|[\s(])#([a-zA-Z0-9_-]+)\b`) + firstH1Pattern = regexp.MustCompile(`(?s)^\s*]*>.*?\s*`) ) type Result struct { @@ -61,8 +62,10 @@ func (r *Renderer) Render(content []byte) (Result, error) { return Result{}, fmt.Errorf("render markdown: %w", err) } + html := firstH1Pattern.ReplaceAllString(output.String(), "") + return Result{ - HTML: template.HTML(output.String()), + HTML: template.HTML(html), Title: title, Tags: tags, }, nil