diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..ed479c5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + entrypoint = ["./tmp/main"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/Makefile b/Makefile index b506310..40eb844 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ SERVER_DIR := apps/server WEB_DIR := apps/web SERVER_BIN := $(SERVER_DIR)/bin/md-hub-secure -.PHONY: web-install web-build server-run server-build server-test docker-build ci fmt +.PHONY: web-install web-build web-dev server-run server-build server-test docker-build ci fmt dev dev-web dev-server web-install: cd $(WEB_DIR) && $(NPM) install @@ -13,6 +13,9 @@ web-install: web-build: cd $(WEB_DIR) && $(NPM) run build +web-dev: + cd $(WEB_DIR) && $(NPM) run dev + server-run: cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) run ./cmd/md-hub-secure @@ -23,6 +26,24 @@ server-build: server-test: cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) test ./... +dev-server: + cd $(SERVER_DIR) && MD_HUB_DEV_VITE_URL=http://localhost:5173 air + +dev-web: + cd $(WEB_DIR) && $(NPM) run dev + +dev: + @echo "Starting dev servers..." + @echo " - Go server (with air live reload) on http://localhost:8080" + @echo " - Vite dev server on http://localhost:5173" + @echo "" + @echo "Access the app at http://localhost:8080/app/" + @echo "" + @trap 'kill %1 %2 2>/dev/null || true' EXIT; \ + make dev-server & \ + make dev-web & \ + wait + docker-build: docker build -t md-hub-secure:dev . diff --git a/README.md b/README.md index f9dc4c9..be411e2 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,34 @@ This repository now contains the Milestone 1 foundation scaffold: - npm 11+ - Docker (optional) - CGO-capable toolchain for `go-libsql` +- `air` for Go live reload (`go install github.com/air-verse/air@latest`) -### Run locally +### Development Mode (with live reload) + +Run both the Go server and Vite dev server with live reload: + +```bash +make dev +``` + +This starts: +- Go server with `air` live reload on http://localhost:8080 +- Vite dev server with HMR on http://localhost:5173 +- The Go server proxies `/app/*` requests to Vite, so access the app at http://localhost:8080/app/ + +You can also run them separately in two terminals: + +```bash +# Terminal 1 - Go server with live reload +make dev-server + +# Terminal 2 - Vite dev server +make dev-web +``` + +### Production Mode + +Build the web app and run the Go server without Vite proxy: ```bash make web-install @@ -40,6 +66,7 @@ make server-test make server-build make docker-build make ci +make fmt ``` ## Configuration @@ -47,7 +74,7 @@ make ci Configuration can come from: 1. `config.json` via `MD_HUB_CONFIG` -2. environment variables +2. Environment variables Supported variables: @@ -58,6 +85,7 @@ Supported variables: - `MD_HUB_CONTENT_SOURCE_DIR` - `MD_HUB_CONTENT_STORE_DIR` - `MD_HUB_WEB_DIST_DIR` +- `MD_HUB_DEV_VITE_URL` - Set to proxy `/app/*` to Vite dev server (e.g. `http://localhost:5173`) - `MD_HUB_LOG_LEVEL` ## Noted Gaps @@ -65,4 +93,3 @@ Supported variables: - The docs describe both a single-binary system and a separate Vite/Preact app. This implementation keeps the Go server as the primary runtime and treats the web app as compiled static assets. - The docs call for `golang-migrate`, but the current foundation uses an internal migration runner while `go-libsql` integration details are validated. - The sync docs describe JSON messages while the Milestone 1 layout mentions a protobuf package; both are preserved in `packages/protocol/` pending a later protocol lock. - diff --git a/apps/server/.air.toml b/apps/server/.air.toml new file mode 100644 index 0000000..8e44ce2 --- /dev/null +++ b/apps/server/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./bin/md-hub-secure" + cmd = "CGO_ENABLED=1 go build -trimpath -o bin/md-hub-secure ./cmd/md-hub-secure" + delay = 500 + entrypoint = ["./bin/md-hub-secure"] + exclude_dir = ["assets", "tmp", "vendor", "testdata", "bin"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "css", "js"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = true + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/apps/server/go.mod b/apps/server/go.mod index ff3394a..8a963be 100644 --- a/apps/server/go.mod +++ b/apps/server/go.mod @@ -11,6 +11,8 @@ require ( require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect + golang.org/x/sys v0.13.0 // indirect ) diff --git a/apps/server/go.sum b/apps/server/go.sum index 679e1a0..78b8254 100644 --- a/apps/server/go.sum +++ b/apps/server/go.sum @@ -1,5 +1,7 @@ github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= @@ -18,5 +20,7 @@ golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6R golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/apps/server/internal/app/app.go b/apps/server/internal/app/app.go index 31f380e..2cc81b1 100644 --- a/apps/server/internal/app/app.go +++ b/apps/server/internal/app/app.go @@ -91,6 +91,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err func (a *App) Run(ctx context.Context) error { go a.watchDocuments(ctx) + go a.syncPoll(ctx) go func() { <-ctx.Done() @@ -107,7 +108,26 @@ func (a *App) Run(ctx context.Context) error { } func (a *App) watchDocuments(ctx context.Context) { - ticker := time.NewTicker(2 * time.Second) + watcher, err := NewFileWatcher(a.cfg.Content.SourceDir, a.logger) + if err != nil { + a.logger.Warn("file watcher failed, falling back to polling only", "error", err) + <-ctx.Done() + return + } + + watcher.SetOnChange(func(path string) { + if _, err := a.docs.SyncSourceDir(ctx); err != nil { + a.logger.Warn("document sync failed", "error", err) + } else { + a.logger.Debug("document synced", "path", path) + } + }) + + watcher.Run(ctx) +} + +func (a *App) syncPoll(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { diff --git a/apps/server/internal/app/watcher.go b/apps/server/internal/app/watcher.go new file mode 100644 index 0000000..829520a --- /dev/null +++ b/apps/server/internal/app/watcher.go @@ -0,0 +1,101 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/fsnotify/fsnotify" +) + +type FileWatcher struct { + watcher *fsnotify.Watcher + rootDir string + onChange func(path string) + logger *slog.Logger +} + +func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("create fsnotify watcher: %w", err) + } + + fw := &FileWatcher{ + watcher: watcher, + rootDir: rootDir, + logger: logger, + } + + if err := fw.addWatches(); err != nil { + watcher.Close() + return nil, err + } + + return fw, nil +} + +func (fw *FileWatcher) addWatches() error { + return filepath.Walk(fw.rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + if err := fw.watcher.Add(path); err != nil { + fw.logger.Warn("watch directory", "path", path, "error", err) + } + } + return nil + }) +} + +func (fw *FileWatcher) SetOnChange(fn func(path string)) { + fw.onChange = fn +} + +func (fw *FileWatcher) Run(ctx context.Context) { + defer fw.watcher.Close() + + for { + select { + case <-ctx.Done(): + return + case event, ok := <-fw.watcher.Events: + if !ok { + return + } + if fw.shouldProcess(event) { + if event.Op&fsnotify.Create == fsnotify.Create { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + fw.watcher.Add(event.Name) + } + } + if fw.onChange != nil { + fw.onChange(event.Name) + } + } + case err, ok := <-fw.watcher.Errors: + if !ok { + return + } + fw.logger.Warn("fsnotify error", "error", err) + } + } +} + +func (fw *FileWatcher) shouldProcess(event fsnotify.Event) bool { + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 { + return false + } + name := filepath.Base(event.Name) + if strings.HasPrefix(name, ".") || strings.HasSuffix(name, "~") { + return false + } + if filepath.Ext(event.Name) == ".md" || event.Op&fsnotify.Remove != 0 { + return true + } + return false +} diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index bc0683b..83c62d7 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -31,7 +31,8 @@ type ContentConfig struct { } type WebConfig struct { - DistDir string `json:"distDir"` + DistDir string `json:"distDir"` + DevViteURL string `json:"devViteUrl"` } func Default() Config { @@ -47,7 +48,8 @@ func Default() Config { StoreDir: filepath.Join("..", "..", "data", "files"), }, Web: WebConfig{ - DistDir: filepath.Join("..", "web", "dist"), + DistDir: filepath.Join("..", "web", "dist"), + DevViteURL: os.Getenv("MD_HUB_DEV_VITE_URL"), }, LogLevel: "INFO", } @@ -69,6 +71,7 @@ func Load() (Config, error) { overrideString(&cfg.Content.SourceDir, "MD_HUB_CONTENT_SOURCE_DIR") overrideString(&cfg.Content.StoreDir, "MD_HUB_CONTENT_STORE_DIR") overrideString(&cfg.Web.DistDir, "MD_HUB_WEB_DIST_DIR") + overrideString(&cfg.Web.DevViteURL, "MD_HUB_DEV_VITE_URL") overrideString(&cfg.LogLevel, "MD_HUB_LOG_LEVEL") if cfg.Server.Addr == "" { diff --git a/apps/server/internal/database/migrate.go b/apps/server/internal/database/migrate.go index a5a4864..4c87fe0 100644 --- a/apps/server/internal/database/migrate.go +++ b/apps/server/internal/database/migrate.go @@ -85,14 +85,53 @@ func migrationApplied(ctx context.Context, db *sql.DB, version string) (bool, er } func splitStatements(body string) []string { - raw := strings.Split(body, ";") - statements := make([]string, 0, len(raw)) - for _, statement := range raw { - statement = strings.TrimSpace(statement) - if statement == "" { + var statements []string + var current strings.Builder + inTrigger := false + depth := 0 + + lines := strings.Split(body, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "--") { continue } - statements = append(statements, statement) + + upper := strings.ToUpper(trimmed) + if strings.HasPrefix(upper, "CREATE TRIGGER") { + inTrigger = true + } + + if inTrigger { + current.WriteString(line) + current.WriteString("\n") + if strings.HasPrefix(upper, "BEGIN") { + depth++ + } + if strings.HasPrefix(upper, "END") { + depth-- + if depth == 0 { + inTrigger = false + statements = append(statements, strings.TrimSpace(current.String())) + current.Reset() + } + } + } else { + current.WriteString(line) + current.WriteString("\n") + if strings.HasSuffix(trimmed, ";") { + statements = append(statements, strings.TrimSpace(current.String())) + current.Reset() + } + } } + + if current.Len() > 0 { + stmt := strings.TrimSpace(current.String()) + if stmt != "" { + statements = append(statements, stmt) + } + } + return statements } diff --git a/apps/server/internal/database/migrations/000007_fts5.down.sql b/apps/server/internal/database/migrations/000007_fts5.down.sql new file mode 100644 index 0000000..ab0cf84 --- /dev/null +++ b/apps/server/internal/database/migrations/000007_fts5.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS document_search; diff --git a/apps/server/internal/database/migrations/000007_fts5.up.sql b/apps/server/internal/database/migrations/000007_fts5.up.sql new file mode 100644 index 0000000..95bf7b8 --- /dev/null +++ b/apps/server/internal/database/migrations/000007_fts5.up.sql @@ -0,0 +1,29 @@ +CREATE VIRTUAL TABLE IF NOT EXISTS document_search USING fts5( + title, + content, + path UNINDEXED, + tokenize='porter' +); + +INSERT INTO document_search (rowid, title, content, path) +SELECT rowid, title, '', path FROM documents; + +CREATE TRIGGER IF NOT EXISTS document_search_insert +AFTER INSERT ON documents +BEGIN + INSERT INTO document_search (rowid, title, content, path) + VALUES (NEW.rowid, NEW.title, '', NEW.path); +END; + +CREATE TRIGGER IF NOT EXISTS document_search_update +AFTER UPDATE ON documents +BEGIN + UPDATE document_search SET title = NEW.title, path = NEW.path + WHERE rowid = NEW.rowid; +END; + +CREATE TRIGGER IF NOT EXISTS document_search_delete +AFTER DELETE ON documents +BEGIN + DELETE FROM document_search WHERE rowid = OLD.rowid; +END; diff --git a/apps/server/internal/docs/repository.go b/apps/server/internal/docs/repository.go index 1942dbe..7657306 100644 --- a/apps/server/internal/docs/repository.go +++ b/apps/server/internal/docs/repository.go @@ -271,6 +271,47 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen return &record, nil } +type SearchResult struct { + Path string `json:"path"` + Title string `json:"title"` +} + +func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]SearchResult, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT d.path, d.title + FROM document_search ds + JOIN documents d ON d.rowid = ds.rowid + WHERE document_search MATCH ? + ORDER BY rank + LIMIT 50 + `, query) + if err != nil { + return nil, fmt.Errorf("search documents: %w", err) + } + defer rows.Close() + + var results []SearchResult + for rows.Next() { + var result SearchResult + if err := rows.Scan(&result.Path, &result.Title); err != nil { + return nil, fmt.Errorf("scan search result: %w", err) + } + results = append(results, result) + } + + return results, rows.Err() +} + +func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error { + if _, err := r.db.ExecContext(ctx, ` + UPDATE document_search SET content = ? + WHERE rowid = (SELECT rowid FROM documents WHERE path = ?) + `, content, path); err != nil { + return fmt.Errorf("update search content: %w", err) + } + return nil +} + func (r *Repository) Ping(ctx context.Context) error { return r.db.PingContext(ctx) } diff --git a/apps/server/internal/docs/service.go b/apps/server/internal/docs/service.go index 66793c6..dd0fcd7 100644 --- a/apps/server/internal/docs/service.go +++ b/apps/server/internal/docs/service.go @@ -200,6 +200,10 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e return nil, err } + if err := s.repo.UpdateSearchContent(ctx, relative, string(content)); err != nil { + s.logger.Warn("index search content", "path", relative, "error", err) + } + s.logger.Debug("synced document", "path", relative, "hash", record.Hash) return &DocumentChange{ Path: relative, diff --git a/apps/server/internal/httpserver/app_files.go b/apps/server/internal/httpserver/app_files.go index 54367d1..83fe3ea 100644 --- a/apps/server/internal/httpserver/app_files.go +++ b/apps/server/internal/httpserver/app_files.go @@ -2,12 +2,29 @@ package httpserver import ( "net/http" + "net/http/httputil" + "net/url" "os" "path/filepath" "strings" ) func (s *Server) appFileServer() http.Handler { + if s.config.Web.DevViteURL != "" { + viteURL, err := url.Parse(s.config.Web.DevViteURL) + if err == nil { + proxy := httputil.NewSingleHostReverseProxy(viteURL) + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + req.Host = viteURL.Host + // Add back the /app/ prefix that was stripped by http.StripPrefix + req.URL.Path = "/app" + req.URL.Path + } + return proxy + } + } + files := http.FileServer(http.Dir(s.config.Web.DistDir)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cleanPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index 95cc4bf..49fec63 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -33,12 +33,18 @@ type indexData struct { } type documentData struct { - Browser browserData - Title string - Path string - Tags []string - Hash string - HTML template.HTML + Browser browserData + Title string + Path string + Tags []string + Hash string + HTML template.HTML + Breadcrumbs []breadcrumb +} + +type breadcrumb struct { + Name string + URL string } type errorData struct { @@ -66,6 +72,12 @@ type browserItem struct { } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query != "" { + s.handleSearchPage(w, r, query) + return + } + items, err := s.documents.ListDocuments(r.Context()) if err != nil { s.renderError(w, r, http.StatusInternalServerError, err.Error()) @@ -84,6 +96,30 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query string) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + results, err := s.repository.SearchDocuments(ctx, query) + if err != nil { + s.logger.Warn("search failed", "error", err) + } + + s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{ + Title: "Search: " + query, + WebEnabled: s.webEnabled, + BodyClass: "page-search", + BodyTemplate: "search_content", + Data: struct { + Query string + Results []docs.SearchResult + }{ + Query: query, + Results: results, + }, + }) +} + func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) { s.renderDocumentPage(w, r, "") } @@ -116,12 +152,13 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page BodyClass: "page-document", BodyTemplate: "document_content", Data: documentData{ - Browser: buildBrowser(items, page.Path), - 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), + Breadcrumbs: buildBreadcrumbs(page.Path), }, }) } @@ -147,6 +184,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + results, err := s.repository.SearchDocuments(ctx, query) + if err != nil { + s.logger.Warn("search failed", "error", err) + writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"results": results}) +} + func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(32 << 20); err != nil { s.renderError(w, r, http.StatusBadRequest, "invalid multipart upload") @@ -264,6 +321,25 @@ func sanitizeFilename(name string) string { return strings.ReplaceAll(name, "\"", "") } +func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) { + records, err := s.documents.ListDocuments(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + results := make([]map[string]any, 0, len(records)) + for _, record := range records { + results = append(results, map[string]any{ + "path": record.Path, + "title": record.Title, + "hash": record.CurrentHash, + }) + } + + writeJSON(w, http.StatusOK, map[string]any{"documents": results}) +} + func writeJSON(w http.ResponseWriter, status int, payload any) { writeJSONWithStatus(w, status, payload) } @@ -424,3 +500,32 @@ func shouldHideIndexFile(path string, prefix string) bool { folder := strings.TrimSuffix(path, "/index.md") return folder == prefix } + +func buildBreadcrumbs(path string) []breadcrumb { + crumbs := []breadcrumb{ + {Name: "Content", URL: "/"}, + } + if path == "" { + return crumbs + } + + cleanPath := strings.TrimSuffix(path, "/index.md") + cleanPath = strings.TrimSuffix(cleanPath, ".md") + parts := strings.Split(cleanPath, "/") + currentPath := "" + for _, part := range parts { + if part == "" { + continue + } + if currentPath == "" { + currentPath = part + } else { + currentPath = currentPath + "/" + part + } + crumbs = append(crumbs, breadcrumb{ + Name: part, + URL: "/docs/" + currentPath, + }) + } + return crumbs +} diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go index 5672ae4..45d0cbc 100644 --- a/apps/server/internal/httpserver/middleware.go +++ b/apps/server/internal/httpserver/middleware.go @@ -39,7 +39,7 @@ func (s *Server) requestLogger(next http.Handler) http.Handler { func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ws: wss:; object-src 'none'") + w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self' ws: wss:; object-src 'none'") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index ed1e784..0dc42bd 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -46,6 +46,9 @@ func New(deps Dependencies) (http.Handler, error) { "trimMd": func(path string) string { return strings.TrimSuffix(path, ".md") }, + "sub": func(a, b int) int { + return a - b + }, }).ParseFS(assets, "templates/*.gohtml") if err != nil { return nil, err @@ -83,6 +86,8 @@ func New(deps Dependencies) (http.Handler, error) { router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync) router.Get("/docs", server.handleDocsIndex) router.Get("/docs/*", server.handleDocument) + router.Get("/api/documents", server.handleDocuments) + router.Get("/api/search", server.handleSearch) router.Post("/api/uploads", server.handleUpload) router.Get("/attachments/{hash}", server.handleAttachment) router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static")))) diff --git a/apps/server/internal/httpserver/static/cache.js b/apps/server/internal/httpserver/static/cache.js new file mode 100644 index 0000000..cfc907b --- /dev/null +++ b/apps/server/internal/httpserver/static/cache.js @@ -0,0 +1,100 @@ +(function () { + const DB_NAME = "md-hub-cache"; + const DB_VERSION = 1; + const STORE_DOCUMENTS = "documents"; + const STORE_CONTENT = "content"; + + function openDB() { + return new Promise(function (resolve, reject) { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = function () { + reject(request.error); + }; + request.onsuccess = function () { + resolve(request.result); + }; + request.onupgradeneeded = function (event) { + const db = event.target.result; + if (!db.objectStoreNames.contains(STORE_DOCUMENTS)) { + db.createObjectStore(STORE_DOCUMENTS, { keyPath: "path" }); + } + if (!db.objectStoreNames.contains(STORE_CONTENT)) { + db.createObjectStore(STORE_CONTENT, { keyPath: "path" }); + } + }; + }); + } + + function cacheDocuments(documents) { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + const tx = db.transaction(STORE_DOCUMENTS, "readwrite"); + const store = tx.objectStore(STORE_DOCUMENTS); + documents.forEach(function (doc) { + store.put(doc); + }); + tx.oncomplete = function () { + resolve(); + }; + tx.onerror = function () { + reject(tx.error); + }; + }); + }); + } + + function cacheContent(path, html, title) { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + const tx = db.transaction(STORE_CONTENT, "readwrite"); + const store = tx.objectStore(STORE_CONTENT); + store.put({ path: path, html: html, title: title, cachedAt: Date.now() }); + tx.oncomplete = function () { + resolve(); + }; + tx.onerror = function () { + reject(tx.error); + }; + }); + }); + } + + function getCachedDocuments() { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + const tx = db.transaction(STORE_DOCUMENTS, "readonly"); + const store = tx.objectStore(STORE_DOCUMENTS); + const request = store.getAll(); + request.onsuccess = function () { + resolve(request.result); + }; + request.onerror = function () { + reject(request.error); + }; + }); + }); + } + + function getCachedContent(path) { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + const tx = db.transaction(STORE_CONTENT, "readonly"); + const store = tx.objectStore(STORE_CONTENT); + const request = store.get(path); + request.onsuccess = function () { + resolve(request.result); + }; + request.onerror = function () { + reject(request.error); + }; + }); + }); + } + + window.MDHubCache = { + cacheDocuments: cacheDocuments, + cacheContent: cacheContent, + getCachedDocuments: getCachedDocuments, + getCachedContent: getCachedContent, + }; +})(); diff --git a/apps/server/internal/httpserver/static/realtime.js b/apps/server/internal/httpserver/static/realtime.js index 9c41a2c..aa5b17f 100644 --- a/apps/server/internal/httpserver/static/realtime.js +++ b/apps/server/internal/httpserver/static/realtime.js @@ -1,35 +1,225 @@ (function () { const notice = document.querySelector("[data-version-notice]"); const reload = document.querySelector("[data-version-reload]"); + const offlineNotice = document.querySelector("[data-offline-notice]"); const documentShell = document.querySelector("[data-document-path][data-document-hash]"); + const browser = document.querySelector(".miller-browser"); - if (!notice || !reload || !documentShell || !window.WebSocket) { + // Auto-scroll miller browser to show the rightmost (active) column + function scrollBrowserToRight() { + if (!browser) return; + requestAnimationFrame(function () { + // Only scroll if the rightmost column is not already visible + var tolerance = 50; + var maxScroll = browser.scrollWidth - browser.clientWidth; + if (maxScroll <= 0) return; // everything fits, no scroll needed + if (browser.scrollLeft >= maxScroll - tolerance) return; // already at right edge + browser.scrollTo({ + left: browser.scrollWidth, + behavior: "smooth", + }); + }); + } + + scrollBrowserToRight(); + + function updateOfflineUI() { + if (!offlineNotice) return; + if (navigator.onLine) { + offlineNotice.hidden = true; + } else { + offlineNotice.hidden = false; + } + } + + window.addEventListener("online", function () { + updateOfflineUI(); + fetchAndCacheDocuments(); + }); + + window.addEventListener("offline", function () { + updateOfflineUI(); + restoreBrowserFromCache(); + }); + + updateOfflineUI(); + + // Cache current page content + if (documentShell && window.MDHubCache) { + const path = documentShell.getAttribute("data-document-path"); + const title = document.title; + const html = documentShell.querySelector(".markdown-body")?.innerHTML; + if (path && html) { + window.MDHubCache.cacheContent(path, html, title).catch(function () {}); + } + } + + // Fetch and cache documents + function fetchAndCacheDocuments() { + if (!window.MDHubCache) return; + fetch("/api/documents") + .then(function (res) { + if (!res.ok) throw new Error("fetch failed"); + return res.json(); + }) + .then(function (data) { + if (data.documents) { + window.MDHubCache.cacheDocuments(data.documents).catch(function () {}); + } + }) + .catch(function () {}); + } + + fetchAndCacheDocuments(); + + // Restore browser from cache when offline + function restoreBrowserFromCache() { + if (!browser || !window.MDHubCache) return; + window.MDHubCache.getCachedDocuments().then(function (docs) { + if (!docs || docs.length === 0) return; + // The browser is already rendered server-side; we just keep it. + // If we wanted to rebuild it from cache, we'd need the full logic. + // For now, the existing browser HTML is likely cached by the browser itself. + }).catch(function () {}); + } + + // Intercept browser navigation when offline + if (browser) { + browser.addEventListener("click", function (event) { + const link = event.target.closest("a"); + if (!link) return; + if (navigator.onLine) return; + + const href = link.getAttribute("href"); + if (!href || href.startsWith("http") || href.startsWith("//")) return; + + // Try to serve from cache for document pages + if (href.startsWith("/docs/")) { + event.preventDefault(); + const path = href.replace("/docs/", ""); + window.MDHubCache.getCachedContent(path).then(function (cached) { + if (cached && cached.html) { + loadCachedDocument(path, cached.title, cached.html); + } else { + window.location.href = href; + } + }).catch(function () { + window.location.href = href; + }); + } + }); + } + + function loadCachedDocument(path, title, html) { + if (!documentShell) { + window.location.href = "/docs/" + path; + return; + } + + // Update URL without reloading + history.pushState({ cachedPath: path }, title, "/docs/" + path); + document.title = title; + + // Update document shell + documentShell.setAttribute("data-document-path", path); + documentShell.setAttribute("data-document-hash", ""); + const titleEl = documentShell.querySelector(".document-meta h1"); + if (titleEl) titleEl.textContent = title; + const pathEl = documentShell.querySelector(".meta-grid code"); + if (pathEl) pathEl.textContent = path; + const bodyEl = documentShell.querySelector(".markdown-body"); + if (bodyEl) bodyEl.innerHTML = html; + + // Re-render math and mermaid + if (typeof renderMath === "function") renderMath(); + if (typeof renderMermaid === "function") renderMermaid(); + + // Update active state in browser + updateBrowserActiveState(path); + } + + function updateBrowserActiveState(activePath) { + if (!browser) return; + browser.querySelectorAll("a.is-active").forEach(function (el) { + el.classList.remove("is-active"); + }); + browser.querySelectorAll("a").forEach(function (link) { + const href = link.getAttribute("href"); + if (href === "/docs/" + activePath || href === "/?folder=" + activePath) { + link.classList.add("is-active"); + } + }); + } + + // WebSocket realtime updates + if (!window.WebSocket) { return; } - const currentPath = documentShell.getAttribute("data-document-path"); - const currentHash = documentShell.getAttribute("data-document-hash"); + const currentPath = documentShell ? documentShell.getAttribute("data-document-path") : null; + const currentHash = documentShell ? documentShell.getAttribute("data-document-hash") : null; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const socket = new WebSocket(protocol + "//" + window.location.host + "/ws"); + let reconnectTimer = null; - socket.addEventListener("message", function (event) { - let payload; - try { - payload = JSON.parse(event.data); - } catch { - return; + function connect() { + const socket = new WebSocket(protocol + "//" + window.location.host + "/ws"); + + socket.addEventListener("open", function () { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }); + + socket.addEventListener("message", function (event) { + let payload; + try { + payload = JSON.parse(event.data); + } catch { + return; + } + + if (payload.type !== "document_version" || !payload.data) { + return; + } + + const change = payload.data; + + if (currentPath && change.path === currentPath && change.hash !== currentHash) { + if (notice) notice.hidden = false; + return; + } + + if (browser && !documentShell) { + if (notice) notice.hidden = false; + } + }); + + socket.addEventListener("close", function () { + reconnectTimer = setTimeout(connect, 3000); + }); + + socket.addEventListener("error", function () { + socket.close(); + }); + } + + connect(); + + if (reload) { + reload.addEventListener("click", function () { + window.location.reload(); + }); + } + + // Handle back/forward buttons for cached documents + window.addEventListener("popstate", function (event) { + if (event.state && event.state.cachedPath && window.MDHubCache) { + window.MDHubCache.getCachedContent(event.state.cachedPath).then(function (cached) { + if (cached && cached.html) { + loadCachedDocument(event.state.cachedPath, cached.title, cached.html); + } + }).catch(function () {}); } - - if (payload.type !== "document_version" || !payload.data) { - return; - } - - if (payload.data.path === currentPath && payload.data.hash !== currentHash) { - notice.hidden = false; - } - }); - - reload.addEventListener("click", function () { - window.location.reload(); }); })(); diff --git a/apps/server/internal/httpserver/static/render.js b/apps/server/internal/httpserver/static/render.js new file mode 100644 index 0000000..30b1926 --- /dev/null +++ b/apps/server/internal/httpserver/static/render.js @@ -0,0 +1,81 @@ +(function () { + function renderMermaid() { + if (typeof mermaid === "undefined") return; + const blocks = document.querySelectorAll("pre code.language-mermaid"); + blocks.forEach(function (block) { + const pre = block.parentElement; + const container = document.createElement("div"); + container.className = "mermaid"; + container.textContent = block.textContent; + pre.replaceWith(container); + }); + mermaid.initialize({ startOnLoad: false }); + mermaid.run({ querySelector: ".mermaid" }); + } + + function renderMath() { + if (typeof katex === "undefined") return; + + const blocks = document.querySelectorAll("pre code.language-math"); + blocks.forEach(function (block) { + const pre = block.parentElement; + const container = document.createElement("div"); + container.className = "katex-block"; + try { + katex.render(block.textContent.trim(), container, { + displayMode: true, + throwOnError: false, + }); + pre.replaceWith(container); + } catch (e) { + console.warn("KaTeX render failed:", e); + } + }); + + const markdownBody = document.querySelector(".markdown-body"); + if (!markdownBody) return; + + const html = markdownBody.innerHTML; + let newHtml = html; + + newHtml = newHtml.replace(/\$\$([\s\S]+?)\$\$/g, function (match, tex) { + try { + return katex.renderToString(tex.trim(), { + displayMode: true, + throwOnError: false, + }); + } catch (e) { + return match; + } + }); + + newHtml = newHtml.replace(/\$([^\s$][^$]*?)\$/g, function (match, tex) { + try { + return katex.renderToString(tex.trim(), { + displayMode: false, + throwOnError: false, + }); + } catch (e) { + return match; + } + }); + + if (newHtml !== html) { + markdownBody.innerHTML = newHtml; + } + } + + function init() { + renderMermaid(); + renderMath(); + } + + window.renderMermaid = renderMermaid; + window.renderMath = renderMath; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index 7a4ad8b..0558641 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -24,9 +24,9 @@ body { min-height: 100vh; color: var(--text); background: - radial-gradient(circle at top left, rgba(15, 91, 216, 0.16), transparent 34%), - radial-gradient(circle at bottom right, rgba(14, 163, 125, 0.13), transparent 28%), - linear-gradient(180deg, #fdfaf5 0%, #f5efe6 100%); + radial-gradient(circle at top left, rgba(56, 189, 248, 0.28), transparent 34%), + radial-gradient(circle at bottom right, rgba(99, 102, 241, 0.22), transparent 30%), + linear-gradient(180deg, #eef6ff 0%, #dbeafe 100%); } a { @@ -42,21 +42,21 @@ code { top: 0; z-index: 10; border-bottom: 1px solid rgba(255, 255, 255, 0.42); - background: rgba(251, 247, 239, 0.92); + background: rgba(238, 246, 255, 0.92); backdrop-filter: blur(12px); } .site-header__inner, .site-main { - width: min(1080px, calc(100vw - 2rem)); - margin: 0 auto; + width: 100%; } .site-header__inner { display: flex; align-items: center; justify-content: space-between; - min-height: 72px; + min-height: 64px; + padding: 0 1rem; } .site-brand { @@ -68,103 +68,195 @@ code { .site-nav { display: flex; - gap: 1rem; + gap: 1.25rem; } .site-nav a { text-decoration: none; + font-size: 0.95rem; +} + +.site-search { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + max-width: 320px; + margin: 0 1rem; +} + +.site-search input { + width: 100%; + padding: 0.45rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0; + background: var(--panel-strong); + font: inherit; + font-size: 0.9rem; +} + +.site-search input:focus { + outline: 2px solid var(--accent-soft); + border-color: var(--accent); +} + +.site-search button { + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-strong); + cursor: pointer; + font-size: 0.9rem; } .site-main { - padding: 2rem 0 4rem; -} - -.document-shell, -.error-panel, -.empty-preview { - border: 1px solid var(--border); - border-radius: var(--radius-lg); - background: var(--panel); - box-shadow: var(--shadow); -} - -.error-panel, -.empty-preview { - padding: 2rem; -} - -.document-shell { - padding: 1.5rem 2rem; + padding: 0; + height: calc(100vh - 64px); } +/* Workspace layout - adaptive grid */ .workspace-shell { display: grid; - grid-template-columns: minmax(20rem, 0.42fr) minmax(0, 1fr); - gap: 1rem; - align-items: start; + grid-template-columns: minmax(280px, 1fr) minmax(0, 2fr); + gap: 0; + align-items: stretch; + height: 100%; + max-width: 100%; } +/* Document view: sidebar gets reasonable fixed proportion */ +.workspace-shell--document { + grid-template-columns: minmax(260px, 0.38fr) minmax(0, 1fr); +} + +/* Empty state: more balanced 50/50 feel */ .workspace-shell--empty { - grid-template-columns: minmax(22rem, 1fr) minmax(18rem, 0.45fr); + grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr); } +/* Miller browser - flex layout so all columns are visible as vertical slices */ .miller-browser { - display: grid; - grid-auto-columns: minmax(12rem, 1fr); - grid-auto-flow: column; + display: flex; + flex-direction: row; overflow-x: auto; - min-height: 22rem; + min-height: 100%; + max-height: 100%; border: 1px solid var(--border); - border-radius: var(--radius-lg); + border-right: 0; + border-radius: 0; background: var(--panel); - box-shadow: var(--shadow); } -.miller-column { - min-width: 12rem; +.miller-browser + .document-shell { border-left: 1px solid var(--border); } +.miller-column { + flex: 0 0 auto; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Root column - fixed width for top-level navigation */ .miller-column:first-child { + width: 160px; border-left: 0; } +/* Middle columns - narrow slices (48px shows ~32px of content) */ +.miller-column:not(:first-child):not(:last-child) { + width: 48px; +} + +/* Active/last column - grows to fill remaining sidebar space */ +.miller-column:last-child { + flex: 1 1 auto; + min-width: 200px; +} + .miller-column h2 { position: sticky; top: 0; z-index: 1; margin: 0; - padding: 0.75rem 0.85rem; + padding: 0.7rem 0.8rem; 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; + font: 700 0.72rem/1.2 ui-monospace, SFMono-Regular, monospace; text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Middle column headers - show just first couple chars */ +.miller-column:not(:first-child):not(:last-child) h2 { + padding: 0.7rem 0.3rem; + text-align: center; +} + +.miller-column:not(:first-child):not(:last-child) h2 .miller-column-title-text { + display: inline-block; + max-width: 2rem; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; } .miller-column ul { margin: 0; - padding: 0.35rem; + padding: 0.3rem; list-style: none; + overflow-y: auto; + flex: 1; } .miller-column a { display: flex; - min-height: 2.25rem; + min-height: 2.1rem; align-items: center; justify-content: space-between; - gap: 0.75rem; - padding: 0.45rem 0.55rem; - border-radius: var(--radius-sm); + gap: 0.5rem; + padding: 0.4rem 0.5rem; + border-radius: 0; color: var(--text); text-decoration: none; + font-size: 0.94rem; +} + +/* Middle column items - icon + truncated text */ +.miller-column:not(:first-child):not(:last-child) a { + padding: 0.4rem 0.2rem; + justify-content: center; +} + +.miller-column:not(:first-child):not(:last-child) .browser-item-label { + justify-content: center; + gap: 0.15rem; +} + +.miller-column:not(:first-child):not(:last-child) .browser-item-name { + display: inline-block; + max-width: 1.6rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + +.miller-column:not(:first-child):not(:last-child) .browser-item-chevron { + display: none; } .browser-item-label { display: inline-flex; min-width: 0; align-items: center; - gap: 0.45rem; + gap: 0.4rem; } .browser-icon { @@ -178,7 +270,7 @@ code { .browser-icon--folder { margin-top: 0.1rem; border: 1px solid rgba(15, 91, 216, 0.24); - border-radius: 0.18rem; + border-radius: 0; background: var(--accent-soft); } @@ -190,18 +282,18 @@ code { height: 0.25rem; border: 1px solid rgba(15, 91, 216, 0.24); border-bottom: 0; - border-radius: 0.14rem 0.14rem 0 0; + border-radius: 0; background: var(--accent-soft); content: ""; } -.browser-icon--file { +.browser-icon--page { border: 1px solid var(--border); - border-radius: 0.16rem; + border-radius: 0; background: rgba(255, 255, 255, 0.72); } -.browser-icon--file::before { +.browser-icon--page::before { position: absolute; right: -1px; top: -1px; @@ -213,18 +305,66 @@ code { content: ""; } +.browser-icon--root { + width: 0.85rem; + height: 0.85rem; + border: 1.5px solid var(--accent); + border-radius: 0; + background: var(--accent-soft); +} + +.browser-icon--root::before { + position: absolute; + top: 50%; + left: 50%; + width: 0.3rem; + height: 0.3rem; + border-radius: 0; + background: var(--accent); + transform: translate(-50%, -50%); + content: ""; +} + +.miller-column--root h2 { + display: flex; + align-items: center; + gap: 0.35rem; +} + .miller-column a:hover, .miller-column a.is-active { background: var(--accent-soft); color: var(--accent); } -.browser-item-label span:last-child { +.browser-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Document shell */ +.document-shell, +.error-panel, +.empty-preview { + border: 1px solid var(--border); + border-left: 0; + border-radius: 0; + background: var(--panel); +} + +.error-panel, +.empty-preview { + padding: 2rem; +} + +.document-shell { + padding: 1.5rem 2rem; + min-height: 100%; + max-height: 100%; + overflow-y: auto; +} + .eyebrow { margin: 0 0 0.8rem; color: var(--accent); @@ -254,11 +394,52 @@ code { .tag-list a { display: inline-flex; padding: 0.15rem 0.4rem; - border-radius: 999px; + border-radius: 0; background: var(--accent-soft); text-decoration: none; } +.breadcrumbs { + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); +} + +.breadcrumbs ol { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.breadcrumbs li { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.breadcrumbs li:not(:last-child)::after { + content: "/"; + color: var(--muted); + margin-left: 0.25rem; +} + +.breadcrumbs a { + color: var(--accent); + text-decoration: none; +} + +.breadcrumbs a:hover { + text-decoration: underline; +} + +.breadcrumbs span[aria-current="page"] { + color: var(--text); + font-weight: 600; +} + .document-meta { margin-bottom: 1.5rem; padding-bottom: 1rem; @@ -273,7 +454,7 @@ code { .meta-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin: 0; } @@ -303,7 +484,7 @@ code { .markdown-body pre { overflow-x: auto; padding: 1rem; - border-radius: var(--radius-md); + border-radius: 0; background: #18202a; color: #f7f9fc; } @@ -312,7 +493,7 @@ code { margin-left: 0; padding: 0.9rem 1rem; border-left: 4px solid var(--accent); - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + border-radius: 0; background: rgba(15, 91, 216, 0.05); } @@ -330,9 +511,37 @@ code { max-width: min(28rem, calc(100vw - 2rem)); padding: 0.75rem 0.85rem; border: 1px solid var(--border); - border-radius: var(--radius-sm); + border-radius: 0; background: var(--panel-strong); - box-shadow: var(--shadow); +} + +.offline-notice { + position: fixed; + left: 1rem; + bottom: 1rem; + display: flex; + align-items: center; + gap: 0.6rem; + max-width: min(28rem, calc(100vw - 2rem)); + padding: 0.6rem 0.85rem; + border: 1px solid rgba(234, 179, 8, 0.4); + border-radius: 0; + background: rgba(254, 252, 232, 0.95); + color: #854d0e; + font-size: 0.9rem; +} + +.offline-notice[hidden] { + display: none; +} + +.offline-icon { + display: inline-block; + width: 0.6rem; + height: 0.6rem; + border-radius: 0; + background: #eab308; + flex-shrink: 0; } .version-notice[hidden] { @@ -348,36 +557,155 @@ code { min-height: 2.2rem; padding: 0 0.8rem; border: 0; - border-radius: var(--radius-sm); + border-radius: 0; color: white; background: var(--accent); font: 700 0.9rem/1 ui-monospace, SFMono-Regular, monospace; cursor: pointer; } -@media (max-width: 720px) { +/* Adaptive breakpoints */ +@media (max-width: 1024px) { + .workspace-shell, + .workspace-shell--document, + .workspace-shell--empty { + grid-template-columns: minmax(240px, 0.45fr) minmax(0, 1fr); + gap: 0; + } + + .document-shell { + padding: 1.25rem 1.5rem; + } +} + +@media (max-width: 768px) { .site-header__inner { flex-direction: column; align-items: flex-start; justify-content: center; gap: 0.5rem; - padding: 0.75rem 0; + padding: 0.75rem 1rem; + min-height: auto; + } + + .site-main { + height: auto; + min-height: calc(100vh - 64px); } .workspace-shell, + .workspace-shell--document, .workspace-shell--empty { grid-template-columns: 1fr; + gap: 0; } .miller-browser { - min-height: 14rem; + min-height: 16rem; + max-height: 50vh; + border-right: 1px solid var(--border); + border-bottom: 0; + } + + .miller-column, + .miller-column:first-child, + .miller-column:last-child, + .miller-column:not(:first-child):not(:last-child) { + flex: 0 0 auto; + width: auto; + min-width: 9rem; + max-width: none; + } + + .miller-column:not(:first-child):not(:last-child) h2 { + padding: 0.7rem 0.8rem; + text-align: left; + } + + .miller-column:not(:first-child):not(:last-child) h2 .miller-column-title-text { + max-width: none; + } + + .miller-column:not(:first-child):not(:last-child) a { + padding: 0.4rem 0.5rem; + justify-content: space-between; + } + + .miller-column:not(:first-child):not(:last-child) .browser-item-label { + justify-content: flex-start; + gap: 0.4rem; + } + + .miller-column:not(:first-child):not(:last-child) .browser-item-name { + max-width: none; + } + + .miller-column:not(:first-child):not(:last-child) .browser-item-chevron { + display: inline; } .document-shell { padding: 1.25rem; + min-height: auto; + max-height: none; + border-left: 1px solid var(--border); + border-top: 0; } .meta-grid { grid-template-columns: 1fr; } } + +@media (min-width: 1600px) { + .workspace-shell--document { + grid-template-columns: minmax(300px, 0.32fr) minmax(0, 1fr); + } +} + +/* Search results */ +.search-panel { + max-width: 800px; + padding: 2rem; + border: 1px solid var(--border); + border-radius: 0; + background: var(--panel); +} + +.search-results { + margin: 1.5rem 0 0; + padding: 0; + list-style: none; +} + +.search-results li { + border-bottom: 1px solid var(--border); +} + +.search-results li:last-child { + border-bottom: 0; +} + +.search-results a { + display: block; + padding: 1rem 0; + color: var(--text); + text-decoration: none; +} + +.search-results a:hover { + color: var(--accent); +} + +.search-result-title { + display: block; + font-weight: 600; + font-size: 1.1rem; +} + +.search-result-path { + display: block; + color: var(--muted); + font-size: 0.85rem; + font-family: ui-monospace, SFMono-Regular, monospace; +} diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml index 4037504..6cb881b 100644 --- a/apps/server/internal/httpserver/templates/base.gohtml +++ b/apps/server/internal/httpserver/templates/base.gohtml @@ -6,11 +6,18 @@