diff --git a/.gitignore b/.gitignore index 15c5ac7..54e4a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ backend/*.test backend/archivum # Frontend -frontend/node_modules/ +frontend/node_modules/* frontend/dist/ frontend/.env frontend/.env.local @@ -32,4 +32,4 @@ Thumbs.db # IDE .idea/ -*.swp +*.swp \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 55e50bb..ef758be 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -367,18 +367,29 @@ Archivum/ ```graphql type Query { systemStatus: SystemStatus! # OK | REQUIRE_SETUP + serverDirectories(path: String): [ServerDirectory!]! # Listar mappar (inkl. isGitRepo) documents(prefix: String): [DocumentMeta!]! document(slug: String!): Document + folders: [String!]! # Listar alla mappvägar i repositoriet history(slug: String!): [CommitEntry!]! diff(slug: String!, fromHash: String!, toHash: String!): String! } +type ServerDirectory { + name: String! + path: String! + isGitRepo: Boolean! + hasDocuments: Boolean! +} + type Mutation { setup(input: SetupInput!): Boolean! login(username: String!, password: String!): String! # returnerar session-token logout: Boolean! saveDocument(input: SaveDocumentInput!): Document! deleteDocument(slug: String!): Boolean! + createFolder(path: String!): Boolean! # Skapar en ny mapp (katalog) + moveDocument(oldSlug: String!, newSlug: String!): Boolean! # Flyttar ett dokument till en annan plats } ``` diff --git a/backend/internal/git/git.go b/backend/internal/git/git.go index ac5c4ad..54c0362 100644 --- a/backend/internal/git/git.go +++ b/backend/internal/git/git.go @@ -121,6 +121,19 @@ func (r *Repo) CommitAll(message, author, email string) error { return err } +// Move renames a file from oldPath to newPath and creates a Git commit. +func (r *Repo) Move(oldPath, newPath, message, author, email string) error { + if _, err := r.run("mv", oldPath, newPath); err != nil { + return err + } + _, err := r.run( + "-c", fmt.Sprintf("user.name=%s", author), + "-c", fmt.Sprintf("user.email=%s", email), + "commit", "-m", message, + ) + return err +} + type LogEntry struct { Hash string Author string diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index 09c04c4..d796208 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -14,6 +14,9 @@ type Query { # List documents under an optional path prefix. documents(prefix: String): [DocumentMeta!]! + # List all relative folder paths in the repository. + folders: [String!]! + # Commit history for a document. history(slug: String!): [CommitEntry!]! @@ -25,6 +28,9 @@ type Query { # Whether the current storage path has uncommitted files. repoStatus: RepoStatus! + + # List images next to a document. + images(slug: String!): [ImageFile!]! } type Mutation { @@ -57,6 +63,15 @@ type Mutation { # Commit all uncommitted files in the storage path. initCommit(message: String!): Boolean! + + # Delete an image file. + deleteImage(slug: String!, filename: String!): Boolean! + + # Create a new folder (directory) at the specified path. + createFolder(path: String!): Boolean! + + # Move a document from one location to another. + moveDocument(oldSlug: String!, newSlug: String!): Boolean! } # ── Types ────────────────────────────────────────────────────────────────────── @@ -79,8 +94,10 @@ type LDAPConfig { } type ServerDirectory { - name: String! - path: String! + name: String! + path: String! + isGitRepo: Boolean! + hasDocuments: Boolean! } type Document { @@ -114,6 +131,12 @@ type RepoStatus { hasUncommitted: Boolean! } +type ImageFile { + name: String! + url: String! + size: Int! +} + # ── Inputs ───────────────────────────────────────────────────────────────────── input SetupInput { diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index 8927428..ed5718f 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -42,6 +42,8 @@ func NewServer(cfg *config.Config, configPath string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/graphql", s.handleGraphQL) mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/api/upload", s.handleUpload) + mux.HandleFunc("/media/", s.handleMedia) uiDir := os.Getenv("UI_DIR") if uiDir == "" { @@ -185,6 +187,9 @@ func (s *Server) dispatchAuthenticated( case strings.Contains(q, "deleteDocument"): s.handleDeleteDocument(w, req, sess) + case strings.Contains(q, "deleteImage"): + s.handleDeleteImage(w, req, sess) + case strings.Contains(q, "createUser"): s.handleCreateUser(w, req, sess) @@ -218,6 +223,18 @@ func (s *Server) dispatchAuthenticated( case strings.Contains(q, "history"): s.handleHistory(w, req, sess) + case strings.Contains(q, "images"): + s.handleImages(w, req, sess) + + case strings.Contains(q, "createFolder"): + s.handleCreateFolder(w, req, sess) + + case strings.Contains(q, "moveDocument"): + s.handleMoveDocument(w, req, sess) + + case strings.Contains(q, "folders"): + s.handleFolders(w, sess) + case strings.Contains(q, "diff"): s.handleDiff(w, req, sess) @@ -240,18 +257,24 @@ func (s *Server) handleServerDirectories(w http.ResponseWriter, req gqlRequest, // Only allow setup state without auth if cfg != nil { if sess == nil { - log.Printf("[unauth] session is nil") - writeGQLError(w, "UNAUTHORIZED") - return - } - if sess.Role != "admin" { - log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) - writeGQLError(w, "UNAUTHORIZED") - return - } + log.Printf("[unauth] session is nil") + writeGQLError(w, "UNAUTHORIZED") + return + } + if sess.Role != "admin" { + log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) + writeGQLError(w, "UNAUTHORIZED") + return + } } reqPath, _ := req.Variables["path"].(string) + if reqPath == "" { + // Also try "p" just in case the frontend sends p + if p, ok := req.Variables["p"].(string); ok && p != "" { + reqPath = p + } + } if reqPath == "" { if runtime.GOOS == "windows" { reqPath = "C:\\" @@ -268,8 +291,10 @@ func (s *Server) handleServerDirectories(w http.ResponseWriter, req gqlRequest, parent := filepath.Dir(reqPath) if parent != reqPath && parent != "." { out = append(out, map[string]interface{}{ - "name": "..", - "path": parent, + "name": "..", + "path": parent, + "isGitRepo": false, + "hasDocuments": false, }) } @@ -282,9 +307,26 @@ func (s *Server) handleServerDirectories(w http.ResponseWriter, req gqlRequest, for _, entry := range entries { if entry.IsDir() { + fullPath := filepath.Join(reqPath, entry.Name()) + isGit := false + if _, err := os.Stat(filepath.Join(fullPath, ".git")); err == nil { + isGit = true + } + + hasDocs := false + childEntries, _ := os.ReadDir(fullPath) + for _, child := range childEntries { + if !child.IsDir() && (strings.HasSuffix(child.Name(), ".md") || strings.HasSuffix(child.Name(), ".adoc")) { + hasDocs = true + break + } + } + out = append(out, map[string]interface{}{ - "name": entry.Name(), - "path": filepath.Join(reqPath, entry.Name()), + "name": entry.Name(), + "path": fullPath, + "isGitRepo": isGit, + "hasDocuments": hasDocs, }) } } @@ -299,27 +341,27 @@ func (s *Server) handleServerDirectories(w http.ResponseWriter, req gqlRequest, // ── Session helpers ─────────────────────────────────────────────────────────── func (s *Server) sessionFromRequest(r *http.Request) *auth.Session { - raw := r.Header.Get("Authorization") - if !strings.HasPrefix(raw, "Bearer ") { - log.Printf("[auth] No Bearer token found in header") - return nil - } - token := strings.TrimPrefix(raw, "Bearer ") + raw := r.Header.Get("Authorization") + if !strings.HasPrefix(raw, "Bearer ") { + log.Printf("[auth] No Bearer token found in header") + return nil + } + token := strings.TrimPrefix(raw, "Bearer ") - s.mu.RLock() - mgr := s.authMgr - s.mu.RUnlock() + s.mu.RLock() + mgr := s.authMgr + s.mu.RUnlock() - if mgr == nil { - log.Printf("[auth] authmgr is nil") - return nil - } - sess, err := mgr.Validate(token) - if err != nil { - log.Printf("[auth] Token validation failed: %v", err) - return nil - } - return sess + if mgr == nil { + log.Printf("[auth] authmgr is nil") + return nil + } + sess, err := mgr.Validate(token) + if err != nil { + log.Printf("[auth] Token validation failed: %v", err) + return nil + } + return sess } // ── Document handlers ───────────────────────────────────────────────────────── @@ -534,12 +576,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) { return } - log.Printf("[auth] login: %s (%s)", username, role) - writeJSONObj(w, map[string]interface{}{ - "data": map[string]interface{}{ - "login": token, - }, - }) + log.Printf("[auth] login: %s (%s)", username, role) + writeJSONObj(w, map[string]interface{}{ + "data": map[string]interface{}{ + "login": token, + }, + }) } func (s *Server) handleLogout(w http.ResponseWriter, sess *auth.Session) { @@ -559,15 +601,15 @@ func (s *Server) handleLogout(w http.ResponseWriter, sess *auth.Session) { func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) { if sess == nil { - log.Printf("[unauth] session is nil") - writeGQLError(w, "UNAUTHORIZED") - return - } - if sess.Role != "admin" { - log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) - writeGQLError(w, "UNAUTHORIZED") - return - } + log.Printf("[unauth] session is nil") + writeGQLError(w, "UNAUTHORIZED") + return + } + if sess.Role != "admin" { + log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) + writeGQLError(w, "UNAUTHORIZED") + return + } s.mu.RLock() database := s.database @@ -600,15 +642,15 @@ func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) { func (s *Server) handleCreateUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { - log.Printf("[unauth] session is nil") - writeGQLError(w, "UNAUTHORIZED") - return - } - if sess.Role != "admin" { - log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) - writeGQLError(w, "UNAUTHORIZED") - return - } + log.Printf("[unauth] session is nil") + writeGQLError(w, "UNAUTHORIZED") + return + } + if sess.Role != "admin" { + log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) + writeGQLError(w, "UNAUTHORIZED") + return + } username, _ := req.Variables["u"].(string) password, _ := req.Variables["p"].(string) @@ -655,15 +697,15 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, req gqlRequest, sess *a func (s *Server) handleDeleteUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { - log.Printf("[unauth] session is nil") - writeGQLError(w, "UNAUTHORIZED") - return - } - if sess.Role != "admin" { - log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) - writeGQLError(w, "UNAUTHORIZED") - return - } + log.Printf("[unauth] session is nil") + writeGQLError(w, "UNAUTHORIZED") + return + } + if sess.Role != "admin" { + log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) + writeGQLError(w, "UNAUTHORIZED") + return + } username, _ := req.Variables["username"].(string) if username == "" { @@ -745,15 +787,15 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, req gqlRequest, ses func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { - log.Printf("[unauth] session is nil") - writeGQLError(w, "UNAUTHORIZED") - return - } - if sess.Role != "admin" { - log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) - writeGQLError(w, "UNAUTHORIZED") - return - } + log.Printf("[unauth] session is nil") + writeGQLError(w, "UNAUTHORIZED") + return + } + if sess.Role != "admin" { + log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) + writeGQLError(w, "UNAUTHORIZED") + return + } s.mu.RLock() cfg := s.cfg @@ -801,15 +843,15 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) { if sess == nil { - log.Printf("[unauth] session is nil") - writeGQLError(w, "UNAUTHORIZED") - return - } - if sess.Role != "admin" { - log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) - writeGQLError(w, "UNAUTHORIZED") - return - } + log.Printf("[unauth] session is nil") + writeGQLError(w, "UNAUTHORIZED") + return + } + if sess.Role != "admin" { + log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) + writeGQLError(w, "UNAUTHORIZED") + return + } s.mu.RLock() cfg := s.cfg @@ -899,15 +941,15 @@ func (s *Server) handleInitCommit(w http.ResponseWriter, req gqlRequest, sess *a func (s *Server) handleUpdateStoragePath(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { - log.Printf("[unauth] session is nil") - writeGQLError(w, "UNAUTHORIZED") - return - } - if sess.Role != "admin" { - log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) - writeGQLError(w, "UNAUTHORIZED") - return - } + log.Printf("[unauth] session is nil") + writeGQLError(w, "UNAUTHORIZED") + return + } + if sess.Role != "admin" { + log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) + writeGQLError(w, "UNAUTHORIZED") + return + } s.mu.RLock() cfg := s.cfg @@ -1227,78 +1269,359 @@ func resolveDBPath(configPath string) string { return filepath.Join(dirOf(configPath), "archivum.db") } - - func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { - if sess == nil { writeGQLError(w, "UNAUTHORIZED"); return } - slug, _ := req.Variables["slug"].(string) - if slug == "" { writeGQLError(w, "slug required"); return } + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + slug, _ := req.Variables["slug"].(string) + if slug == "" { + writeGQLError(w, "slug required") + return + } - s.mu.RLock() - repo := s.gitRepo - s.mu.RUnlock() + s.mu.RLock() + repo := s.gitRepo + s.mu.RUnlock() - if repo == nil { - writeGQLError(w, "git not initialized") - return - } + if repo == nil { + writeGQLError(w, "git not initialized") + return + } - history, err := repo.Log(slug + ".adoc") - if err != nil { - writeGQLError(w, err.Error()) - return - } + history, err := repo.Log(slug + ".adoc") + if err != nil { + writeGQLError(w, err.Error()) + return + } - var out []map[string]interface{} - for _, entry := range history { - out = append(out, map[string]interface{}{ - "hash": entry.Hash, - "author": entry.Author, - "email": entry.Email, - "date": entry.Date, - "subject": entry.Subject, - "added": entry.Added, - "removed": entry.Removed, - }) - } - writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "history": out } }) + var out []map[string]interface{} + for _, entry := range history { + out = append(out, map[string]interface{}{ + "hash": entry.Hash, + "author": entry.Author, + "email": entry.Email, + "date": entry.Date, + "subject": entry.Subject, + "added": entry.Added, + "removed": entry.Removed, + }) + } + writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"history": out}}) } func (s *Server) handleDiff(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { - if sess == nil { writeGQLError(w, "UNAUTHORIZED"); return } - slug, _ := req.Variables["slug"].(string) - fromHash, _ := req.Variables["fromHash"].(string) - toHash, _ := req.Variables["toHash"].(string) + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + slug, _ := req.Variables["slug"].(string) + fromHash, _ := req.Variables["fromHash"].(string) + toHash, _ := req.Variables["toHash"].(string) - s.mu.RLock() - repo := s.gitRepo - s.mu.RUnlock() + s.mu.RLock() + repo := s.gitRepo + s.mu.RUnlock() - if repo == nil { writeGQLError(w, "git not initialized"); return } + if repo == nil { + writeGQLError(w, "git not initialized") + return + } - diff, err := repo.Diff(slug + ".adoc", fromHash, toHash) - if err != nil { writeGQLError(w, err.Error()); return } + diff, err := repo.Diff(slug+".adoc", fromHash, toHash) + if err != nil { + writeGQLError(w, err.Error()) + return + } - writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "diff": diff } }) + writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"diff": diff}}) } func (s *Server) handleDocumentAtCommit(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { - if sess == nil { writeGQLError(w, "UNAUTHORIZED"); return } - slug, _ := req.Variables["slug"].(string) - hash, _ := req.Variables["hash"].(string) + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + slug, _ := req.Variables["slug"].(string) + hash, _ := req.Variables["hash"].(string) - s.mu.RLock() - repo := s.gitRepo - s.mu.RUnlock() + s.mu.RLock() + repo := s.gitRepo + s.mu.RUnlock() - if repo == nil { writeGQLError(w, "git not initialized"); return } + if repo == nil { + writeGQLError(w, "git not initialized") + return + } - content, err := repo.Show(hash, slug + ".adoc") - if err != nil { writeGQLError(w, err.Error()); return } + content, err := repo.Show(hash, slug+".adoc") + if err != nil { + writeGQLError(w, err.Error()) + return + } - writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "documentAtCommit": content } }) + writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"documentAtCommit": content}}) +} + +func (s *Server) handleCreateFolder(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + + path, _ := req.Variables["path"].(string) + if path == "" { + writeGQLError(w, "path is required") + return + } + + s.mu.RLock() + store := s.store + s.mu.RUnlock() + + if store == nil { + writeGQLError(w, "storage not initialised") + return + } + + if err := store.CreateFolder(path); err != nil { + log.Printf("[storage] failed to create folder %q: %v", path, err) + writeGQLError(w, fmt.Sprintf("failed to create folder: %v", err)) + return + } + + log.Printf("[storage] folder created at %q by %s", path, sess.Username) + writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"createFolder": true}}) +} + +func (s *Server) handleMoveDocument(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + + oldSlug, _ := req.Variables["oldSlug"].(string) + newSlug, _ := req.Variables["newSlug"].(string) + + if oldSlug == "" { + writeGQLError(w, "oldSlug is required") + return + } + if newSlug == "" { + writeGQLError(w, "newSlug is required") + return + } + + s.mu.RLock() + store := s.store + repo := s.gitRepo + s.mu.RUnlock() + + if store == nil { + writeGQLError(w, "storage not initialised") + return + } + + var err error + if repo != nil { + // Attempt to use git mv + dir := filepath.ToSlash(filepath.Dir(newSlug)) + if dir != "." && dir != "" { + _ = store.CreateFolder(dir) + } + err = repo.Move(oldSlug+".adoc", newSlug+".adoc", "Move "+oldSlug+" to "+newSlug, sess.Username, "") + if err != nil { + err = store.MoveDocument(oldSlug, newSlug) + } + } else { + err = store.MoveDocument(oldSlug, newSlug) + } + + if err != nil { + log.Printf("[storage] failed to move document from %q to %q: %v", oldSlug, newSlug, err) + writeGQLError(w, fmt.Sprintf("failed to move document: %v", err)) + return + } + + log.Printf("[storage] document moved from %q to %q by %s", oldSlug, newSlug, sess.Username) + writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"moveDocument": true}}) +} + +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.RLock() + am := s.authMgr + store := s.store + s.mu.RUnlock() + + // Check auth + authHeader := r.Header.Get("Authorization") + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == "" || am == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + sess, errLogin := am.Validate(token) + if errLogin != nil || sess == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + err := r.ParseMultipartForm(10 << 20) // 10 MB + if err != nil { + http.Error(w, "Failed to parse form", http.StatusBadRequest) + return + } + + slug := r.FormValue("slug") + if slug == "" { + http.Error(w, "Missing slug", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("image") + if err != nil { + http.Error(w, "Missing image", http.StatusBadRequest) + return + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "Failed to read image", http.StatusInternalServerError) + return + } + + if err := store.SaveImage(slug, header.Filename, data); err != nil { + http.Error(w, "Failed to save image", http.StatusInternalServerError) + return + } + + // Calculate url + relPath := strings.TrimPrefix(filepath.ToSlash(filepath.Join(filepath.Dir(slug), header.Filename)), "/") + url := "/media/" + relPath + + resp := map[string]interface{}{"url": url, "name": header.Filename, "size": len(data)} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.RLock() + store := s.store + s.mu.RUnlock() + + if store == nil { + http.Error(w, "Storage not initialized", http.StatusInternalServerError) + return + } + + relPath := strings.TrimPrefix(r.URL.Path, "/media/") + fullPath, err := store.GetImagePath(relPath) + if err != nil { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + http.ServeFile(w, r, fullPath) +} +func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) { + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + + s.mu.RLock() + store := s.store + s.mu.RUnlock() + + if store == nil { + writeGQLError(w, "storage not initialised") + return + } + + folders, err := store.ListFolders() + if err != nil { + log.Printf("[folders] failed to list folders: %v", err) + writeGQLError(w, fmt.Sprintf("failed to list folders: %v", err)) + return + } + + log.Printf("[folders] user %q listed folders (found %d folders)", sess.Username, len(folders)) + + writeJSONObj(w, map[string]interface{}{ + "data": map[string]interface{}{ + "folders": folders, + }, + }) +} + +func (s *Server) handleImages(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { +if sess == nil { +writeGQLError(w, "UNAUTHORIZED") +return +} +slug, _ := req.Variables["slug"].(string) +if slug == "" { +writeGQLError(w, "slug is required") +return +} + +s.mu.RLock() +store := s.store +s.mu.RUnlock() + +if store == nil { +writeGQLError(w, "storage not initialized") +return +} + +images, err := store.ListImages(slug) +if err != nil { +writeGQLError(w, fmt.Sprintf("failed to list images: %v", err)) +return +} + +writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"images": images}}) +} + +func (s *Server) handleDeleteImage(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { +if sess == nil { +writeGQLError(w, "UNAUTHORIZED") +return +} + +slug, _ := req.Variables["slug"].(string) +filename, _ := req.Variables["filename"].(string) + +if slug == "" || filename == "" { +writeGQLError(w, "slug and filename are required") +return +} + +s.mu.RLock() +store := s.store +s.mu.RUnlock() + +if store == nil { +writeGQLError(w, "storage not initialized") +return +} + +if err := store.DeleteImage(slug, filename); err != nil { +writeGQLError(w, fmt.Sprintf("failed to delete image: %v", err)) +return +} + +log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username) +writeJSON(w, `{"data":{"deleteImage":true}}`) } - - diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index c378baa..6a2da45 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -40,6 +40,26 @@ func (s *Store) Write(slug, content string) error { return os.WriteFile(path, []byte(content), 0644) } +// CreateFolder creates a new folder/directory. +func (s *Store) CreateFolder(folderSlug string) error { + path := s.folderPath(folderSlug) + return os.MkdirAll(path, 0755) +} + +// MoveDocument moves a document from one location to another. +func (s *Store) MoveDocument(fromSlug, toSlug string) error { + fromPath := s.filePath(fromSlug) + toPath := s.filePath(toSlug) + + // Ensure target directory exists + if err := os.MkdirAll(filepath.Dir(toPath), 0755); err != nil { + return err + } + + // Move the file + return os.Rename(fromPath, toPath) +} + // Delete removes a document from disk. func (s *Store) Delete(slug string) error { return os.Remove(s.filePath(slug)) @@ -63,8 +83,91 @@ func (s *Store) List(prefix string) ([]string, error) { return slugs, err } +// ListImages returns all images in the directory of a given slug. +func (s *Store) ListImages(slug string) ([]map[string]interface{}, error) { + dir := filepath.Dir(s.filePath(slug)) + var images []map[string]interface{} + + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return images, nil + } + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + ext := strings.ToLower(filepath.Ext(name)) + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" || ext == ".svg" || ext == ".webp" { + info, err := entry.Info() + if err == nil { + images = append(images, map[string]interface{}{ + "name": name, + "size": info.Size(), + // The URL is relative to the media endpoint mapped in the server. + "url": "/media/" + strings.TrimPrefix(filepath.ToSlash(filepath.Join(filepath.Dir(slug), name)), "/"), + }) + } + } + } + return images, nil +} + +// SaveImage saves an uploaded image file into the directory of a slug. +func (s *Store) SaveImage(slug, filename string, data []byte) error { + dir := filepath.Dir(s.filePath(slug)) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, filename), data, 0644) +} + +// DeleteImage removes an image file from the directory of a slug. +func (s *Store) DeleteImage(slug, filename string) error { + dir := filepath.Dir(s.filePath(slug)) + return os.Remove(filepath.Join(dir, filename)) +} + +// GetImagePath returns the absolute path on disk for a media file. +// It prevents directory traversal out of the root storage path. +func (s *Store) GetImagePath(relPath string) (string, error) { + if strings.Contains(relPath, "..") { + return "", errors.New("invalid path") + } + return filepath.Join(s.root, filepath.FromSlash(relPath)), nil +} + +// ListFolders returns all folder paths relative to root, excluding .git. +func (s *Store) ListFolders() ([]string, error) { + var folders []string + err := filepath.WalkDir(s.root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == ".git" { + return filepath.SkipDir + } + if path != s.root { + rel, _ := filepath.Rel(s.root, path) + folders = append(folders, filepath.ToSlash(rel)) + } + } + return nil + }) + return folders, err +} + func (s *Store) filePath(slug string) string { return filepath.Join(s.root, filepath.FromSlash(slug)+".adoc") } +func (s *Store) folderPath(folderSlug string) string { + return filepath.Join(s.root, filepath.FromSlash(folderSlug)) +} + var ErrNotFound = errors.New("document not found") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4310726..e2255f6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "archivum", "version": "0.1.0", "dependencies": { + "@tailwindcss/typography": "^0.5.19", "@tiptap/core": "^2.4.0", "@tiptap/extension-blockquote": "^2.27.2", "@tiptap/extension-bold": "^2.27.2", @@ -54,7 +55,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2219,7 +2219,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2241,7 +2240,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2268,7 +2266,6 @@ "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2356,7 +2353,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2370,7 +2366,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2380,7 +2375,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2863,6 +2857,31 @@ "sourcemap-codec": "^1.4.8" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tiptap/core": { "version": "2.27.2", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", @@ -3614,14 +3633,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "devOptional": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3635,7 +3652,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -3873,7 +3889,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -3895,7 +3910,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "devOptional": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3997,7 +4011,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -4037,7 +4050,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "devOptional": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -4062,7 +4074,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "devOptional": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4221,7 +4232,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -4365,14 +4375,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/doctypes": { @@ -4655,7 +4663,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -4672,7 +4679,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4709,7 +4715,6 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -4728,7 +4733,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "devOptional": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4975,7 +4979,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -5251,7 +5254,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "devOptional": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -5354,7 +5356,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5409,7 +5410,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5455,7 +5455,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5712,7 +5711,6 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -5811,7 +5809,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -5824,7 +5821,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/linkify-it": { @@ -5918,7 +5914,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -5928,7 +5923,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -5987,7 +5981,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -6050,7 +6043,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6094,7 +6086,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -6244,7 +6235,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6257,7 +6247,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6289,7 +6278,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -6337,7 +6325,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -6355,7 +6342,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6381,7 +6367,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6424,7 +6409,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6450,7 +6434,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -6464,7 +6447,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/pretty-bytes": { @@ -6831,7 +6813,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -6862,7 +6843,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -6872,7 +6852,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "devOptional": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -7026,7 +7005,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -7088,7 +7066,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -7574,7 +7551,6 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -7597,7 +7573,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -7619,7 +7594,6 @@ "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -7725,7 +7699,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -7735,7 +7708,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -7748,7 +7720,6 @@ "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -7765,7 +7736,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -7783,7 +7753,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7805,7 +7774,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "devOptional": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -7830,7 +7798,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/type-fest": { @@ -8105,7 +8072,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vite": { diff --git a/frontend/package.json b/frontend/package.json index a8235c2..e78da73 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "type-check": "vue-tsc --noEmit" }, "dependencies": { + "@tailwindcss/typography": "^0.5.19", "@tiptap/core": "^2.4.0", "@tiptap/extension-blockquote": "^2.27.2", "@tiptap/extension-bold": "^2.27.2", diff --git a/frontend/src/components/FolderPicker.vue b/frontend/src/components/FolderPicker.vue index 50b741e..fa66221 100644 --- a/frontend/src/components/FolderPicker.vue +++ b/frontend/src/components/FolderPicker.vue @@ -14,15 +14,15 @@ const emit = defineEmits<{ const open = ref(false) const currentPath = ref(props.modelValue) -const directories = ref<{ name: string; path: string }[]>([]) +const directories = ref<{ name: string; path: string; isGitRepo: boolean; hasDocuments: boolean }[]>([]) const loading = ref(false) async function fetchDirectories(path: string) { loading.value = true try { - const data = await gql<{ serverDirectories: { name: string; path: string }[] }>( - `query Dirs($p: String) { serverDirectories(path: $p) { name path } }`, - { p: path || null } + const data = await gql<{ serverDirectories: { name: string; path: string; isGitRepo: boolean; hasDocuments: boolean }[] }>( + `query Dirs($path: String) { serverDirectories(path: $path) { name path isGitRepo hasDocuments } }`, + { path: path || null } ) directories.value = data.serverDirectories // Update input box to the fetched path if we passed empty and it resolved to root @@ -109,8 +109,10 @@ watch(() => props.modelValue, (val) => { class="w-fulltext-left flex items-center gap-2 px-3 py-2 w-full hover:bg-accent-50 dark:hover:bg-accent-500/10 focus:bg-accent-50 focus:outline-none transition group" @click="navigate(dir.path)" > - + {{ dir.name }} + docs + git diff --git a/frontend/src/components/layout/Sidebar.vue b/frontend/src/components/layout/Sidebar.vue index 58c7601..9019f7e 100644 --- a/frontend/src/components/layout/Sidebar.vue +++ b/frontend/src/components/layout/Sidebar.vue @@ -1,7 +1,7 @@