diff --git a/apps/server/internal/docs/repository.go b/apps/server/internal/docs/repository.go index bcf0c13..5e4fae0 100644 --- a/apps/server/internal/docs/repository.go +++ b/apps/server/internal/docs/repository.go @@ -192,6 +192,34 @@ func (r *Repository) MoveDocument(ctx context.Context, input MoveDocumentInput) return nil } +func (r *Repository) MoveFolderRelated(ctx context.Context, oldPrefix, newPrefix string) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin folder metadata move: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, ` + UPDATE permissions + SET resource_id = ? || substr(resource_id, length(?) + 1) + WHERE resource_type = 'folder' AND (resource_id = ? OR resource_id LIKE ? || '/%') + `, newPrefix, oldPrefix, oldPrefix, oldPrefix); err != nil { + return fmt.Errorf("move folder permissions: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + UPDATE OR IGNORE watchers + SET folder_path = ? || substr(folder_path, length(?) + 1) + WHERE folder_path IS NOT NULL AND folder_path != '' AND (folder_path = ? OR folder_path LIKE ? || '/%') + `, newPrefix, oldPrefix, oldPrefix, oldPrefix); err != nil { + return fmt.Errorf("move folder watchers: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit folder metadata move: %w", err) + } + return nil +} + func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) { var ( record DocumentRecord diff --git a/apps/server/internal/docs/service.go b/apps/server/internal/docs/service.go index a0ece48..9dcd61d 100644 --- a/apps/server/internal/docs/service.go +++ b/apps/server/internal/docs/service.go @@ -274,6 +274,207 @@ func (s *Service) MoveDocument(ctx context.Context, requestPath, destinationPath return updated, nil } +func (s *Service) MoveFolder(ctx context.Context, oldPrefix, newPrefix string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, err := s.syncSourceDirLocked(ctx); err != nil { + return 0, err + } + oldPath, err := NormalizeFolderPath(oldPrefix) + if err != nil { + return 0, err + } + newPath, err := NormalizeFolderPath(newPrefix) + if err != nil { + return 0, err + } + if oldPath == newPath { + return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"} + } + if strings.HasPrefix(newPath, oldPath+"/") { + return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "cannot move a folder into itself"} + } + + records, err := s.repo.ListDocuments(ctx) + if err != nil { + return 0, err + } + moving := make([]DocumentRecord, 0) + for _, record := range records { + if strings.HasPrefix(record.Path, oldPath+"/") { + moving = append(moving, record) + } + } + if len(moving) == 0 { + return 0, sql.ErrNoRows + } + + newFullDir := filepath.Join(s.sourceDir, filepath.FromSlash(newPath)) + if info, err := os.Stat(newFullDir); err == nil && info.IsDir() { + return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination folder already exists"} + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return 0, fmt.Errorf("check destination folder: %w", err) + } + + for _, record := range moving { + destination := newPath + strings.TrimPrefix(record.Path, oldPath) + if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, destination); err == nil { + return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists: " + destination} + } else if !errors.Is(err, sql.ErrNoRows) { + return 0, err + } + if _, err := os.Stat(filepath.Join(s.sourceDir, filepath.FromSlash(destination))); err == nil { + return 0, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists: " + destination} + } else if !errors.Is(err, os.ErrNotExist) { + return 0, fmt.Errorf("check destination file: %w", err) + } + } + + type movedPair struct { + record DocumentRecord + newPath string + newTitle string + oldFull string + newFull string + dbUpdated bool + } + moved := make([]movedPair, 0, len(moving)) + rollback := func(moveErr error) (int, error) { + for i := len(moved) - 1; i >= 0; i-- { + pair := moved[i] + if pair.dbUpdated { + if err := s.repo.MoveDocument(ctx, MoveDocumentInput{ + ID: pair.record.ID, + OldPath: pair.newPath, + NewPath: pair.record.Path, + ExpectedHash: pair.record.CurrentHash, + Title: pair.record.Title, + }); err != nil { + return 0, fmt.Errorf("move folder failed: %v; database rollback failed for %s: %w", moveErr, pair.newPath, err) + } + } + if err := os.Rename(pair.newFull, pair.oldFull); err != nil { + return 0, fmt.Errorf("move folder failed: %v; filesystem rollback failed for %s: %w", moveErr, pair.newFull, err) + } + } + return 0, moveErr + } + + for _, record := range moving { + destination := newPath + strings.TrimPrefix(record.Path, oldPath) + oldFull := filepath.Join(s.sourceDir, filepath.FromSlash(record.Path)) + newFull := filepath.Join(s.sourceDir, filepath.FromSlash(destination)) + content, err := os.ReadFile(oldFull) + if err != nil { + return rollback(fmt.Errorf("read source document: %w", err)) + } + rendered, err := s.renderer.RenderPath(content, destination) + if err != nil { + return rollback(fmt.Errorf("render moved document: %w", err)) + } + if err := os.MkdirAll(filepath.Dir(newFull), 0o755); err != nil { + return rollback(fmt.Errorf("create destination folder: %w", err)) + } + if err := os.Rename(oldFull, newFull); err != nil { + return rollback(fmt.Errorf("move document file: %w", err)) + } + pair := movedPair{record: record, newPath: destination, newTitle: rendered.Title, oldFull: oldFull, newFull: newFull} + if err := s.repo.MoveDocument(ctx, MoveDocumentInput{ + ID: record.ID, + OldPath: record.Path, + NewPath: destination, + ExpectedHash: record.CurrentHash, + Title: rendered.Title, + }); err != nil { + moved = append(moved, pair) + return rollback(err) + } + pair.dbUpdated = true + moved = append(moved, pair) + } + + if err := s.repo.MoveFolderRelated(ctx, oldPath, newPath); err != nil { + return rollback(fmt.Errorf("move folder metadata: %w", err)) + } + + deepestOldDir := "" + for _, pair := range moved { + if dir := filepath.Dir(pair.oldFull); len(dir) > len(deepestOldDir) { + deepestOldDir = dir + } + } + if deepestOldDir != "" { + removeEmptyDirsUpTo(s.sourceDir, deepestOldDir) + } + + if s.onChange != nil { + for _, pair := range moved { + s.onChange(DocumentChange{ + Type: "move", + DocumentID: pair.record.ID, + Path: pair.newPath, + OldPath: pair.record.Path, + Title: pair.newTitle, + Hash: pair.record.CurrentHash, + }) + } + } + return len(moved), nil +} + +func (s *Service) ArchiveFolder(ctx context.Context, prefix string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, err := s.syncSourceDirLocked(ctx); err != nil { + return 0, err + } + folder, err := NormalizeFolderPath(prefix) + if err != nil { + return 0, err + } + + records, err := s.repo.ListDocuments(ctx) + if err != nil { + return 0, err + } + archived := make([]DocumentRecord, 0) + for _, record := range records { + if !strings.HasPrefix(record.Path, folder+"/") { + continue + } + if err := s.repo.ArchiveDocument(ctx, record.Path); err != nil { + return len(archived), fmt.Errorf("archive document %s: %w", record.Path, err) + } + archived = append(archived, record) + } + if len(archived) == 0 { + return 0, sql.ErrNoRows + } + + if s.onChange != nil { + for _, record := range archived { + s.onChange(DocumentChange{ + DocumentID: record.ID, + Path: record.Path, + Title: record.Title, + Hash: record.CurrentHash, + }) + } + } + return len(archived), nil +} + +func removeEmptyDirsUpTo(root, dir string) { + for dir != root && strings.HasPrefix(dir, root) { + if err := os.Remove(dir); err != nil { + return + } + dir = filepath.Dir(dir) + } +} + func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) { if _, err := s.SyncSourceDir(ctx); err != nil { return nil, err @@ -450,6 +651,26 @@ func NormalizeDocumentPath(path string) (string, error) { return normalizeWritableDocumentPath(path) } +func NormalizeFolderPath(path string) (string, error) { + path = strings.Trim(path, "/") + if path == "" { + return "", fmt.Errorf("folder path is required") + } + path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) + if path == "." || path == ".." || strings.HasPrefix(path, "../") || filepath.IsAbs(path) { + return "", fmt.Errorf("invalid folder path") + } + for _, segment := range strings.Split(path, "/") { + if segment == "" || segment == "." || segment == ".." { + return "", fmt.Errorf("invalid folder path") + } + if strings.HasSuffix(strings.ToLower(segment), ".md") { + return "", fmt.Errorf("folder names must not end with .md") + } + } + return path, nil +} + func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) { content, err := os.ReadFile(path) if err != nil { diff --git a/apps/server/internal/docs/service_test.go b/apps/server/internal/docs/service_test.go index ce959f7..338baf0 100644 --- a/apps/server/internal/docs/service_test.go +++ b/apps/server/internal/docs/service_test.go @@ -361,6 +361,219 @@ func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) { } } +func TestMoveFolderMovesAllDocumentsAndMetadata(t *testing.T) { + service, sourceDir := setupDocsTestService(t) + ctx := context.Background() + + if err := os.MkdirAll(filepath.Join(sourceDir, "guides", "deep"), 0o755); err != nil { + t.Fatalf("create guides folder: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil { + t.Fatalf("create intro.md: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "guides", "deep", "dive.md"), []byte("# Dive\n"), 0o644); err != nil { + t.Fatalf("create dive.md: %v", err) + } + if _, err := service.SyncSourceDir(ctx); err != nil { + t.Fatalf("SyncSourceDir() error = %v", err) + } + + if _, err := service.repo.db.ExecContext(ctx, ` + INSERT INTO users (id, email, display_name, created_at) VALUES ('user-folder', 'folder@example.com', 'Folder', ?) + `, time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatalf("create user: %v", err) + } + if _, err := service.repo.db.ExecContext(ctx, ` + INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at) + VALUES ('permission-folder', 'user-folder', 'folder', 'guides/deep', 'write', ?) + `, time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatalf("create folder permission: %v", err) + } + if _, err := service.repo.db.ExecContext(ctx, ` + INSERT INTO watchers (user_id, document_id, folder_path, created_at) + VALUES ('user-folder', NULL, 'guides', ?) + `, time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatalf("create folder watcher: %v", err) + } + + var changes []DocumentChange + service.OnChange(func(change DocumentChange) { + changes = append(changes, change) + }) + + moved, err := service.MoveFolder(ctx, "guides", "handbook") + if err != nil { + t.Fatalf("MoveFolder() error = %v", err) + } + if moved != 2 { + t.Fatalf("moved count = %d, want 2", moved) + } + for _, oldPath := range []string{"guides/intro.md", "guides/deep/dive.md"} { + if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(oldPath))); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("old file %s still exists or stat failed: %v", oldPath, err) + } + } + for oldPath, newPath := range map[string]string{"guides/intro.md": "handbook/intro.md", "guides/deep/dive.md": "handbook/deep/dive.md"} { + if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(newPath))); err != nil { + t.Fatalf("new file %s missing: %v", newPath, err) + } + record, err := service.repo.GetDocumentByPath(ctx, newPath) + if err != nil { + t.Fatalf("get moved record %s: %v", newPath, err) + } + alias, err := service.repo.GetDocumentByPathOrAlias(ctx, oldPath) + if err != nil { + t.Fatalf("resolve alias for %s: %v", oldPath, err) + } + if alias.ID != record.ID { + t.Fatalf("alias for %s resolved to %q, want %q", oldPath, alias.ID, record.ID) + } + } + if _, err := os.Stat(filepath.Join(sourceDir, "guides")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("empty source folder not removed: %v", err) + } + + var permissionFolder string + if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-folder'`).Scan(&permissionFolder); err != nil { + t.Fatalf("get folder permission: %v", err) + } + if permissionFolder != "handbook/deep" { + t.Fatalf("permission folder = %q, want handbook/deep", permissionFolder) + } + var watcherFolder string + if err := service.repo.db.QueryRowContext(ctx, `SELECT folder_path FROM watchers WHERE user_id = 'user-folder'`).Scan(&watcherFolder); err != nil { + t.Fatalf("get watcher: %v", err) + } + if watcherFolder != "handbook" { + t.Fatalf("watcher folder = %q, want handbook", watcherFolder) + } + if len(changes) != 2 { + t.Fatalf("move changes = %d, want 2", len(changes)) + } + for _, change := range changes { + if change.Type != "move" { + t.Fatalf("change type = %q, want move", change.Type) + } + } +} + +func TestMoveFolderRejectsInvalidDestinations(t *testing.T) { + service, sourceDir := setupDocsTestService(t) + ctx := context.Background() + + if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil { + t.Fatalf("create guides folder: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil { + t.Fatalf("create intro.md: %v", err) + } + if _, err := service.SyncSourceDir(ctx); err != nil { + t.Fatalf("SyncSourceDir() error = %v", err) + } + + var conflict *DocumentMoveConflictError + if _, err := service.MoveFolder(ctx, "guides", "guides"); !errors.As(err, &conflict) { + t.Fatalf("unchanged MoveFolder() error = %v, want DocumentMoveConflictError", err) + } + if _, err := service.MoveFolder(ctx, "guides", "guides/nested"); !errors.As(err, &conflict) { + t.Fatalf("self-nested MoveFolder() error = %v, want DocumentMoveConflictError", err) + } + if _, err := service.MoveFolder(ctx, "guides", "../outside"); err == nil { + t.Fatal("expected traversal destination to be rejected") + } + if _, err := service.MoveFolder(ctx, "missing", "handbook"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("missing folder MoveFolder() error = %v, want sql.ErrNoRows", err) + } + if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil { + t.Fatalf("source file changed after rejected moves: %v", err) + } +} + +func TestArchiveFolderArchivesAllDocuments(t *testing.T) { + service, sourceDir := setupDocsTestService(t) + ctx := context.Background() + + if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil { + t.Fatalf("create guides folder: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil { + t.Fatalf("create intro.md: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "guides", "dive.md"), []byte("# Dive\n"), 0o644); err != nil { + t.Fatalf("create dive.md: %v", err) + } + if _, err := service.SyncSourceDir(ctx); err != nil { + t.Fatalf("SyncSourceDir() error = %v", err) + } + + archived, err := service.ArchiveFolder(ctx, "guides") + if err != nil { + t.Fatalf("ArchiveFolder() error = %v", err) + } + if archived != 2 { + t.Fatalf("archived count = %d, want 2", archived) + } + + records, err := service.repo.ListDocuments(ctx) + if err != nil { + t.Fatalf("ListDocuments() error = %v", err) + } + for _, record := range records { + if record.Path == "guides/intro.md" || record.Path == "guides/dive.md" { + t.Fatalf("document %s still listed after archive", record.Path) + } + } + archivedRecords, err := service.repo.ListArchivedDocuments(ctx) + if err != nil { + t.Fatalf("ListArchivedDocuments() error = %v", err) + } + if len(archivedRecords) != 2 { + t.Fatalf("archived records = %d, want 2", len(archivedRecords)) + } + if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil { + t.Fatalf("archived file removed from disk: %v", err) + } + + if _, err := service.ArchiveFolder(ctx, "guides"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("re-archive error = %v, want sql.ErrNoRows", err) + } +} + +func TestNormalizeFolderPathValidation(t *testing.T) { + tests := []struct { + name string + path string + want string + wantErr bool + }{ + {name: "simple", path: "guides", want: "guides"}, + {name: "nested with slashes", path: "/guides/deep/", want: "guides/deep"}, + {name: "empty", path: "", wantErr: true}, + {name: "traversal", path: "../outside", wantErr: true}, + {name: "leading slash", path: "/etc", want: "etc"}, + {name: "markdown suffix", path: "guides.md", wantErr: true}, + {name: "nested markdown segment", path: "guides/notes.md/deep", wantErr: true}, + {name: "cleaned dot segment", path: "guides/./deep", want: "guides/deep"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeFolderPath(tt.path) + if tt.wantErr { + if err == nil { + t.Fatalf("NormalizeFolderPath(%q) = %q, want error", tt.path, got) + } + return + } + if err != nil { + t.Fatalf("NormalizeFolderPath(%q) error = %v", tt.path, err) + } + if got != tt.want { + t.Fatalf("NormalizeFolderPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + func setupDocsTestService(t *testing.T) (*Service, string) { t.Helper() diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index 4d9cc2c..1c4be45 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -1067,6 +1067,92 @@ func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Reques writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"}) } +func (s *Server) handleFolderMutation(w http.ResponseWriter, r *http.Request) { + folderPath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*")) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid folder path"}) + return + } + switch { + case strings.HasSuffix(folderPath, "/move"): + s.handleFolderMovePath(w, r, strings.TrimSuffix(folderPath, "/move")) + case strings.HasSuffix(folderPath, "/archive"): + s.handleFolderArchivePath(w, r, strings.TrimSuffix(folderPath, "/archive")) + default: + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "unsupported folder operation"}) + } +} + +func (s *Server) handleFolderMovePath(w http.ResponseWriter, r *http.Request, folderPath string) { + folder, err := docs.NormalizeFolderPath(folderPath) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + destination, err := docs.NormalizeFolderPath(req.Path) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if !s.canWriteFolder(r, folder) || !s.canWriteFolder(r, documentFolder(destination)) { + writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"}) + return + } + + moved, err := s.documents.MoveFolder(r.Context(), folder, destination) + if err != nil { + var conflict *docs.DocumentMoveConflictError + switch { + case errors.As(err, &conflict): + writeJSONWithStatus(w, http.StatusConflict, map[string]string{"error": conflict.Error()}) + case errors.Is(err, sql.ErrNoRows): + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"}) + default: + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return + } + writeJSONWithStatus(w, http.StatusOK, map[string]any{ + "status": "moved", + "path": destination, + "url": "/?folder=" + queryEscape(destination), + "count": moved, + }) +} + +func (s *Server) handleFolderArchivePath(w http.ResponseWriter, r *http.Request, folderPath string) { + folder, err := docs.NormalizeFolderPath(folderPath) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if !s.canWriteFolder(r, folder) { + writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"}) + return + } + + archived, err := s.documents.ArchiveFolder(r.Context(), folder) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"}) + return + } + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSONWithStatus(w, http.StatusOK, map[string]any{ + "status": "archived", + "count": archived, + }) +} + func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) { if pagePath == "" { writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"}) diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index de2fed9..e1c8de7 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -153,6 +153,7 @@ func New(deps Dependencies) (http.Handler, error) { router.Get("/documents/{id}", server.handleDocumentByID) router.Get("/api/documents", server.handleDocuments) router.Post("/api/documents/*", server.handleDocumentMutation) + router.Post("/api/folders/*", server.handleFolderMutation) router.Get("/api/search", server.handleSearch) router.Get("/api/permissions", server.handlePermissionsAPI) router.Patch("/api/permissions", server.handlePermissionsAPI) diff --git a/apps/server/internal/httpserver/static/folder-actions.js b/apps/server/internal/httpserver/static/folder-actions.js new file mode 100644 index 0000000..4d9a733 --- /dev/null +++ b/apps/server/internal/httpserver/static/folder-actions.js @@ -0,0 +1,101 @@ +(function () { + 'use strict'; + + function apiFolderPath(path) { + return (path || '') + .replace(/^\/+/, '') + .split('/') + .map(encodeURIComponent) + .join('/'); + } + + function parentFolder(path) { + var index = path.lastIndexOf('/'); + return index < 0 ? '' : path.slice(0, index); + } + + function baseName(path) { + var index = path.lastIndexOf('/'); + return index < 0 ? path : path.slice(index + 1); + } + + function postFolder(path, operation, body) { + return fetch('/api/folders/' + apiFolderPath(path) + '/' + operation, { + method: 'POST', + credentials: 'same-origin', + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }).then(function (response) { + return response.json().then(function (data) { + if (!response.ok) throw new Error(data.error || 'Folder operation failed'); + return data; + }); + }); + } + + function closeMenu(button) { + var details = button.closest('details'); + if (details) details.removeAttribute('open'); + } + + document.addEventListener('click', function (event) { + var renameButton = event.target.closest('[data-folder-rename-path]'); + if (renameButton) { + var renamePath = renameButton.getAttribute('data-folder-rename-path'); + var newName = window.prompt('Rename folder to:', baseName(renamePath)); + if (newName === null) return; + newName = newName.trim().replace(/^\/+|\/+$/g, ''); + if (newName === '' || newName === baseName(renamePath)) return; + var parent = parentFolder(renamePath); + var renameDestination = (parent ? parent + '/' : '') + newName; + renameButton.disabled = true; + postFolder(renamePath, 'move', { path: renameDestination }) + .then(function (data) { + window.location.assign(data.url); + }) + .catch(function (error) { + window.alert(error.message || 'Failed to rename folder'); + renameButton.disabled = false; + }); + return; + } + + var moveButton = event.target.closest('[data-folder-move-path]'); + if (moveButton) { + var movePath = moveButton.getAttribute('data-folder-move-path'); + var destination = window.prompt('Move folder to:', movePath); + if (destination === null) return; + destination = destination.trim().replace(/^\/+|\/+$/g, ''); + if (destination === '' || destination === movePath) return; + moveButton.disabled = true; + postFolder(movePath, 'move', { path: destination }) + .then(function (data) { + window.location.assign(data.url); + }) + .catch(function (error) { + window.alert(error.message || 'Failed to move folder'); + moveButton.disabled = false; + }); + return; + } + + var archiveButton = event.target.closest('[data-folder-archive-path]'); + if (archiveButton) { + var archivePath = archiveButton.getAttribute('data-folder-archive-path'); + if (!archivePath) return; + if (!window.confirm('Archive this folder and all its documents? They will be hidden from the site but can be restored later.')) { + return; + } + archiveButton.disabled = true; + closeMenu(archiveButton); + postFolder(archivePath, 'archive') + .then(function () { + window.location.assign('/'); + }) + .catch(function (error) { + window.alert(error.message || 'Failed to archive folder'); + archiveButton.disabled = false; + }); + } + }); +})(); diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index 9fb2f68..29e1618 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -71,9 +71,10 @@ code { position: sticky; top: 0; z-index: 10; - border-bottom: 1px solid rgba(255, 255, 255, 0.42); - background: rgba(238, 246, 255, 0.92); + border-bottom: 1px solid rgba(24, 32, 42, 0.14); + background: rgba(253, 254, 255, 0.96); backdrop-filter: blur(12px); + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.65); } .site-header__inner, @@ -85,8 +86,9 @@ code { display: flex; align-items: center; justify-content: space-between; - min-height: 64px; - padding: 0 1rem; + gap: 0.375rem; + min-height: 40px; + padding: 0 0.5rem; } .site-brand { @@ -99,34 +101,46 @@ code { .site-brand img { display: block; width: auto; - height: 2.5rem; + height: 1.375rem; max-width: min(12rem, 42vw); object-fit: contain; + image-rendering: pixelated; } .site-nav { display: flex; - gap: 0.5rem; + align-items: center; + gap: 0.125rem; } .site-nav a { display: inline-flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; - border-radius: 50%; + width: 28px; + height: 28px; + border: 1px solid transparent; + border-radius: 3px; color: inherit; text-decoration: none; - transition: background 0.15s; + transition: background 0.1s, border-color 0.1s; } .site-nav a:hover { - background: rgba(0, 0, 0, 0.06); + background: rgba(0, 0, 0, 0.05); + border-color: rgba(24, 32, 42, 0.16); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.site-nav a:active { + background: rgba(0, 0, 0, 0.09); + box-shadow: inset 0 1px 2px rgba(24, 32, 42, 0.18); } .site-nav a svg { display: block; + width: 16px; + height: 16px; } /* Notification bell */ @@ -138,29 +152,38 @@ code { display: inline-flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; + width: 28px; + height: 28px; padding: 0; - border: 0; - border-radius: 50%; + border: 1px solid transparent; + border-radius: 3px; background: transparent; color: inherit; cursor: pointer; - transition: background 0.15s; + transition: background 0.1s, border-color 0.1s; } .notification-bell__trigger:hover { - background: rgba(0, 0, 0, 0.06); + background: rgba(0, 0, 0, 0.05); + border-color: rgba(24, 32, 42, 0.16); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.notification-bell__trigger:active { + background: rgba(0, 0, 0, 0.09); + box-shadow: inset 0 1px 2px rgba(24, 32, 42, 0.18); } .notification-bell__trigger svg { display: block; + width: 16px; + height: 16px; } .notification-bell__count { position: absolute; - top: 2px; - right: 2px; + top: -2px; + right: -2px; min-width: 16px; height: 16px; padding: 0 4px; @@ -279,13 +302,13 @@ code { display: flex; align-items: center; flex: 1; - max-width: 320px; - margin: 0 1rem; + max-width: 300px; + margin: 0 0.375rem; } .site-search__icon { position: absolute; - left: 0.6rem; + left: 0.45rem; z-index: 1; color: var(--muted); pointer-events: none; @@ -293,14 +316,16 @@ code { .site-search input { width: 100%; - padding: 0.4rem 0.75rem 0.4rem 2rem; + padding: 0.25rem 0.5rem 0.25rem 1.6rem; border: 1px solid var(--border); - border-radius: 0; - background: var(--panel); + border-radius: 3px; + background: var(--panel-strong); + box-shadow: inset 0 1px 2px rgba(24, 32, 42, 0.08); color: var(--text); font: inherit; - font-size: 0.825rem; - transition: border-color 0.15s, background 0.15s; + font-size: 0.78rem; + line-height: 1.4; + transition: border-color 0.1s, background 0.1s; } .site-search input::placeholder { @@ -311,11 +336,12 @@ code { outline: none; border-color: var(--accent); background: var(--panel-strong); + box-shadow: inset 0 1px 2px rgba(24, 32, 42, 0.08), 0 0 0 2px var(--accent-soft); } .site-main { padding: 0; - height: calc(100vh - 64px); + height: calc(100vh - 41px); } .auth-shell, @@ -1632,6 +1658,66 @@ code { flex: 0 0 auto; } +.browser-item { + display: flex; + align-items: center; +} + +.browser-item > a { + flex: 0 1 auto; + min-width: 0; +} + +.browser-item > .browser-item-chevron { + margin-left: auto; + margin-right: 0.5rem; + color: var(--muted); +} + +.browser-item-actions { + flex: 0 0 auto; + margin-right: 0.25rem; + opacity: 0; + transition: opacity 0.15s; +} + +.browser-item:hover .browser-item-actions, +.browser-item:focus-within .browser-item-actions, +.browser-item-actions[open] { + opacity: 1; +} + +.browser-item-actions > summary { + width: 1.5rem; + height: 1.5rem; + border-color: transparent; + background: transparent; + color: var(--muted); +} + +.browser-item-actions > summary:hover { + background: var(--accent-soft); + border-color: transparent; + color: var(--accent); +} + +.browser-item-actions > summary svg { + width: 0.9rem; + height: 0.9rem; +} + +.browser-item-actions .document-actions-dropdown__menu { + right: auto; + left: 0; + min-width: 10rem; +} + +@media (hover: none) { + .browser-item-actions { + opacity: 1; + } +} + /* Document shell */ .document-shell, .error-panel, @@ -2424,21 +2510,21 @@ code { @media (max-width: 768px) { .site-brand img { - height: 2.25rem; + height: 1.25rem; } .site-header__inner { flex-direction: row; align-items: center; justify-content: space-between; - gap: 0.75rem; - padding: 0.75rem 1rem; - min-height: auto; + gap: 0.375rem; + padding: 0 0.5rem; + min-height: 40px; } .site-main { height: auto; - min-height: calc(100vh - 64px); + min-height: calc(100vh - 41px); } .workspace-shell, @@ -2821,12 +2907,22 @@ code { @media (prefers-color-scheme: dark) { .site-header { - border-bottom-color: oklch(0.28 0.018 55 / 0.5); - background: oklch(0.16 0.014 55 / 0.92); + border-bottom-color: oklch(0.28 0.018 55 / 0.6); + background: oklch(0.19 0.014 55 / 0.96); + box-shadow: inset 0 -1px 0 oklch(0.26 0.016 55 / 0.5); } + .site-nav a:hover, .notification-bell__trigger:hover { - background: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.07); + border-color: oklch(0.34 0.018 55 / 0.7); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); + } + + .site-nav a:active, + .notification-bell__trigger:active { + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4); } .miller-column h2 { diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml index b9db8c7..139c6a3 100644 --- a/apps/server/internal/httpserver/templates/base.gohtml +++ b/apps/server/internal/httpserver/templates/base.gohtml @@ -115,6 +115,7 @@ + diff --git a/apps/server/internal/httpserver/templates/document.gohtml b/apps/server/internal/httpserver/templates/document.gohtml index 8beb5f3..45b2cd8 100644 --- a/apps/server/internal/httpserver/templates/document.gohtml +++ b/apps/server/internal/httpserver/templates/document.gohtml @@ -174,14 +174,35 @@