feature: move folders
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
|
||||
101
apps/server/internal/httpserver/static/folder-actions.js
Normal file
101
apps/server/internal/httpserver/static/folder-actions.js
Normal file
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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 {
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
<script src="/static/keyboard-nav.js" defer></script>
|
||||
<script src="/static/mobile.js" defer></script>
|
||||
<script src="/static/archive.js" defer></script>
|
||||
<script src="/static/folder-actions.js" defer></script>
|
||||
<script src="/static/move.js" defer></script>
|
||||
<script src="/static/permissions.js" defer></script>
|
||||
<script src="/static/tags.js" defer></script>
|
||||
|
||||
@@ -174,14 +174,35 @@
|
||||
</h2>
|
||||
<ul>
|
||||
{{ range .Items }}
|
||||
<li>
|
||||
<li class="browser-item">
|
||||
<a class="{{ if .Active }}is-active{{ end }} {{ if .IsFolder }}is-folder{{ end }}" href="{{ .URL }}" title="{{ .Name }}">
|
||||
<span class="browser-item-label">
|
||||
{{ if .IsFolder }}{{ template "icon-folder" }}{{ else }}{{ template "icon-file-text" }}{{ end }}
|
||||
<span class="browser-item-name">{{ .Name }}</span>
|
||||
</span>
|
||||
{{ if .IsFolder }}{{ template "icon-chevron-right" }}{{ end }}
|
||||
</a>
|
||||
{{ if and $.CanWrite .IsFolder }}
|
||||
<details class="browser-item-actions document-actions-dropdown">
|
||||
<summary aria-haspopup="true" aria-label="Actions for folder {{ .Name }}" title="Folder actions">
|
||||
{{ template "icon-kebab" }}
|
||||
</summary>
|
||||
<div class="document-actions-dropdown__menu">
|
||||
<button class="document-actions-dropdown__item" type="button" data-folder-rename-path="{{ .Path }}">
|
||||
{{ template "icon-rename" }}
|
||||
<span>Rename</span>
|
||||
</button>
|
||||
<button class="document-actions-dropdown__item" type="button" data-folder-move-path="{{ .Path }}">
|
||||
{{ template "icon-folder-move" }}
|
||||
<span>Move</span>
|
||||
</button>
|
||||
<button class="document-actions-dropdown__item document-actions-dropdown__item--danger" type="button" data-folder-archive-path="{{ .Path }}">
|
||||
{{ template "icon-archive" }}
|
||||
<span>Archive</span>
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
{{ end }}
|
||||
{{ if .IsFolder }}{{ template "icon-chevron-right" }}{{ end }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
@@ -237,3 +258,35 @@
|
||||
<path d="m9 18 6-6-6-6"></path>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-kebab" }}
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-rename" }}
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-folder-move" }}
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 9V5h4"></path>
|
||||
<path d="m5 5 6 6"></path>
|
||||
<path d="M19 15v4h-4"></path>
|
||||
<path d="m19 19-6-6"></path>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-archive" }}
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="21 8 21 21 3 21 3 8"></polyline>
|
||||
<rect x="1" y="3" width="22" height="5"></rect>
|
||||
<line x1="10" y1="12" x2="14" y2="12"></line>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user